import requests.exceptions from django.conf import settings from django.db import transaction from django.db.models import Count, Q from rest_framework import decorators from rest_framework import mixins from rest_framework import permissions from rest_framework import response from rest_framework import viewsets from drf_spectacular.utils import extend_schema, extend_schema_view from funkwhale_api.common import preferences from funkwhale_api.common import utils as common_utils from funkwhale_api.common.permissions import ConditionalAuthentication from funkwhale_api.music import models as music_models from funkwhale_api.music import views as music_views from funkwhale_api.music import serializers as music_serializers from funkwhale_api.users.oauth import permissions as oauth_permissions from . import activity from . import api_serializers from . import exceptions from . import filters from . import models from . import routes from . import serializers from . import tasks from . import utils @transaction.atomic def update_follow(follow, approved): follow.approved = approved follow.save(update_fields=["approved"]) if approved: routes.outbox.dispatch({"type": "Accept"}, context={"follow": follow}) else: routes.outbox.dispatch({"type": "Reject"}, context={"follow": follow}) @extend_schema_view( list=extend_schema(operation_id="get_federation_library_follows"), create=extend_schema(operation_id="create_federation_library_follow"), ) # NOTE: For some weird reason, @extend_schema_view doesn't work with `retrieve` and `destroy` methods. @extend_schema(operation_id="get_federation_library_follow", methods=["get"]) @extend_schema(operation_id="delete_federation_library_follow", methods=["delete"]) class LibraryFollowViewSet( mixins.CreateModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, viewsets.GenericViewSet, ): lookup_field = "uuid" queryset = ( models.LibraryFollow.objects.all() .order_by("-creation_date") .select_related("actor", "target__actor") ) serializer_class = api_serializers.LibraryFollowSerializer permission_classes = [oauth_permissions.ScopePermission] required_scope = "follows" filterset_class = filters.LibraryFollowFilter ordering_fields = ("creation_date",) def get_queryset(self): qs = super().get_queryset() return qs.filter(actor=self.request.user.actor).exclude(approved=False) def perform_create(self, serializer): follow = serializer.save(actor=self.request.user.actor) routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow}) @transaction.atomic def perform_destroy(self, instance): routes.outbox.dispatch( {"type": "Undo", "object": {"type": "Follow"}}, context={"follow": instance} ) instance.delete() def get_serializer_context(self): context = super().get_serializer_context() context["actor"] = self.request.user.actor return context @extend_schema( operation_id="accept_federation_library_follow", responses={404: None, 204: None}, ) @decorators.action(methods=["post"], detail=True) def accept(self, request, *args, **kwargs): try: follow = self.queryset.get( target__actor=self.request.user.actor, uuid=kwargs["uuid"] ) except models.LibraryFollow.DoesNotExist: return response.Response({}, status=404) update_follow(follow, approved=True) return response.Response(status=204) @extend_schema(operation_id="reject_federation_library_follow") @decorators.action(methods=["post"], detail=True) def reject(self, request, *args, **kwargs): try: follow = self.queryset.get( target__actor=self.request.user.actor, uuid=kwargs["uuid"] ) except models.LibraryFollow.DoesNotExist: return response.Response({}, status=404) update_follow(follow, approved=False) return response.Response(status=204) @extend_schema(operation_id="get_all_federation_library_follows") @decorators.action(methods=["get"], detail=False) def all(self, request, *args, **kwargs): """ Return all the subscriptions of the current user, with only limited data to have a performant endpoint and avoid lots of queries just to display subscription status in the UI """ follows = list( self.get_queryset().values_list("uuid", "target__uuid", "approved") ) payload = { "results": [ {"uuid": str(u[0]), "library": str(u[1]), "approved": u[2]} for u in follows ], "count": len(follows), } return response.Response(payload, status=200) class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = "uuid" queryset = ( music_models.Library.objects.all() .order_by("-creation_date") .select_related("actor__user") .annotate(_uploads_count=Count("uploads")) ) serializer_class = api_serializers.LibrarySerializer permission_classes = [oauth_permissions.ScopePermission] required_scope = "libraries" def get_queryset(self): qs = super().get_queryset() return qs.viewable_by(actor=self.request.user.actor) @decorators.action(methods=["post"], detail=True) def scan(self, request, *args, **kwargs): library = self.get_object() if library.actor.get_user(): return response.Response({"status": "skipped"}, 200) scan = library.schedule_scan(actor=request.user.actor) if scan: return response.Response( { "status": "scheduled", "scan": api_serializers.LibraryScanSerializer(scan).data, }, 200, ) return response.Response({"status": "skipped"}, 200) @decorators.action(methods=["post"], detail=False) def fetch(self, request, *args, **kwargs): try: fid = request.data["fid"] except KeyError: return response.Response({"fid": ["This field is required"]}) try: library = utils.retrieve_ap_object( fid, actor=request.user.actor, queryset=self.queryset, serializer_class=serializers.LibrarySerializer, ) except exceptions.BlockedActorOrDomain: return response.Response( {"detail": "This domain/account is blocked on your instance."}, status=400, ) except requests.exceptions.RequestException as e: return response.Response( {"detail": "Error while fetching the library: {}".format(str(e))}, status=400, ) except serializers.serializers.ValidationError as e: return response.Response( {"detail": "Invalid data in remote library: {}".format(str(e))}, status=400, ) serializer = self.serializer_class(library) return response.Response({"count": 1, "results": [serializer.data]}) class InboxItemViewSet( mixins.UpdateModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet, ): queryset = ( models.InboxItem.objects.select_related("activity__actor") .prefetch_related("activity__object", "activity__target") .filter(activity__type__in=activity.BROADCAST_TO_USER_ACTIVITIES, type="to") .order_by("-activity__creation_date") ) serializer_class = api_serializers.InboxItemSerializer permission_classes = [oauth_permissions.ScopePermission] required_scope = "notifications" filterset_class = filters.InboxItemFilter ordering_fields = ("activity__creation_date",) def get_queryset(self): qs = super().get_queryset() return qs.filter(actor=self.request.user.actor) @decorators.action(methods=["post"], detail=False) def action(self, request, *args, **kwargs): queryset = self.get_queryset() serializer = api_serializers.InboxItemActionSerializer( request.data, queryset=queryset ) serializer.is_valid(raise_exception=True) result = serializer.save() return response.Response(result, status=200) class FetchViewSet( mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet ): queryset = models.Fetch.objects.select_related("actor") serializer_class = api_serializers.FetchSerializer permission_classes = [permissions.IsAuthenticated] throttling_scopes = {"create": {"authenticated": "fetch"}} def get_queryset(self): return super().get_queryset().filter(actor=self.request.user.actor) def perform_create(self, serializer): fetch = serializer.save(actor=self.request.user.actor) if fetch.status == "finished": # a duplicate was returned, no need to fetch again return if settings.FEDERATION_SYNCHRONOUS_FETCH: tasks.fetch(fetch_id=fetch.pk) fetch.refresh_from_db() else: common_utils.on_commit(tasks.fetch.delay, fetch_id=fetch.pk) class DomainViewSet( mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet ): queryset = models.Domain.objects.order_by("name").external() permission_classes = [ConditionalAuthentication] serializer_class = api_serializers.DomainSerializer ordering_fields = ("creation_date", "name") max_page_size = 100 def get_queryset(self): qs = super().get_queryset() qs = qs.exclude( instance_policy__is_active=True, instance_policy__block_all=True ) if preferences.get("moderation__allow_list_enabled"): qs = qs.filter(allowed=True) return qs class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): queryset = models.Actor.objects.select_related( "user", "channel", "summary_obj", "attachment_icon" ) permission_classes = [ConditionalAuthentication] serializer_class = api_serializers.FullActorSerializer lookup_field = "full_username" lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)" def get_object(self): queryset = self.get_queryset() username, domain = self.kwargs["full_username"].split("@", 1) return queryset.get(preferred_username=username, domain_id=domain) def get_queryset(self): qs = super().get_queryset() qs = qs.exclude( domain__instance_policy__is_active=True, domain__instance_policy__block_all=True, ) if preferences.get("moderation__allow_list_enabled"): query = Q(domain_id=settings.FUNKWHALE_HOSTNAME) | Q(domain__allowed=True) qs = qs.filter(query) return qs libraries = decorators.action( methods=["get"], detail=True, serializer_class=music_serializers.LibraryForOwnerSerializer, )( music_views.get_libraries( filter_uploads=lambda o, uploads: uploads.filter(library__actor=o) ) )