Merge branch '432-tags-radio' into 'develop'

See #432: tags radio and search

See merge request funkwhale/funkwhale!834
This commit is contained in:
Eliot Berriot 2019-07-19 06:58:25 +02:00
commit 9376f808e9
9 changed files with 134 additions and 24 deletions

View File

@ -4,7 +4,6 @@ import urllib
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.db.models import Count, Prefetch, Sum, F, Q from django.db.models import Count, Prefetch, Sum, F, Q
from django.db.models.functions import Length
from django.utils import timezone from django.utils import timezone
from rest_framework import mixins from rest_framework import mixins
@ -24,6 +23,7 @@ from funkwhale_api.federation import api_serializers as federation_api_serialize
from funkwhale_api.federation import decorators as federation_decorators from funkwhale_api.federation import decorators as federation_decorators
from funkwhale_api.federation import routes from funkwhale_api.federation import routes
from funkwhale_api.tags.models import Tag, TaggedItem from funkwhale_api.tags.models import Tag, TaggedItem
from funkwhale_api.tags.serializers import TagSerializer
from funkwhale_api.users.oauth import permissions as oauth_permissions from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, licenses, models, serializers, tasks, utils from . import filters, licenses, models, serializers, tasks, utils
@ -339,7 +339,7 @@ def handle_serve(upload, user, format=None, max_bitrate=None, proxy_media=True):
f = transcoded_version f = transcoded_version
file_path = get_file_path(f.audio_file) file_path = get_file_path(f.audio_file)
mt = f.mimetype mt = f.mimetype
if not proxy_media: if not proxy_media and f.audio_file:
# we simply issue a 302 redirect to the real URL # we simply issue a 302 redirect to the real URL
response = Response(status=302) response = Response(status=302)
response["Location"] = f.audio_file.url response["Location"] = f.audio_file.url
@ -482,6 +482,7 @@ class Search(views.APIView):
"albums": serializers.AlbumSerializer( "albums": serializers.AlbumSerializer(
self.get_albums(query), many=True self.get_albums(query), many=True
).data, ).data,
"tags": TagSerializer(self.get_tags(query), many=True).data,
} }
return Response(results, status=200) return Response(results, status=200)
@ -520,15 +521,8 @@ class Search(views.APIView):
def get_tags(self, query): def get_tags(self, query):
search_fields = ["name__unaccent"] search_fields = ["name__unaccent"]
query_obj = utils.get_query(query, search_fields) query_obj = utils.get_query(query, search_fields)
qs = Tag.objects.all().filter(query_obj)
# We want the shortest tag first return common_utils.order_for_search(qs, "name")[: self.max_results]
qs = (
Tag.objects.all()
.annotate(name_length=Length("name"))
.order_by("name_length")
)
return qs.filter(query_obj)[: self.max_results]
class LicenseViewSet(viewsets.ReadOnlyModelViewSet): class LicenseViewSet(viewsets.ReadOnlyModelViewSet):

View File

@ -178,9 +178,9 @@ class TagFilter(RadioFilter):
"autocomplete_fields": { "autocomplete_fields": {
"remoteValues": "results", "remoteValues": "results",
"name": "name", "name": "name",
"value": "slug", "value": "name",
}, },
"autocomplete_qs": "query={query}", "autocomplete_qs": "q={query}&ordering=length",
"label": "Tags", "label": "Tags",
"placeholder": "Select tags", "placeholder": "Select tags",
} }
@ -189,4 +189,8 @@ class TagFilter(RadioFilter):
label = "Tag" label = "Tag"
def get_query(self, candidates, names, **kwargs): def get_query(self, candidates, names, **kwargs):
return Q(tags__slug__in=names) return (
Q(tagged_items__tag__name__in=names)
| Q(artist__tagged_items__tag__name__in=names)
| Q(album__tagged_items__tag__name__in=names)
)

View File

@ -2,6 +2,7 @@ import random
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import connection from django.db import connection
from django.db.models import Q
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.moderation import filters as moderation_filters from funkwhale_api.moderation import filters as moderation_filters
@ -14,6 +15,8 @@ from .registries import registry
class SimpleRadio(object): class SimpleRadio(object):
related_object_field = None
def clean(self, instance): def clean(self, instance):
return return
@ -146,6 +149,8 @@ class CustomRadio(SessionRadio):
class RelatedObjectRadio(SessionRadio): class RelatedObjectRadio(SessionRadio):
"""Abstract radio related to an object (tag, artist, user...)""" """Abstract radio related to an object (tag, artist, user...)"""
related_object_field = serializers.IntegerField(required=True)
def clean(self, instance): def clean(self, instance):
super().clean(instance) super().clean(instance)
if not instance.related_object: if not instance.related_object:
@ -162,10 +167,22 @@ class RelatedObjectRadio(SessionRadio):
@registry.register(name="tag") @registry.register(name="tag")
class TagRadio(RelatedObjectRadio): class TagRadio(RelatedObjectRadio):
model = Tag model = Tag
related_object_field = serializers.CharField(required=True)
def get_related_object(self, name):
return self.model.objects.get(name=name)
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs) qs = super().get_queryset(**kwargs)
return qs.filter(tagged_items__tag=self.session.related_object) query = (
Q(tagged_items__tag=self.session.related_object)
| Q(artist__tagged_items__tag=self.session.related_object)
| Q(album__tagged_items__tag=self.session.related_object)
)
return qs.filter(query)
def get_related_object_id_repr(self, obj):
return obj.name
def weighted_choice(choices): def weighted_choice(choices):

View File

@ -54,6 +54,9 @@ class RadioSessionTrackSerializer(serializers.ModelSerializer):
class RadioSessionSerializer(serializers.ModelSerializer): class RadioSessionSerializer(serializers.ModelSerializer):
related_object_id = serializers.CharField(required=False, allow_null=True)
class Meta: class Meta:
model = models.RadioSession model = models.RadioSession
fields = ( fields = (
@ -66,7 +69,17 @@ class RadioSessionSerializer(serializers.ModelSerializer):
) )
def validate(self, data): def validate(self, data):
registry[data["radio_type"]]().validate_session(data, **self.context) radio_conf = registry[data["radio_type"]]()
if radio_conf.related_object_field:
try:
data[
"related_object_id"
] = radio_conf.related_object_field.to_internal_value(
data["related_object_id"]
)
except KeyError:
raise serializers.ValidationError("Radio requires a related object")
radio_conf.validate_session(data, **self.context)
return data return data
def create(self, validated_data): def create(self, validated_data):
@ -77,3 +90,11 @@ class RadioSessionSerializer(serializers.ModelSerializer):
validated_data["related_object_id"] validated_data["related_object_id"]
) )
return super().create(validated_data) return super().create(validated_data)
def to_representation(self, instance):
repr = super().to_representation(instance)
radio_conf = registry[repr["radio_type"]]()
handler = getattr(radio_conf, "get_related_object_id_repr", None)
if handler and instance.related_object:
repr["related_object_id"] = handler(instance.related_object)
return repr

View File

@ -197,16 +197,19 @@ def test_can_start_artist_radio(factories):
def test_can_start_tag_radio(factories): def test_can_start_tag_radio(factories):
user = factories["users.User"]() user = factories["users.User"]()
factories["music.Upload"].create_batch(5)
tag = factories["tags.Tag"]() tag = factories["tags.Tag"]()
good_files = factories["music.Upload"].create_batch(5, track__set_tags=[tag]) good_tracks = [
good_tracks = [f.track for f in good_files] factories["music.Track"](set_tags=[tag.name]),
factories["music.Track"](album__set_tags=[tag.name]),
factories["music.Track"](album__artist__set_tags=[tag.name]),
]
factories["music.Track"].create_batch(3, set_tags=["notrock"])
radio = radios.TagRadio() radio = radios.TagRadio()
session = radio.start_session(user, related_object=tag) session = radio.start_session(user, related_object=tag)
assert session.radio_type == "tag" assert session.radio_type == "tag"
for i in range(5): for i in range(3):
assert radio.pick(filter_playable=False) in good_tracks assert radio.pick(filter_playable=False) in good_tracks

View File

@ -0,0 +1,44 @@
from funkwhale_api.radios import serializers
def test_create_tag_radio(factories):
tag = factories["tags.Tag"]()
data = {"radio_type": "tag", "related_object_id": tag.name}
serializer = serializers.RadioSessionSerializer(data=data)
assert serializer.is_valid(raise_exception=True) is True
session = serializer.save()
assert session.related_object_id == tag.pk
assert session.related_object == tag
def test_create_artist_radio(factories):
artist = factories["music.Artist"]()
data = {"radio_type": "artist", "related_object_id": artist.pk}
serializer = serializers.RadioSessionSerializer(data=data)
assert serializer.is_valid(raise_exception=True) is True
session = serializer.save()
assert session.related_object_id == artist.pk
assert session.related_object == artist
def test_tag_radio_repr(factories):
tag = factories["tags.Tag"]()
session = factories["radios.RadioSession"](related_object=tag, radio_type="tag")
expected = {
"id": session.pk,
"radio_type": "tag",
"custom_radio": None,
"user": session.user.pk,
"related_object_id": tag.name,
"creation_date": session.creation_date.isoformat().split("+")[0] + "Z",
}
assert serializers.RadioSessionSerializer(session).data == expected

View File

@ -32,6 +32,7 @@ export default {
let artistLabel = this.$pgettext('*/*/*/Noun', 'Artist') let artistLabel = this.$pgettext('*/*/*/Noun', 'Artist')
let albumLabel = this.$pgettext('*/*/*', 'Album') let albumLabel = this.$pgettext('*/*/*', 'Album')
let trackLabel = this.$pgettext('*/*/*/Noun', 'Track') let trackLabel = this.$pgettext('*/*/*/Noun', 'Track')
let tagLabel = this.$pgettext('*/*/*/Noun', 'Tag')
let self = this let self = this
var searchQuery; var searchQuery;
@ -75,6 +76,9 @@ export default {
}, },
getDescription (r) { getDescription (r) {
return '' return ''
},
getId (t) {
return t.id
} }
}, },
{ {
@ -86,6 +90,9 @@ export default {
}, },
getDescription (r) { getDescription (r) {
return '' return ''
},
getId (t) {
return t.id
} }
}, },
{ {
@ -97,6 +104,23 @@ export default {
}, },
getDescription (r) { getDescription (r) {
return '' return ''
},
getId (t) {
return t.id
}
},
{
code: 'tags',
route: 'library.tags.detail',
name: tagLabel,
getTitle (r) {
return r.name
},
getDescription (r) {
return ''
},
getId (t) {
return t.name
} }
} }
] ]
@ -106,13 +130,14 @@ export default {
results: [] results: []
} }
initialResponse[category.code].forEach(result => { initialResponse[category.code].forEach(result => {
let id = category.getId(result)
results[category.code].results.push({ results[category.code].results.push({
title: category.getTitle(result), title: category.getTitle(result),
id: result.id, id,
routerUrl: { routerUrl: {
name: category.route, name: category.route,
params: { params: {
id: result.id id
} }
}, },
description: category.getDescription(result) description: category.getDescription(result)

View File

@ -6,6 +6,7 @@
{{ labels.title }} {{ labels.title }}
</span> </span>
</h2> </h2>
<radio-button type="tag" :object-id="id"></radio-button>
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<div class="ui row"> <div class="ui row">
<artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}"> <artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}">
@ -39,10 +40,10 @@
<script> <script>
import TrackWidget from "@/components/audio/track/Widget" import TrackWidget from "@/components/audio/track/Widget"
import AlbumWidget from "@/components/audio/album/Widget" import AlbumWidget from "@/components/audio/album/Widget"
import ArtistWidget from "@/components/audio/artist/Widget" import ArtistWidget from "@/components/audio/artist/Widget"
import RadioButton from "@/components/radios/Button"
export default { export default {
props: { props: {
@ -52,6 +53,7 @@ export default {
ArtistWidget, ArtistWidget,
AlbumWidget, AlbumWidget,
TrackWidget, TrackWidget,
RadioButton,
}, },
computed: { computed: {
labels() { labels() {

View File

@ -12,7 +12,7 @@ export default {
props: { props: {
customRadioId: {required: false}, customRadioId: {required: false},
type: {type: String, required: false}, type: {type: String, required: false},
objectId: {type: Number, default: null} objectId: {default: null}
}, },
methods: { methods: {
toggleRadio () { toggleRadio () {