diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 5e81143cb..35b15b667 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -61,6 +61,11 @@ class ActorQuerySet(models.QuerySet): return qs + 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 +76,9 @@ 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 + ) ) @@ -171,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): @@ -202,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 d9b9bfc1d..51648298a 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,33 @@ 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"}, + "email": {"to": "user__email"}, + "bio": {"to": "summary"}, + "type": {"to": "type"}, + }, + filter_fields={ + "domain": {"to": "domain__name__iexact"}, + "username": {"to": "preferred_username__iexact"}, + "email": {"to": "user__email__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..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", @@ -191,3 +192,33 @@ class ManageDomainSerializer(serializers.ModelSerializer): def get_outbox_activities_count(self, o): return getattr(o, "outbox_activities_count", 0) + + +class ManageActorSerializer(serializers.ModelSerializer): + uploads_count = serializers.SerializerMethodField() + user = ManageUserSerializer() + + class Meta: + model = federation_models.Actor + fields = [ + "id", + "url", + "fid", + "preferred_username", + "full_username", + "domain", + "name", + "summary", + "type", + "creation_date", + "last_fetch_date", + "inbox_url", + "outbox_url", + "shared_inbox_url", + "manually_approves_followers", + "uploads_count", + "user", + ] + + def get_uploads_count(self, o): + return getattr(o, "uploads_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..0697c6c14 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,43 @@ 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_uploads_count() + .order_by("-creation_date") + .select_related("user") + ) + 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", + "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/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 8a9f2ac8e..803820b48 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -51,3 +51,30 @@ 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, "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", + "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, + "full_username": actor.full_username, + "user": None, + } + 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 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 @@