Commit bdf83bd8 authored by Eliot Berriot's avatar Eliot Berriot 💬

Resolve "Hide an artist in the UI"

parent d4d4e60e
......@@ -40,6 +40,12 @@ v1_patterns += [
r"^manage/",
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
),
url(
r"^moderation/",
include(
("funkwhale_api.moderation.urls", "moderation"), namespace="moderation"
),
),
url(
r"^federation/",
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.moderation import filters as moderation_filters
from . import models
class TrackFavoriteFilter(filters.FilterSet):
class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(
search_fields=["track__title", "track__artist__name", "track__album__title"]
)
......@@ -13,3 +12,6 @@ class TrackFavoriteFilter(filters.FilterSet):
class Meta:
model = models.TrackFavorite
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
from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track
from funkwhale_api.music import utils as music_utils
from . import models, serializers
from . import filters, models, serializers
class ListeningViewSet(
......@@ -25,6 +25,7 @@ class ListeningViewSet(
IsAuthenticatedOrReadOnly,
]
owner_checks = ["write"]
filterset_class = filters.ListeningFilter
def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]:
......
......@@ -28,3 +28,10 @@ class InstancePolicyAdmin(admin.ModelAdmin):
"summary",
]
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
from funkwhale_api.factories import registry, NoUpdateOnCreate
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
......@@ -21,3 +23,17 @@ class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
for_actor = factory.Trait(
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):
return {"type": "actor", "obj": self.target_actor}
if self.target_domain_id:
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
from funkwhale_api.common import fields
from funkwhale_api.common import search
from funkwhale_api.moderation import filters as moderation_filters
from . import models
from . import utils
class ArtistFilter(filters.FilterSet):
class ArtistFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(search_fields=["name"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
......@@ -17,13 +18,14 @@ class ArtistFilter(filters.FilterSet):
"name": ["exact", "iexact", "startswith", "icontains"],
"playable": "exact",
}
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
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"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
......@@ -36,6 +38,7 @@ class TrackFilter(filters.FilterSet):
"album": ["exact"],
"license": ["exact"],
}
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
......@@ -85,13 +88,14 @@ class UploadFilter(filters.FilterSet):
return queryset.playable_by(actor, value)
class AlbumFilter(filters.FilterSet):
class AlbumFilter(moderation_filters.HiddenContentFilterSet):
playable = filters.BooleanFilter(field_name="_", method="filter_playable")
q = fields.SearchFilter(search_fields=["title", "artist__name"])
class Meta:
model = models.Album
fields = ["playable", "q", "artist"]
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request)
......
......@@ -18,6 +18,7 @@ from taggit.models import Tag
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences
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 import api_serializers as federation_api_serializers
from funkwhale_api.federation import routes
......@@ -58,7 +59,7 @@ class TagViewSetMixin(object):
return queryset
class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
queryset = models.Artist.objects.all()
serializer_class = serializers.ArtistWithAlbumsSerializer
permission_classes = [common_permissions.ConditionalAuthentication]
......@@ -82,7 +83,7 @@ class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
)
class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
queryset = (
models.Album.objects.all().order_by("artist", "release_date").select_related()
)
......@@ -166,7 +167,9 @@ class LibraryViewSet(
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.
"""
......
......@@ -17,7 +17,7 @@ class PlaylistQuerySet(models.QuerySet):
def with_covers(self):
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",
......
......@@ -117,9 +117,21 @@ class PlaylistSerializer(serializers.ModelSerializer):
except AttributeError:
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 = []
max_covers = 5
for plt in plts:
if plt.track.album.artist_id in excluded_artists:
continue
url = plt.track.album.cover.crop["200x200"].url
if url in covers:
continue
......
......@@ -5,6 +5,7 @@ from django.db import connection
from rest_framework import serializers
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.users.models import User
......@@ -43,7 +44,14 @@ class SessionRadio(SimpleRadio):
return self.session
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):
return {}
......
......@@ -13,6 +13,7 @@ import funkwhale_api
from funkwhale_api.activity import record
from funkwhale_api.common import fields, preferences, utils as common_utils
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 utils
from funkwhale_api.music import views as music_views
......@@ -152,8 +153,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getArtists",
)
def get_artists(self, request, *args, **kwargs):
artists = music_models.Artist.objects.all().playable_by(
utils.get_actor_from_request(request)
artists = (
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
payload = {"artists": data}
......@@ -167,8 +174,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getIndexes",
)
def get_indexes(self, request, *args, **kwargs):
artists = music_models.Artist.objects.all().playable_by(
utils.get_actor_from_request(request)
artists = (
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
payload = {"indexes": data}
......@@ -273,7 +286,11 @@ class SubsonicViewSet(viewsets.GenericViewSet):
def get_random_songs(self, request, *args, **kwargs):
data = request.GET or request.POST
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)
try:
size = int(data["size"])
......@@ -308,8 +325,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getAlbumList2",
)
def get_album_list2(self, request, *args, **kwargs):
queryset = music_models.Album.objects.with_tracks_count().order_by(
"artist__name"
queryset = (
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
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]
from funkwhale_api.history import filters
from funkwhale_api.history import models
def test_listening_filter_track_artist(factories, mocker, queryset_equal_list):
factories["history.Listening"]()
cf = factories["moderation.UserFilter"](for_artist=True)
hidden_listening = factories["history.Listening"](track__artist=cf.target_artist)
qs = models.Listening.objects.all()
filterset = filters.ListeningFilter(
{"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
)
assert filterset.qs == [hidden_listening]
def test_listening_filter_track_album_artist(factories, mocker, queryset_equal_list):
factories["history.Listening"]()
cf = factories["moderation.UserFilter"](for_artist=True)
hidden_listening = factories["history.Listening"](
track__album__artist=cf.target_artist
)
qs = models.Listening.objects.all()
filterset = filters.ListeningFilter(
{"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
)
assert filterset.qs == [hidden_listening]
from funkwhale_api.moderation import filters
from funkwhale_api.music import models as music_models
def test_hidden_defaults_to_true(factories, queryset_equal_list, mocker):
user = factories["users.User"]()
artist = factories["music.Artist"]()
hidden_artist = factories["music.Artist"]()
factories["moderation.UserFilter"](target_artist=hidden_artist, user=user)
class FS(filters.HiddenContentFilterSet):
class Meta:
hidden_content_fields_mapping = {"target_artist": ["pk"]}
filterset = FS(
data={},
queryset=music_models.Artist.objects.all(),
request=mocker.Mock(user=user),
)
assert filterset.data["hidden"] is False
queryset = filterset.filter_hidden_content(
music_models.Artist.objects.all(), "", False
)
assert queryset == [artist]
def test_hidden_false(factories, queryset_equal_list, mocker):
user = factories["users.User"]()
factories["music.Artist"]()
hidden_artist = factories["music.Artist"]()
factories["moderation.UserFilter"](target_artist=hidden_artist, user=user)
class FS(filters.HiddenContentFilterSet):
class Meta:
hidden_content_fields_mapping = {"target_artist": ["pk"]}
filterset = FS(
data={},
queryset=music_models.Artist.objects.all(),
request=mocker.Mock(user=user),
)
queryset = filterset.filter_hidden_content(
music_models.Artist.objects.all(), "", True
)
assert queryset == [hidden_artist]
def test_hidden_anon