Commit 33d1f879 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Report UI (end-user)

parent 1a8edf27
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
......@@ -27,10 +28,17 @@ class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
actor = serializers.SerializerMethodField()
class Meta:
model = models.TrackFavorite
fields = ("id", "user", "track", "creation_date")
fields = ("id", "user", "track", "creation_date", "actor")
actor = serializers.SerializerMethodField()
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer):
......
......@@ -22,7 +22,7 @@ class TrackFavoriteViewSet(
filterset_class = filters.TrackFavoriteFilter
serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all().select_related("user")
queryset = models.TrackFavorite.objects.all().select_related("user__actor")
permission_classes = [
oauth_permissions.ScopePermission,
permissions.OwnerPermission,
......@@ -54,7 +54,7 @@ class TrackFavoriteViewSet(
)
tracks = Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)
).select_related("artist", "album__artist")
).select_related("artist", "album__artist", "attributed_to")
queryset = queryset.prefetch_related(Prefetch("track", queryset=tracks))
return queryset
......
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
......@@ -27,16 +28,22 @@ class ListeningActivitySerializer(activity_serializers.ModelSerializer):
class ListeningSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
actor = serializers.SerializerMethodField()
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date")
fields = ("id", "user", "track", "creation_date", "actor")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
return super().create(validated_data)
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
class ListeningWriteSerializer(serializers.ModelSerializer):
class Meta:
......
......@@ -19,7 +19,7 @@ class ListeningViewSet(
):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related("user")
queryset = models.Listening.objects.all().select_related("user__actor")
permission_classes = [
oauth_permissions.ScopePermission,
......@@ -47,7 +47,7 @@ class ListeningViewSet(
)
tracks = Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)
).select_related("artist", "album__artist")
).select_related("artist", "album__artist", "attributed_to")
return queryset.prefetch_related(Prefetch("track", queryset=tracks))
def get_serializer_context(self):
......
......@@ -3,6 +3,7 @@ import memoize.djangocache
import funkwhale_api
from funkwhale_api.common import preferences
from funkwhale_api.federation import actors, models as federation_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import utils as music_utils
from . import stats
......@@ -15,6 +16,9 @@ def get():
share_stats = preferences.get("instance__nodeinfo_stats_enabled")
allow_list_enabled = preferences.get("moderation__allow_list_enabled")
allow_list_public = preferences.get("moderation__allow_list_public")
unauthenticated_report_types = preferences.get(
"moderation__unauthenticated_report_types"
)
if allow_list_enabled and allow_list_public:
allowed_domains = list(
federation_models.Domain.objects.filter(allowed=True)
......@@ -47,6 +51,10 @@ def get():
},
"supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS,
"allowList": {"enabled": allow_list_enabled, "domains": allowed_domains},
"reportTypes": [
{"type": t, "label": l, "anonymous": t in unauthenticated_report_types}
for t, l in moderation_models.REPORT_TYPES
],
},
}
if share_stats:
......
......@@ -115,7 +115,7 @@ REPORT_TYPES = [
class Report(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
summary = models.TextField(null=True, max_length=50000)
summary = models.TextField(null=True, blank=True, max_length=50000)
handled_date = models.DateTimeField(null=True)
is_handled = models.BooleanField(default=False)
type = models.CharField(max_length=40, choices=REPORT_TYPES)
......
......@@ -2,6 +2,7 @@ from django.db import transaction
from rest_framework import serializers
from funkwhale_api.common import preferences
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.models import Track
from funkwhale_api.music.serializers import TrackSerializer
from funkwhale_api.users.serializers import UserBasicSerializer
......@@ -79,6 +80,7 @@ class PlaylistSerializer(serializers.ModelSerializer):
album_covers = serializers.SerializerMethodField(read_only=True)
user = UserBasicSerializer(read_only=True)
is_playable = serializers.SerializerMethodField()
actor = serializers.SerializerMethodField()
class Meta:
model = models.Playlist
......@@ -93,9 +95,15 @@ class PlaylistSerializer(serializers.ModelSerializer):
"album_covers",
"duration",
"is_playable",
"actor",
)
read_only_fields = ["id", "modification_date", "creation_date"]
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
def get_is_playable(self, obj):
try:
return bool(obj.playable_plts)
......
......@@ -23,7 +23,7 @@ class PlaylistViewSet(
serializer_class = serializers.PlaylistSerializer
queryset = (
models.Playlist.objects.all()
.select_related("user")
.select_related("user__actor")
.annotate(tracks_count=Count("playlist_tracks"))
.with_covers()
.with_duration()
......
......@@ -4,8 +4,7 @@ import pytest
from django.urls import reverse
from funkwhale_api.favorites.models import TrackFavorite
from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.users import serializers as users_serializers
from funkwhale_api.favorites import serializers
def test_user_can_add_favorite(factories):
......@@ -20,22 +19,15 @@ def test_user_can_add_favorite(factories):
def test_user_can_get_his_favorites(
api_request, factories, logged_in_api_client, client
):
r = api_request.get("/")
request = api_request.get("/")
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
factories["favorites.TrackFavorite"]()
url = reverse("api:v1:favorites:tracks-list")
response = logged_in_api_client.get(url, {"user": logged_in_api_client.user.pk})
expected = [
{
"user": users_serializers.UserBasicSerializer(
favorite.user, context={"request": r}
).data,
"track": music_serializers.TrackSerializer(
favorite.track, context={"request": r}
).data,
"id": favorite.id,
"creation_date": favorite.creation_date.isoformat().replace("+00:00", "Z"),
}
serializers.UserTrackFavoriteSerializer(
favorite, context={"request": request}
).data
]
assert response.status_code == 200
assert response.data["results"] == expected
......
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.favorites import serializers
from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.users import serializers as users_serializers
def test_track_favorite_serializer(factories, to_api_date):
favorite = factories["favorites.TrackFavorite"]()
actor = favorite.user.create_actor()
expected = {
"id": favorite.pk,
"creation_date": to_api_date(favorite.creation_date),
"track": music_serializers.TrackSerializer(favorite.track).data,
"actor": federation_serializers.APIActorSerializer(actor).data,
"user": users_serializers.UserBasicSerializer(favorite.user).data,
}
serializer = serializers.UserTrackFavoriteSerializer(favorite)
assert serializer.data == expected
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.history import serializers
from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.users import serializers as users_serializers
def test_listening_serializer(factories, to_api_date):
listening = factories["history.Listening"]()
actor = listening.user.create_actor()
expected = {
"id": listening.pk,
"creation_date": to_api_date(listening.creation_date),
"track": music_serializers.TrackSerializer(listening.track).data,
"actor": federation_serializers.APIActorSerializer(actor).data,
"user": users_serializers.UserBasicSerializer(listening.user).data,
}
serializer = serializers.ListeningSerializer(listening)
assert serializer.data == expected
......@@ -8,6 +8,12 @@ from funkwhale_api.music import utils as music_utils
def test_nodeinfo_dump(preferences, mocker):
preferences["instance__nodeinfo_stats_enabled"] = True
preferences["moderation__unauthenticated_report_types"] = [
"takedown_request",
"other",
"other_category_that_doesnt_exist",
]
stats = {
"users": {"total": 1, "active_halfyear": 12, "active_month": 13},
"tracks": 2,
......@@ -51,6 +57,29 @@ def test_nodeinfo_dump(preferences, mocker):
},
"supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS,
"allowList": {"enabled": False, "domains": None},
"reportTypes": [
{
"type": "takedown_request",
"label": "Takedown request",
"anonymous": True,
},
{
"type": "invalid_metadata",
"label": "Invalid metadata",
"anonymous": False,
},
{
"type": "illegal_content",
"label": "Illegal content",
"anonymous": False,
},
{
"type": "offensive_content",
"label": "Offensive content",
"anonymous": False,
},
{"type": "other", "label": "Other", "anonymous": True},
],
},
}
assert nodeinfo.get() == expected
......@@ -58,6 +87,10 @@ def test_nodeinfo_dump(preferences, mocker):
def test_nodeinfo_dump_stats_disabled(preferences, mocker):
preferences["instance__nodeinfo_stats_enabled"] = False
preferences["moderation__unauthenticated_report_types"] = [
"takedown_request",
"other",
]
expected = {
"version": "2.0",
......@@ -83,6 +116,29 @@ def test_nodeinfo_dump_stats_disabled(preferences, mocker):
},
"supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS,
"allowList": {"enabled": False, "domains": None},
"reportTypes": [
{
"type": "takedown_request",
"label": "Takedown request",
"anonymous": True,
},
{
"type": "invalid_metadata",
"label": "Invalid metadata",
"anonymous": False,
},
{
"type": "illegal_content",
"label": "Illegal content",
"anonymous": False,
},
{
"type": "offensive_content",
"label": "Offensive content",
"anonymous": False,
},
{"type": "other", "label": "Other", "anonymous": True},
],
},
}
assert nodeinfo.get() == expected
......
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.playlists import models, serializers
from funkwhale_api.users import serializers as users_serializers
def test_cannot_max_500_tracks_per_playlist(factories, preferences):
......@@ -124,3 +126,25 @@ def test_playlist_serializer_include_duration(factories, api_request):
serializer = serializers.PlaylistSerializer(qs.get())
assert serializer.data["duration"] == 45
def test_playlist_serializer(factories, to_api_date):
playlist = factories["playlists.Playlist"]()
actor = playlist.user.create_actor()
expected = {
"id": playlist.pk,
"name": playlist.name,
"privacy_level": playlist.privacy_level,
"is_playable": None,
"creation_date": to_api_date(playlist.creation_date),
"modification_date": to_api_date(playlist.modification_date),
"actor": federation_serializers.APIActorSerializer(actor).data,
"user": users_serializers.UserBasicSerializer(playlist.user).data,
"duration": 0,
"tracks_count": 0,
"album_covers": [],
}
serializer = serializers.PlaylistSerializer(playlist)
assert serializer.data == expected
......@@ -21,6 +21,7 @@
></app-footer>
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
<filter-modal v-if="$store.state.auth.authenticated"></filter-modal>
<report-modal></report-modal>
<shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal>
<GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal"/>
</template>
......@@ -41,6 +42,7 @@ import moment from 'moment'
import locales from './locales'
import PlaylistModal from '@/components/playlists/PlaylistModal'
import FilterModal from '@/components/moderation/FilterModal'
import ReportModal from '@/components/moderation/ReportModal'
import ShortcutsModal from '@/components/ShortcutsModal'
import SetInstanceModal from '@/components/SetInstanceModal'
......@@ -50,6 +52,7 @@ export default {
Sidebar,
AppFooter,
FilterModal,
ReportModal,
PlaylistModal,
ShortcutsModal,
GlobalEvents,
......
......@@ -27,9 +27,17 @@
<button v-if="track" class="item basic" :disabled="!playable" @click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})" :title="labels.startRadio">
<i class="feed icon"></i><translate translate-context="*/Queue/Button.Label/Short, Verb">Start radio</translate>
</button>
<div class="divider"></div>
<button v-if="filterableArtist" class="item basic" :disabled="!filterableArtist" @click.stop.prevent="filterArtist" :title="labels.hideArtist">
<i class="eye slash outline icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Hide content from this artist</translate>
</button>
<button
v-for="obj in getReportableObjs({track, album, artist, playlist, account})"
:key="obj.target.type + obj.target.id"
class="item basic"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
<i class="share icon" /> {{ obj.label }}
</button>
</div>
</div>
</span>
......@@ -39,11 +47,15 @@
import axios from 'axios'
import jQuery from 'jquery'
import ReportMixin from '@/components/mixins/Report'
export default {
mixins: [ReportMixin],
props: {
// we can either have a single or multiple tracks to play when clicked
tracks: {type: Array, required: false},
track: {type: Object, required: false},
account: {type: Object, required: false},
dropdownIconClasses: {type: Array, required: false, default: () => { return ['dropdown'] }},
playIconClass: {type: String, required: false, default: 'play icon'},
buttonClasses: {type: Array, required: false, default: () => { return ['button'] }},
......@@ -79,7 +91,8 @@ export default {
addToQueue: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Add to current queue'),
playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'),
startRadio: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play similar songs'),
replacePlay: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Replace current queue')
replacePlay: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Replace current queue'),
report: this.$pgettext('*/Moderation/*/Button/Label,Verb', 'Report…'),
}
},
title () {
......@@ -118,7 +131,7 @@ export default {
if (this.artist) {
return this.artist
}
}
},
},
methods: {
......
......@@ -37,7 +37,12 @@
</div>
</div>
<div class="one wide stretched column">
<play-button class="basic icon" :dropdown-only="true" :dropdown-icon-classes="['ellipsis', 'vertical', 'large', 'grey']" :track="object.track"></play-button>
<play-button
class="basic icon"
:account="object.actor"
:dropdown-only="true"
:dropdown-icon-classes="['ellipsis', 'vertical', 'large', 'grey']"
:track="object.track"></play-button>
</div>
</div>
</div>
......
......@@ -74,6 +74,15 @@
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link>
<div class="divider"></div>
<div
role="button"
class="basic item"
v-for="obj in getReportableObjs({album: object})"
:key="obj.target.type + obj.target.id"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
......@@ -105,6 +114,7 @@ import PlayButton from "@/components/audio/PlayButton"
import EmbedWizard from "@/components/audio/EmbedWizard"
import Modal from '@/components/semantic/Modal'
import TagsList from "@/components/tags/List"
import ReportMixin from '@/components/mixins/Report'
const FETCH_URL = "albums/"
......@@ -121,6 +131,7 @@ function groupByDisc(acc, track) {
}
export default {
mixins: [ReportMixin],
props: ["id"],
components: {
PlayButton,
......
......@@ -84,6 +84,16 @@
<i class="edit icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link>
<div class="divider"></div>
<div
role="button"
class="basic item"
v-for="obj in getReportableObjs({artist: object})"
:key="obj.target.type + obj.target.id"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.artists.detail', params: {id: object.id}}">
<i class="wrench icon"></i>
......@@ -125,12 +135,12 @@ import EmbedWizard from "@/components/audio/EmbedWizard"
import Modal from '@/components/semantic/Modal'
import RadioButton from "@/components/radios/Button"
import TagsList from "@/components/tags/List"
import ReportMixin from '@/components/mixins/Report'
const FETCH_URL = "albums/"
export default {
mixins: [ReportMixin],
props: ["id"],
components: {
PlayButton,
......
......@@ -90,6 +90,15 @@
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link>
<div class="divider"></div>
<div
role="button"
class="basic item"
v-for="obj in getReportableObjs({track})"
:key="obj.target.type + obj.target.id"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.tracks.detail', params: {id: track.id}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
......@@ -124,11 +133,13 @@ import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
import Modal from '@/components/semantic/Modal'
import EmbedWizard from "@/components/audio/EmbedWizard"
import TagsList from "@/components/tags/List"
import ReportMixin from '@/components/mixins/Report'
const FETCH_URL = "tracks/"
export default {
props: ["id"],
mixins: [ReportMixin],
components: {
PlayButton,
TrackPlaylistIcon,
......
<script>
export default {
methods: {
getReportableObjs ({track, album, artist, playlist, account}) {
let reportableObjs = []
if (account) {
let accountLabel = this.$pgettext('*/Moderation/*/Verb', "Report @%{ username }…")
reportableObjs.push({
label: this.$gettextInterpolate(accountLabel, {username: account.preferred_username}),
target: {
type: 'account',
full_username: account.full_username,
label: account.full_username,
typeLabel: this.$pgettext("*/*/*", 'Account'),
}
})
if (track) {
album = track.album
artist = track.artist
}
}
if (track) {
reportableObjs.push({
label: this.$pgettext('*/Moderation/*/Verb', "Report this track…"),
target: {
type: 'track',
id: track.id,
label: track.title,
typeLabel: this.$pgettext("*/*/*", 'Track'),
}
})
album = track.album
artist = track.artist
}
if (album) {
reportableObjs.push({
label: this.$pgettext('*/Moderation/*/Verb', "Report this album…"),
target: {
type: 'album',
id: album.id,
label: album.title,