Verified Commit 58dea6d8 authored by Agate's avatar Agate 💬

See #284: WIP

parent f18a04b4
......@@ -2,8 +2,9 @@
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.users.serializers import UserActivitySerializer
from funkwhale_api.music import models as music_models
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models
......@@ -29,3 +30,18 @@ class UserTrackFavoriteSerializer(serializers.ModelSerializer):
class Meta:
model = models.TrackFavorite
fields = ("id", "track", "creation_date")
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
class Meta:
model = models.TrackFavorite
fields = ("id", "user", "track", "creation_date")
class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer):
class Meta:
model = models.TrackFavorite
fields = ("id", "track", "creation_date")
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import list_route
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from funkwhale_api.activity import record
from funkwhale_api.common.permissions import ConditionalAuthentication
from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track
from . import models, serializers
......@@ -18,7 +19,17 @@ class TrackFavoriteViewSet(
serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all()
permission_classes = [ConditionalAuthentication]
permission_classes = [
permissions.ConditionalAuthentication,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
owner_checks = ["write"]
def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]:
return serializers.UserTrackFavoriteSerializer
return serializers.UserTrackFavoriteWriteSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
......@@ -32,7 +43,10 @@ class TrackFavoriteViewSet(
)
def get_queryset(self):
return self.queryset.filter(user=self.request.user)
queryset = super().get_queryset()
return queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level")
)
def perform_create(self, serializer):
track = Track.objects.get(pk=serializer.data["track"])
......
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.users.serializers import UserActivitySerializer
from funkwhale_api.music import models as music_models
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models
......@@ -25,6 +26,20 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer):
class ListeningSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
return super().create(validated_data)
class ListeningWriteSerializer(serializers.ModelSerializer):
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date")
......
from rest_framework import mixins, permissions, viewsets
from rest_framework import mixins, viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from . import models, serializers
class ListeningViewSet(
mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all()
permission_classes = [permissions.IsAuthenticated]
queryset = (
models.Listening.objects.all()
.select_related("track__artist", "track__album__artist", "user")
.prefetch_related("track__files")
)
permission_classes = [
permissions.ConditionalAuthentication,
permissions.OwnerPermission,
IsAuthenticatedOrReadOnly,
]
owner_checks = ["write"]
def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]:
return serializers.ListeningSerializer
return serializers.ListeningWriteSerializer
def perform_create(self, serializer):
r = super().perform_create(serializer)
......@@ -20,7 +39,9 @@ class ListeningViewSet(
def get_queryset(self):
queryset = super().get_queryset()
return queryset.filter(user=self.request.user)
return queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level")
)
def get_serializer_context(self):
context = super().get_serializer_context()
......
import pytest
from django.urls import reverse
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
def test_privacy_filter(preferences, level, factories, api_client):
preferences["common__api_authentication_required"] = False
factories["favorites.TrackFavorite"](user__privacy_level=level)
url = reverse("api:v1:favorites:tracks-list")
response = api_client.get(url)
assert response.status_code == 200
assert response.data["count"] == 0
import pytest
from django.urls import reverse
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
def test_privacy_filter(preferences, level, factories, api_client):
preferences["common__api_authentication_required"] = False
factories["history.Listening"](user__privacy_level=level)
url = reverse("api:v1:history:listenings-list")
response = api_client.get(url)
assert response.status_code == 200
assert response.data["count"] == 0
<template>
<div :title="title" :class="['ui', {'tiny': discrete}, 'buttons']">
<div :title="title" :class="['ui', {'tiny': discrete}, {'buttons': !dropdownOnly && !iconOnly}]">
<button
v-if="!dropdownOnly"
:title="labels.addToQueue"
@click="addNext(true)"
:disabled="!playable"
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, 'button']">
<i class="ui play icon"></i>
<template v-if="!discrete"><slot><translate>Play</translate></slot></template>
:class="['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}, buttonClass]">
<i :class="[playIconClass, 'icon']"></i>
<template v-if="!discrete && !iconOnly"><slot><translate>Play</translate></slot></template>
</button>
<div v-if="!discrete" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', 'icon', 'button']">
<i class="dropdown icon"></i>
<div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]">
<i :class="[dropdownIconClass, 'icon']"></i>
<div class="menu">
<div class="item" :disabled="!playable" @click="add"><i class="plus icon"></i><translate>Add to queue</translate></div>
<div class="item" :disabled="!playable" @click="addNext()"><i class="step forward icon"></i><translate>Play next</translate></div>
......@@ -28,8 +29,13 @@ export default {
// we can either have a single or multiple tracks to play when clicked
tracks: {type: Array, required: false},
track: {type: Object, required: false},
dropdownIconClass: {type: String, required: false, default: 'dropdown'},
playIconClass: {type: String, required: false, default: 'play icon'},
buttonClass: {type: String, required: false, default: 'button'},
playlist: {type: Object, required: false},
discrete: {type: Boolean, default: false},
dropdownOnly: {type: Boolean, default: false},
iconOnly: {type: Boolean, default: false},
artist: {type: Number, required: false},
album: {type: Number, required: false}
},
......
<template>
<div>
<h3 class="ui header">
<slot name="title"></slot>
</h3>
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle left', 'icon']">
</i>
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle right', 'icon']">
</i>
<div class="ui hidden divider"></div>
<div class="ui five cards">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<div class="card" v-for="album in albums" :key="album.id">
<div :class="['ui', 'image', 'with-overlay', {'default-cover': !album.cover}]" :style="getImageStyle(album)">
<play-button class="play-overlay" :icon-only="true" :button-class="'ui circular large orange icon button'" :album="album.id"></play-button>
</div>
<div class="content">
<router-link :title="album.title" :to="{name: 'library.albums.detail', params: {id: album.id}}">
{{ album.title|truncate(25) }}
</router-link>
<div class="description">
<span>
<router-link :title="album.artist.name" class="discrete link" :to="{name: 'library.artists.detail', params: {id: album.artist.id}}">
{{ album.artist.name|truncate(23) }}
</router-link>
</span>
</div>
</div>
<div class="extra content">
<human-date class="left floated" :date="album.creation_date"></human-date>
<play-button class="right floated basic icon" :dropdown-only="true" :dropdown-icon-class="'ellipsis horizontal large grey'" :album="album.id"></play-button>
</div>
</div>
</div>
</div>
</template>
<script>
import _ from 'lodash'
import axios from 'axios'
import PlayButton from '@/components/audio/PlayButton'
export default {
props: {
filters: {type: Object, required: true}
},
components: {
PlayButton
},
data () {
return {
albums: [],
limit: 12,
isLoading: false,
errors: null,
previousPage: null,
nextPage: null
}
},
created () {
this.fetchData('albums/')
},
methods: {
fetchData (url) {
if (!url) {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
params.page_size = this.limit
params.offset = this.offset
axios.get(url, {params: params}).then((response) => {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
self.albums = response.data.results
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
updateOffset (increment) {
if (increment) {
this.offset += this.limit
} else {
this.offset = Math.max(this.offset - this.limit, 0)
}
},
getImageStyle (album) {
let url = '../../../assets/audio/default-cover.png'
if (album.cover) {
url = this.$store.getters['instance/absoluteUrl'](album.cover)
} else {
return {}
}
return {
'background-image': `url("${url}")`
}
}
},
watch: {
offset () {
this.fetchData()
}
}
}
</script>
<style scoped lang="scss">
@import '../../../style/vendor/media';
.default-cover {
background-image: url('../../../assets/audio/default-cover.png') !important;
}
.ui.cards {
justify-content: center;
}
.ui.cards > .card {
width: 15em;
}
.with-overlay {
background-size: cover !important;
background-position: center !important;
height: 15em;
width: 15em;
display: flex !important;
justify-content: center !important;
align-items: center !important;
}
</style>
......@@ -5,7 +5,7 @@
</td>
<td>
<img class="ui mini image" v-if="track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover)">
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
</td>
<td colspan="6">
<router-link class="track" :to="{name: 'library.tracks.detail', params: {id: track.id }}">
......
<template>
<div>
<h3 class="ui header">
<slot name="title"></slot>
</h3>
<i @click="fetchData(previousPage)" :disabled="!previousPage" :class="['ui', {disabled: !previousPage}, 'circular', 'large', 'angle up', 'icon']">
</i>
<i @click="fetchData(nextPage)" :disabled="!nextPage" :class="['ui', {disabled: !nextPage}, 'circular', 'large', 'angle down', 'icon']">
</i>
<div class="ui divided unstackable items">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<div class="item" v-for="object in objects" :key="object.id">
<div class="ui tiny image">
<img v-if="object.track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover)">
<img v-else src="../../../assets/audio/default-cover.png">
<play-button class="play-overlay" :icon-only="true" :button-class="'ui circular tiny orange icon button'" :track="object.track"></play-button>
</div>
<div class="middle aligned content">
<div class="ui unstackable grid">
<div class="thirteen wide stretched column">
<div>
<router-link :title="object.track.title" :to="{name: 'library.tracks.detail', params: {id: object.track.id}}">
{{ object.track.title|truncate(25) }}
</router-link>
</div>
<div class="meta">
<span>
<router-link :title="object.track.artist.name" class="discrete link" :to="{name: 'library.artists.detail', params: {id: object.track.artist.id}}">
{{ object.track.artist.name|truncate(25) }}
</router-link>
</span>
</div>
<div class="extra">
<span class="left floated">@{{ object.user.username }}</span>
<span class="right floated"><human-date :date="object.creation_date" /></span>
</div>
</div>
<div class="one wide stretched column">
<play-button class="basic icon" :dropdown-only="true" :dropdown-icon-class="'ellipsis vertical large grey'" :track="object.track"></play-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import _ from 'lodash'
import axios from 'axios'
import PlayButton from '@/components/audio/PlayButton'
export default {
props: {
filters: {type: Object, required: true},
url: {type: String, required: true}
},
components: {
PlayButton
},
data () {
return {
objects: [],
limit: 5,
isLoading: false,
errors: null,
previousPage: null,
nextPage: null
}
},
created () {
this.fetchData(this.url)
},
methods: {
fetchData (url) {
if (!url) {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
params.page_size = this.limit
params.offset = this.offset
axios.get(url, {params: params}).then((response) => {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
self.objects = response.data.results
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
updateOffset (increment) {
if (increment) {
this.offset += this.limit
} else {
this.offset = Math.max(this.offset - this.limit, 0)
}
}
},
watch: {
offset () {
this.fetchData()
}
}
}
</script>
<style scoped lang="scss">
@import '../../../style/vendor/media';
.play-overlay {
position: absolute;
top: 4em;
left: 4em;
@include media(">tablet") {
top: 2.5em;
left: 2.5em;
}
}
</style>
......@@ -65,7 +65,7 @@ import PasswordInput from '@/components/forms/PasswordInput'
export default {
props: {
invitation: {type: String, required: false, default: null},
defaultInvitation: {type: String, required: false, default: null},
next: {type: String, default: '/'}
},
components: {
......@@ -78,7 +78,8 @@ export default {
password: '',
isLoadingInstanceSetting: true,
errors: [],
isLoading: false
isLoading: false,
invitation: this.defaultInvitation
}
},
created () {
......
<template>
<div v-title="labels.title">
<div class="ui vertical stripe segment">
<search :autofocus="true"></search>
</div>
<div class="ui vertical stripe segment">
<div class="ui stackable three column grid">
<div class="column">
<h2 class="ui header">
<translate>Latest artists</translate>
</h2>
<div :class="['ui', {'active': isLoadingArtists}, 'inline', 'loader']"></div>
<div v-if="artists.length > 0" v-for="artist in artists.slice(0, 3)" :key="artist.id" class="ui cards">
<artist-card :artist="artist"></artist-card>
</div>
<track-widget :url="'history/listenings/'" :filters="{scope: 'user', ordering: '-creation_date'}">
<template slot="title"><translate>Recently listened</translate></template>
</track-widget>
</div>
<div class="column">
<h2 class="ui header">
<track-widget :url="'favorites/tracks/'" :filters="{scope: 'user', ordering: '-creation_date'}">
<template slot="title"><translate>Recently favorited</translate></template>
</track-widget>
</div>
<div class="column">
<h3 class="ui header">
<translate>Radios</translate>
</h2>
</h3>
<radio-card :type="'favorites'"></radio-card>
<radio-card :type="'random'"></radio-card>
<radio-card :type="'less-listened'"></radio-card>
</div>
<div class="column">
<!-- <div class="column">
<h2 class="ui header">
<translate>Music requests</translate>
</h2>
<request-form v-if="$store.state.auth.authenticated"></request-form>
</div> -->
</div>
<div class="ui section hidden divider"></div>
<div class="ui grid">
<div class="ui row">
<album-widget :filters="{ordering: '-creation_date'}">
<template slot="title"><translate>Recently added</translate></template>
</album-widget>
</div>
</div>
</div>
......@@ -40,6 +46,8 @@ import logger from '@/logging'
import ArtistCard from '@/components/audio/artist/Card'
import RadioCard from '@/components/radios/Card'
import RequestForm from '@/components/requests/Form'
import TrackWidget from '@/components/audio/track/Widget'
import AlbumWidget from '@/components/audio/album/Widget'
const ARTISTS_URL = 'artists/'
......@@ -49,6 +57,8 @@ export default {
Search,
ArtistCard,
RadioCard,
TrackWidget,
AlbumWidget,
RequestForm
},
data () {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment