From 47209ee5aeeda9cf6b1b02ceea89d36230f70fd3 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 3 Jan 2019 11:47:29 +0100 Subject: [PATCH 1/3] Added API to list and detail actors --- api/funkwhale_api/federation/models.py | 17 +++++++++- api/funkwhale_api/manage/filters.py | 24 ++++++++++++++ api/funkwhale_api/manage/serializers.py | 37 +++++++++++++++++++++ api/funkwhale_api/manage/urls.py | 5 ++- api/funkwhale_api/manage/views.py | 43 +++++++++++++++++++++++++ api/tests/manage/test_serializers.py | 29 +++++++++++++++++ api/tests/manage/test_views.py | 21 ++++++++++++ 7 files changed, 174 insertions(+), 2 deletions(-) diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 5e81143cb..ad88422e4 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -61,6 +61,21 @@ class ActorQuerySet(models.QuerySet): return qs + def with_outbox_activities_count(self): + return self.annotate( + outbox_activities_count=models.Count("outbox_activities", distinct=True) + ) + + def with_followers_count(self): + return self.annotate( + followers_count=models.Count("received_follows", distinct=True) + ) + + def with_uploads_count(self): + return self.annotate( + uploads_count=models.Count("libraries__uploads", distinct=True) + ) + class DomainQuerySet(models.QuerySet): def external(self): @@ -71,7 +86,7 @@ class DomainQuerySet(models.QuerySet): def with_outbox_activities_count(self): return self.annotate( - outbox_activities_count=models.Count("actors__outbox_activities") + outbox_activities_count=models.Count("actors__outbox_activities", distinct=True) ) diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index d9b9bfc1d..dfb901924 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -1,6 +1,8 @@ from django_filters import rest_framework as filters from funkwhale_api.common import fields +from funkwhale_api.common import search + from funkwhale_api.federation import models as federation_models from funkwhale_api.music import models as music_models from funkwhale_api.users import models as users_models @@ -29,6 +31,28 @@ class ManageDomainFilterSet(filters.FilterSet): fields = ["name"] +class ManageActorFilterSet(filters.FilterSet): + q = fields.SmartSearchFilter( + config=search.SearchConfig( + search_fields={ + "name": {"to": "name"}, + "username": {"to": "preferred_username"}, + "bio": {"to": "summary"}, + "type": {"to": "type"}, + }, + filter_fields={"domain": {"to": "domain_id__iexact"}}, + ) + ) + local = filters.BooleanFilter(name="_", method="filter_local") + + class Meta: + model = federation_models.Actor + fields = ["q", "domain", "type", "manually_approves_followers", "local"] + + def filter_local(self, queryset, name, value): + return queryset.local(value) + + class ManageUserFilterSet(filters.FilterSet): q = fields.SearchFilter(search_fields=["username", "email", "name"]) diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 710d3d62b..3b06fb848 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -191,3 +191,40 @@ class ManageDomainSerializer(serializers.ModelSerializer): def get_outbox_activities_count(self, o): return getattr(o, "outbox_activities_count", 0) + + +class ManageActorSerializer(serializers.ModelSerializer): + outbox_activities_count = serializers.SerializerMethodField() + uploads_count = serializers.SerializerMethodField() + followers_count = serializers.SerializerMethodField() + + class Meta: + model = federation_models.Actor + fields = [ + "id", + "url", + "fid", + "preferred_username", + "domain", + "name", + "summary", + "type", + "creation_date", + "last_fetch_date", + "inbox_url", + "outbox_url", + "shared_inbox_url", + "manually_approves_followers", + "outbox_activities_count", + "uploads_count", + "followers_count", + ] + + def get_uploads_count(self, o): + return getattr(o, "uploads_count", 0) + + def get_followers_count(self, o): + return getattr(o, "followers_count", 0) + + def get_outbox_activities_count(self, o): + return getattr(o, "outbox_activities_count", 0) diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 26832f946..232b88711 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -11,6 +11,9 @@ users_router = routers.SimpleRouter() users_router.register(r"users", views.ManageUserViewSet, "users") users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations") +other_router = routers.SimpleRouter() +other_router.register(r"accounts", views.ManageActorViewSet, "accounts") + urlpatterns = [ url( r"^federation/", @@ -18,4 +21,4 @@ urlpatterns = [ ), url(r"^library/", include((library_router.urls, "instance"), namespace="library")), url(r"^users/", include((users_router.urls, "instance"), namespace="users")), -] +] + other_router.urls diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index c3d87be52..ddd4fe571 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,5 +1,6 @@ from rest_framework import mixins, response, viewsets from rest_framework.decorators import detail_route, list_route +from django.shortcuts import get_object_or_404 from funkwhale_api.common import preferences from funkwhale_api.federation import models as federation_models @@ -129,3 +130,45 @@ class ManageDomainViewSet( def stats(self, request, *args, **kwargs): domain = self.get_object() return response.Response(domain.get_stats(), status=200) + + +class ManageActorViewSet( + mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet +): + lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)" + queryset = ( + federation_models.Actor.objects.all() + .with_outbox_activities_count() + .with_followers_count() + .with_uploads_count() + .order_by("-creation_date") + ) + serializer_class = serializers.ManageActorSerializer + filter_class = filters.ManageActorFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["moderation"] + ordering_fields = [ + "name", + "preferred_username", + "domain", + "fid", + "creation_date", + "last_fetch_date", + "uploads_count", + "followers_count", + "outbox_activities_count", + ] + + def get_object(self): + queryset = self.filter_queryset(self.get_queryset()) + username, domain = self.kwargs["pk"].split("@") + filter_kwargs = {"domain_id": domain, "preferred_username": username} + obj = get_object_or_404(queryset, **filter_kwargs) + self.check_object_permissions(self.request, obj) + + return obj + + @detail_route(methods=["get"]) + def stats(self, request, *args, **kwargs): + domain = self.get_object() + return response.Response(domain.get_stats(), status=200) diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 8a9f2ac8e..83d49cd66 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -51,3 +51,32 @@ def test_manage_domain_serializer(factories, now): s = serializers.ManageDomainSerializer(domain) assert s.data == expected + + +def test_manage_actor_serializer(factories, now): + actor = factories["federation.Actor"]() + setattr(actor, "outbox_activities_count", 23) + setattr(actor, "followers_count", 42) + setattr(actor, "uploads_count", 66) + expected = { + "id": actor.id, + "name": actor.name, + "creation_date": actor.creation_date.isoformat().split("+")[0] + "Z", + "last_fetch_date": actor.last_fetch_date.isoformat().split("+")[0] + "Z", + "outbox_activities_count": 23, + "followers_count": 42, + "uploads_count": 66, + "fid": actor.fid, + "url": actor.url, + "outbox_url": actor.outbox_url, + "shared_inbox_url": actor.shared_inbox_url, + "inbox_url": actor.inbox_url, + "domain": actor.domain_id, + "type": actor.type, + "summary": actor.summary, + "preferred_username": actor.preferred_username, + "manually_approves_followers": actor.manually_approves_followers, + } + s = serializers.ManageActorSerializer(actor) + + assert s.data == expected diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index d47a231e8..72e945bca 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -12,6 +12,7 @@ from funkwhale_api.manage import serializers, views (views.ManageUserViewSet, ["settings"], "and"), (views.ManageInvitationViewSet, ["settings"], "and"), (views.ManageDomainViewSet, ["moderation"], "and"), + (views.ManageActorViewSet, ["moderation"], "and"), ], ) def test_permissions(assert_user_permission, view, permissions, operator): @@ -112,3 +113,23 @@ def test_domain_stats(factories, superuser_api_client, mocker): response = superuser_api_client.get(url) assert response.status_code == 200 assert response.data == {"hello": "world"} + + +def test_actor_list(factories, superuser_api_client, settings): + actor = factories["federation.Actor"]() + url = reverse("api:v1:manage:accounts-list") + response = superuser_api_client.get(url) + + assert response.status_code == 200 + + assert response.data["count"] == 1 + assert response.data["results"][0]["id"] == actor.id + + +def test_actor_detail(factories, superuser_api_client): + actor = factories["federation.Actor"]() + url = reverse("api:v1:manage:accounts-detail", kwargs={"pk": actor.full_username}) + response = superuser_api_client.get(url) + + assert response.status_code == 200 + assert response.data["id"] == actor.id From b1194e50de46145381dbe4d730836689fc5e1c0b Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 3 Jan 2019 12:08:06 +0100 Subject: [PATCH 2/3] Move smart search bar in a dedicated component mixin --- front/src/components/mixins/SmartSearch.vue | 55 +++++++++++++++++++ .../views/content/libraries/FilesTable.vue | 47 +--------------- 2 files changed, 58 insertions(+), 44 deletions(-) create mode 100644 front/src/components/mixins/SmartSearch.vue diff --git a/front/src/components/mixins/SmartSearch.vue b/front/src/components/mixins/SmartSearch.vue new file mode 100644 index 000000000..170436b7a --- /dev/null +++ b/front/src/components/mixins/SmartSearch.vue @@ -0,0 +1,55 @@ + diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue index 130a1a207..4a4bc96d8 100644 --- a/front/src/views/content/libraries/FilesTable.vue +++ b/front/src/views/content/libraries/FilesTable.vue @@ -125,19 +125,19 @@ import axios from 'axios' import _ from '@/lodash' import time from '@/utils/time' -import {normalizeQuery, parseTokens, compileTokens} from '@/search' +import {normalizeQuery, parseTokens} from '@/search' import Pagination from '@/components/Pagination' import ActionTable from '@/components/common/ActionTable' import OrderingMixin from '@/components/mixins/Ordering' import TranslationsMixin from '@/components/mixins/Translations' +import SmartSearchMixin from '@/components/mixins/SmartSearch' export default { - mixins: [OrderingMixin, TranslationsMixin], + mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], props: { filters: {type: Object, required: false}, needsRefresh: {type: Boolean, required: false, default: false}, - defaultQuery: {type: String, default: ''}, customObjects: {type: Array, required: false, default: () => { return [] }} }, components: { @@ -172,36 +172,6 @@ export default { this.fetchData() }, methods: { - getTokenValue (key, fallback) { - let matching = this.search.tokens.filter(t => { - return t.field === key - }) - if (matching.length > 0) { - return matching[0].value - } - return fallback - }, - addSearchToken (key, value) { - if (!value) { - // we remove existing matching tokens, if any - this.search.tokens = this.search.tokens.filter(t => { - return t.field != key - }) - } else { - let existing = this.search.tokens.filter(t => { - return t.field === key - }) - if (existing.length > 0) { - // we replace the value in existing tokens, if any - existing.forEach(t => { - t.value = value - }) - } else { - // we add a new token - this.search.tokens.push({field: key, value}) - } - } - }, fetchData () { this.$emit('fetch-start') let params = _.merge({ @@ -282,17 +252,6 @@ export default { } }, watch: { - 'search.query' (newValue) { - this.search.tokens = parseTokens(normalizeQuery(newValue)) - }, - 'search.tokens': { - handler (newValue) { - this.search.query = compileTokens(newValue) - this.page = 1 - this.fetchData() - }, - deep: true - }, orderingDirection: function () { this.page = 1 this.fetchData() From e186c6bb06508714812575da94b18fef78b9a0f5 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 3 Jan 2019 17:10:02 +0100 Subject: [PATCH 3/3] Admin UI to list and manage remote and local accounts --- api/funkwhale_api/federation/models.py | 47 +- api/funkwhale_api/manage/filters.py | 7 +- api/funkwhale_api/manage/serializers.py | 14 +- api/funkwhale_api/manage/views.py | 4 +- api/funkwhale_api/users/models.py | 3 + api/tests/federation/test_models.py | 19 + api/tests/manage/test_serializers.py | 6 +- front/src/Embed.vue | 2 - .../manage/moderation/AccountsTable.vue | 205 +++++++++ .../components/manage/users/UsersTable.vue | 10 +- front/src/components/mixins/SmartSearch.vue | 12 +- front/src/components/mixins/Translations.vue | 4 + front/src/router/index.js | 26 +- .../views/admin/moderation/AccountsDetail.vue | 426 ++++++++++++++++++ .../views/admin/moderation/AccountsList.vue | 33 ++ front/src/views/admin/moderation/Base.vue | 8 +- .../views/admin/moderation/DomainsDetail.vue | 38 +- front/src/views/admin/users/UsersDetail.vue | 216 --------- 18 files changed, 797 insertions(+), 283 deletions(-) create mode 100644 front/src/components/manage/moderation/AccountsTable.vue create mode 100644 front/src/views/admin/moderation/AccountsDetail.vue create mode 100644 front/src/views/admin/moderation/AccountsList.vue delete mode 100644 front/src/views/admin/users/UsersDetail.vue diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index ad88422e4..35b15b667 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -61,16 +61,6 @@ class ActorQuerySet(models.QuerySet): return qs - def with_outbox_activities_count(self): - return self.annotate( - outbox_activities_count=models.Count("outbox_activities", distinct=True) - ) - - def with_followers_count(self): - return self.annotate( - followers_count=models.Count("received_follows", distinct=True) - ) - def with_uploads_count(self): return self.annotate( uploads_count=models.Count("libraries__uploads", distinct=True) @@ -86,7 +76,9 @@ class DomainQuerySet(models.QuerySet): def with_outbox_activities_count(self): return self.annotate( - outbox_activities_count=models.Count("actors__outbox_activities", distinct=True) + outbox_activities_count=models.Count( + "actors__outbox_activities", distinct=True + ) ) @@ -186,10 +178,10 @@ class Actor(models.Model): @property def full_username(self): - return "{}@{}".format(self.preferred_username, self.domain) + return "{}@{}".format(self.preferred_username, self.domain_id) def __str__(self): - return "{}@{}".format(self.preferred_username, self.domain) + return "{}@{}".format(self.preferred_username, self.domain_id) @property def is_local(self): @@ -217,6 +209,35 @@ class Actor(models.Model): data["total"] = sum(data.values()) return data + def get_stats(self): + from funkwhale_api.music import models as music_models + + data = Actor.objects.filter(pk=self.pk).aggregate( + outbox_activities=models.Count("outbox_activities", distinct=True), + libraries=models.Count("libraries", distinct=True), + received_library_follows=models.Count( + "libraries__received_follows", distinct=True + ), + emitted_library_follows=models.Count("library_follows", distinct=True), + ) + data["artists"] = music_models.Artist.objects.filter( + from_activity__actor=self.pk + ).count() + data["albums"] = music_models.Album.objects.filter( + from_activity__actor=self.pk + ).count() + data["tracks"] = music_models.Track.objects.filter( + from_activity__actor=self.pk + ).count() + + uploads = music_models.Upload.objects.filter(library__actor=self.pk) + data["uploads"] = uploads.count() + data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0 + data["media_downloaded_size"] = ( + uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0 + ) + return data + class InboxItem(models.Model): """ diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index dfb901924..51648298a 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -37,10 +37,15 @@ class ManageActorFilterSet(filters.FilterSet): search_fields={ "name": {"to": "name"}, "username": {"to": "preferred_username"}, + "email": {"to": "user__email"}, "bio": {"to": "summary"}, "type": {"to": "type"}, }, - filter_fields={"domain": {"to": "domain_id__iexact"}}, + filter_fields={ + "domain": {"to": "domain__name__iexact"}, + "username": {"to": "preferred_username__iexact"}, + "email": {"to": "user__email__iexact"}, + }, ) ) local = filters.BooleanFilter(name="_", method="filter_local") diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 3b06fb848..76d0cf05f 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -116,6 +116,7 @@ class ManageUserSerializer(serializers.ModelSerializer): "permissions", "privacy_level", "upload_quota", + "full_username", ) read_only_fields = [ "id", @@ -194,9 +195,8 @@ class ManageDomainSerializer(serializers.ModelSerializer): class ManageActorSerializer(serializers.ModelSerializer): - outbox_activities_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField() - followers_count = serializers.SerializerMethodField() + user = ManageUserSerializer() class Meta: model = federation_models.Actor @@ -205,6 +205,7 @@ class ManageActorSerializer(serializers.ModelSerializer): "url", "fid", "preferred_username", + "full_username", "domain", "name", "summary", @@ -215,16 +216,9 @@ class ManageActorSerializer(serializers.ModelSerializer): "outbox_url", "shared_inbox_url", "manually_approves_followers", - "outbox_activities_count", "uploads_count", - "followers_count", + "user", ] def get_uploads_count(self, o): return getattr(o, "uploads_count", 0) - - def get_followers_count(self, o): - return getattr(o, "followers_count", 0) - - def get_outbox_activities_count(self, o): - return getattr(o, "outbox_activities_count", 0) diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index ddd4fe571..0697c6c14 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -138,10 +138,9 @@ class ManageActorViewSet( lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)" queryset = ( federation_models.Actor.objects.all() - .with_outbox_activities_count() - .with_followers_count() .with_uploads_count() .order_by("-creation_date") + .select_related("user") ) serializer_class = serializers.ManageActorSerializer filter_class = filters.ManageActorFilterSet @@ -155,7 +154,6 @@ class ManageActorViewSet( "creation_date", "last_fetch_date", "uploads_count", - "followers_count", "outbox_activities_count", ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index 07bb4bae4..79650301e 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -204,6 +204,9 @@ class User(AbstractUser): return ["user.{}.{}".format(self.pk, g) for g in groups] + def full_username(self): + return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME) + def generate_code(length=10): return "".join( diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py index 293675048..f59293b67 100644 --- a/api/tests/federation/test_models.py +++ b/api/tests/federation/test_models.py @@ -97,3 +97,22 @@ def test_domain_stats(factories): domain = factories["federation.Domain"]() assert domain.get_stats() == expected + + +def test_actor_stats(factories): + expected = { + "libraries": 0, + "tracks": 0, + "albums": 0, + "uploads": 0, + "artists": 0, + "outbox_activities": 0, + "received_library_follows": 0, + "emitted_library_follows": 0, + "media_total_size": 0, + "media_downloaded_size": 0, + } + + actor = factories["federation.Actor"]() + + assert actor.get_stats() == expected diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 83d49cd66..803820b48 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -55,16 +55,12 @@ def test_manage_domain_serializer(factories, now): def test_manage_actor_serializer(factories, now): actor = factories["federation.Actor"]() - setattr(actor, "outbox_activities_count", 23) - setattr(actor, "followers_count", 42) setattr(actor, "uploads_count", 66) expected = { "id": actor.id, "name": actor.name, "creation_date": actor.creation_date.isoformat().split("+")[0] + "Z", "last_fetch_date": actor.last_fetch_date.isoformat().split("+")[0] + "Z", - "outbox_activities_count": 23, - "followers_count": 42, "uploads_count": 66, "fid": actor.fid, "url": actor.url, @@ -76,6 +72,8 @@ def test_manage_actor_serializer(factories, now): "summary": actor.summary, "preferred_username": actor.preferred_username, "manually_approves_followers": actor.manually_approves_followers, + "full_username": actor.full_username, + "user": None, } s = serializers.ManageActorSerializer(actor) diff --git a/front/src/Embed.vue b/front/src/Embed.vue index fdd3406fa..7987b054a 100644 --- a/front/src/Embed.vue +++ b/front/src/Embed.vue @@ -247,7 +247,6 @@ export default { self.isLoading = false; }).catch(error => { if (error.response) { - console.log(error.response) if (error.response.status === 404) { self.error = 'server_not_found' } @@ -274,7 +273,6 @@ export default { self.isLoading = false; }).catch(error => { if (error.response) { - console.log(error.response) if (error.response.status === 404) { self.error = 'server_not_found' } diff --git a/front/src/components/manage/moderation/AccountsTable.vue b/front/src/components/manage/moderation/AccountsTable.vue new file mode 100644 index 000000000..8750b4ec9 --- /dev/null +++ b/front/src/components/manage/moderation/AccountsTable.vue @@ -0,0 +1,205 @@ + + + diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue index 33b2433cb..974ca392d 100644 --- a/front/src/components/manage/users/UsersTable.vue +++ b/front/src/components/manage/users/UsersTable.vue @@ -45,7 +45,7 @@