Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • funkwhale/funkwhale
  • Luclu7/funkwhale
  • mbothorel/funkwhale
  • EorlBruder/funkwhale
  • tcit/funkwhale
  • JocelynDelalande/funkwhale
  • eneiluj/funkwhale
  • reg/funkwhale
  • ButterflyOfFire/funkwhale
  • m4sk1n/funkwhale
  • wxcafe/funkwhale
  • andybalaam/funkwhale
  • jcgruenhage/funkwhale
  • pblayo/funkwhale
  • joshuaboniface/funkwhale
  • n3ddy/funkwhale
  • gegeweb/funkwhale
  • tohojo/funkwhale
  • emillumine/funkwhale
  • Te-k/funkwhale
  • asaintgenis/funkwhale
  • anoadragon453/funkwhale
  • Sakada/funkwhale
  • ilianaw/funkwhale
  • l4p1n/funkwhale
  • pnizet/funkwhale
  • dante383/funkwhale
  • interfect/funkwhale
  • akhardya/funkwhale
  • svfusion/funkwhale
  • noplanman/funkwhale
  • nykopol/funkwhale
  • roipoussiere/funkwhale
  • Von/funkwhale
  • aurieh/funkwhale
  • icaria36/funkwhale
  • floreal/funkwhale
  • paulwalko/funkwhale
  • comradekingu/funkwhale
  • FurryJulie/funkwhale
  • Legolars99/funkwhale
  • Vierkantor/funkwhale
  • zachhats/funkwhale
  • heyjake/funkwhale
  • sn0w/funkwhale
  • jvoisin/funkwhale
  • gordon/funkwhale
  • Alexander/funkwhale
  • bignose/funkwhale
  • qasim.ali/funkwhale
  • fakegit/funkwhale
  • Kxze/funkwhale
  • stenstad/funkwhale
  • creak/funkwhale
  • Kaze/funkwhale
  • Tixie/funkwhale
  • IISergII/funkwhale
  • lfuelling/funkwhale
  • nhaddag/funkwhale
  • yoasif/funkwhale
  • ifischer/funkwhale
  • keslerm/funkwhale
  • flupe/funkwhale
  • petitminion/funkwhale
  • ariasuni/funkwhale
  • ollie/funkwhale
  • ngaumont/funkwhale
  • techknowlogick/funkwhale
  • Shleeble/funkwhale
  • theflyingfrog/funkwhale
  • jonatron/funkwhale
  • neobrain/funkwhale
  • eorn/funkwhale
  • KokaKiwi/funkwhale
  • u1-liquid/funkwhale
  • marzzzello/funkwhale
  • sirenwatcher/funkwhale
  • newer027/funkwhale
  • codl/funkwhale
  • Zwordi/funkwhale
  • gisforgabriel/funkwhale
  • iuriatan/funkwhale
  • simon/funkwhale
  • bheesham/funkwhale
  • zeoses/funkwhale
  • accraze/funkwhale
  • meliurwen/funkwhale
  • divadsn/funkwhale
  • Etua/funkwhale
  • sdrik/funkwhale
  • Soran/funkwhale
  • kuba-orlik/funkwhale
  • cristianvogel/funkwhale
  • Forceu/funkwhale
  • jeff/funkwhale
  • der_scheibenhacker/funkwhale
  • owlnical/funkwhale
  • jovuit/funkwhale
  • SilverFox15/funkwhale
  • phw/funkwhale
  • mayhem/funkwhale
  • sridhar/funkwhale
  • stromlin/funkwhale
  • rrrnld/funkwhale
  • nitaibezerra/funkwhale
  • jaller94/funkwhale
  • pcouy/funkwhale
  • eduxstad/funkwhale
  • codingHahn/funkwhale
  • captain/funkwhale
  • polyedre/funkwhale
  • leishenailong/funkwhale
  • ccritter/funkwhale
  • lnceballosz/funkwhale
  • fpiesche/funkwhale
  • Fanyx/funkwhale
  • markusblogde/funkwhale
  • Firobe/funkwhale
  • devilcius/funkwhale
  • freaktechnik/funkwhale
  • blopware/funkwhale
  • cone/funkwhale
  • thanksd/funkwhale
  • vachan-maker/funkwhale
  • bbenti/funkwhale
  • tarator/funkwhale
  • prplecake/funkwhale
  • DMarzal/funkwhale
  • lullis/funkwhale
  • hanacgr/funkwhale
  • albjeremias/funkwhale
  • xeruf/funkwhale
  • llelite/funkwhale
  • RoiArthurB/funkwhale
  • cloo/funkwhale
  • nztvar/funkwhale
  • Keunes/funkwhale
  • petitminion/funkwhale-petitminion
  • m-idler/funkwhale
  • SkyLeite/funkwhale
140 results
Select Git revision
Show changes
Showing
with 1732 additions and 37 deletions
from django.db import transaction
from django.db.models import Count, OuterRef, Prefetch, Q, Subquery, Sum
from django.db.models.functions import Coalesce, Collate, Length
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework import decorators as rest_decorators
from rest_framework import mixins, response, viewsets
from rest_framework.decorators import list_route
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import decorators
from funkwhale_api.common import models as common_models
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.common.mixins import MultipleLookupDetailMixin
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import tasks as moderation_tasks
from funkwhale_api.music import models as music_models
from funkwhale_api.requests import models as requests_models
from funkwhale_api.music import views as music_views
from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tags_models
from funkwhale_api.users import models as users_models
from funkwhale_api.users.permissions import HasUserPermission
from . import filters, serializers
class ManageTrackFileViewSet(
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
def get_stats(tracks, target, ignore_fields=[]):
tracks = list(tracks.values_list("pk", flat=True))
uploads = music_models.Upload.objects.filter(track__in=tracks)
fields = {
"listenings": history_models.Listening.objects.filter(track__in=tracks),
"mutations": common_models.Mutation.objects.get_for_target(target),
"playlists": (
playlists_models.PlaylistTrack.objects.filter(track__in=tracks)
.values_list("playlist", flat=True)
.distinct()
),
"track_favorites": (
favorites_models.TrackFavorite.objects.filter(track__in=tracks)
),
"libraries": (
uploads.filter(library__channel=None)
.values_list("library", flat=True)
.distinct()
),
"channels": (
uploads.exclude(library__channel=None)
.values_list("library", flat=True)
.distinct()
),
"uploads": uploads,
"reports": moderation_models.Report.objects.get_for_target(target),
}
data = {}
for key, qs in fields.items():
if key in ignore_fields:
continue
data[key] = qs.count()
data.update(get_media_stats(uploads))
return data
def get_media_stats(uploads):
data = {}
data["media_total_size"] = uploads.aggregate(v=Sum("size"))["v"] or 0
data["media_downloaded_size"] = (
uploads.with_file().aggregate(v=Sum("size"))["v"] or 0
)
return data
class ManageArtistViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
queryset = (
music_models.Artist.objects.all()
.order_by("-id")
.select_related("attributed_to", "attachment_cover", "channel")
.annotate(_tracks_count=Count("artist_credit__tracks", distinct=True))
.annotate(_albums_count=Count("artist_credit__albums", distinct=True))
.prefetch_related(music_views.TAG_PREFETCH)
)
serializer_class = serializers.ManageArtistSerializer
filterset_class = filters.ManageArtistFilterSet
required_scope = "instance:libraries"
ordering_fields = ["creation_date", "name"]
@extend_schema(operation_id="admin_get_library_artist_stats")
@rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs):
artist = self.get_object()
tracks = music_models.Track.objects.filter(
Q(artist_credit__artist=artist) | Q(album__artist_credit__artist=artist)
)
data = get_stats(tracks, artist)
return response.Response(data, status=200)
@rest_decorators.action(methods=["post"], detail=False)
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageArtistActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
class ManageAlbumViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
queryset = (
music_models.TrackFile.objects.all()
.select_related("track__artist", "track__album__artist", "library_track")
music_models.Album.objects.all()
.order_by("-id")
.select_related("attributed_to", "attachment_cover")
.prefetch_related("tracks", "artist_credit__artist")
)
serializer_class = serializers.ManageAlbumSerializer
filterset_class = filters.ManageAlbumFilterSet
required_scope = "instance:libraries"
ordering_fields = ["creation_date", "title", "release_date"]
@extend_schema(operation_id="admin_get_library_album_stats")
@rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs):
album = self.get_object()
data = get_stats(album.tracks.all(), album)
return response.Response(data, status=200)
@rest_decorators.action(methods=["post"], detail=False)
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageAlbumActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
uploads_subquery = (
music_models.Upload.objects.filter(track_id=OuterRef("pk"))
.order_by()
.values("track_id")
.annotate(track_count=Count("track_id"))
.values("track_count")
)
serializer_class = serializers.ManageTrackFileSerializer
filter_class = filters.ManageTrackFileFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["library"]
class ManageTrackViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
queryset = (
music_models.Track.objects.all()
.order_by("-id")
.prefetch_related(
"attributed_to",
"artist_credit",
"album__artist_credit",
"album__attachment_cover",
"attachment_cover",
)
.annotate(uploads_count=Coalesce(Subquery(uploads_subquery), 0))
.prefetch_related(music_views.TAG_PREFETCH)
)
serializer_class = serializers.ManageTrackSerializer
filterset_class = filters.ManageTrackFilterSet
required_scope = "instance:libraries"
ordering_fields = [
"accessed_date",
"modification_date",
"creation_date",
"track__artist__name",
"bitrate",
"size",
"duration",
"title",
"album__release_date",
"position",
"disc_number",
]
@list_route(methods=["post"])
@extend_schema(operation_id="admin_get_track_stats")
@rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs):
track = self.get_object()
data = get_stats(track.__class__.objects.filter(pk=track.pk), track)
return response.Response(data, status=200)
@rest_decorators.action(methods=["post"], detail=False)
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageTrackFileActionSerializer(
serializer = serializers.ManageTrackActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
uploads_subquery = (
music_models.Upload.objects.filter(library_id=OuterRef("pk"))
.order_by()
.values("library_id")
.annotate(library_count=Count("library_id"))
.values("library_count")
)
follows_subquery = (
federation_models.LibraryFollow.objects.filter(target_id=OuterRef("pk"))
.order_by()
.values("target_id")
.annotate(library_count=Count("target_id"))
.values("library_count")
)
class ManageLibraryViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
music_models.Library.objects.all()
.filter(channel=None)
.order_by("-id")
.select_related("actor")
.annotate(
followers_count=Coalesce(Subquery(follows_subquery), 0),
_uploads_count=Coalesce(Subquery(uploads_subquery), 0),
)
)
serializer_class = serializers.ManageLibrarySerializer
filterset_class = filters.ManageLibraryFilterSet
required_scope = "instance:libraries"
@extend_schema(operation_id="admin_get_library_stats")
@rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs):
library = self.get_object()
uploads = library.uploads.all()
tracks = uploads.values_list("track", flat=True).distinct()
albums = (
music_models.Track.objects.filter(pk__in=tracks)
.values_list("album", flat=True)
.distinct()
)
artists = set(
music_models.Album.objects.filter(pk__in=albums).values_list(
"artist_credit__artist", flat=True
)
) | set(
music_models.Track.objects.filter(pk__in=tracks).values_list(
"artist_credit__artist", flat=True
)
)
data = {
"uploads": uploads.count(),
"followers": library.received_follows.count(),
"tracks": tracks.count(),
"albums": albums.count(),
"artists": len(artists),
"reports": moderation_models.Report.objects.get_for_target(library).count(),
}
data.update(get_media_stats(uploads.all()))
return response.Response(data, status=200)
@rest_decorators.action(methods=["post"], detail=False)
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageTrackActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
class ManageUploadViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
music_models.Upload.objects.all()
.order_by("-id")
.prefetch_related(
"library__actor",
"track__artist_credit__artist",
"track__album__artist_credit__artist",
)
)
serializer_class = serializers.ManageUploadSerializer
filterset_class = filters.ManageUploadFilterSet
required_scope = "instance:libraries"
@rest_decorators.action(methods=["post"], detail=False)
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageUploadActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
......@@ -49,11 +340,10 @@ class ManageUserViewSet(
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
queryset = users_models.User.objects.all().order_by("-id")
queryset = users_models.User.objects.all().select_related("actor").order_by("-id")
serializer_class = serializers.ManageUserSerializer
filter_class = filters.ManageUserFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
filterset_class = filters.ManageUserFilterSet
required_scope = "instance:users"
ordering_fields = ["date_joined", "last_activity", "username"]
def get_serializer_context(self):
......@@ -76,15 +366,14 @@ class ManageInvitationViewSet(
.select_related("owner")
)
serializer_class = serializers.ManageInvitationSerializer
filter_class = filters.ManageInvitationFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
filterset_class = filters.ManageInvitationFilterSet
required_scope = "instance:invitations"
ordering_fields = ["creation_date", "expiration_date"]
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
@list_route(methods=["post"])
@rest_decorators.action(methods=["post"], detail=False)
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageInvitationActionSerializer(
......@@ -95,29 +384,354 @@ class ManageInvitationViewSet(
return response.Response(result, status=200)
class ManageImportRequestViewSet(
class ManageDomainViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
lookup_value_regex = r"[a-zA-Z0-9\-\.]+"
queryset = (
requests_models.ImportRequest.objects.all()
.order_by("-id")
federation_models.Domain.objects.with_actors_count()
.with_outbox_activities_count()
.prefetch_related("instance_policy")
.order_by("name")
)
serializer_class = serializers.ManageDomainSerializer
filterset_class = filters.ManageDomainFilterSet
required_scope = "instance:domains"
ordering_fields = [
"name",
"creation_date",
"nodeinfo_fetch_date",
"actors_count",
"outbox_activities_count",
"instance_policy",
]
def get_queryset(self, **kwargs):
queryset = super().get_queryset(**kwargs)
return queryset.external()
def get_serializer_class(self):
if self.action in ["update", "partial_update"]:
# A dedicated serializer for update
# to ensure domain name can't be changed
return serializers.ManageDomainUpdateSerializer
return super().get_serializer_class()
def perform_create(self, serializer):
domain = serializer.save()
federation_tasks.update_domain_nodeinfo(domain_name=domain.name)
@rest_decorators.action(methods=["get"], detail=True)
def nodeinfo(self, request, *args, **kwargs):
domain = self.get_object()
federation_tasks.update_domain_nodeinfo(domain_name=domain.name)
domain.refresh_from_db()
return response.Response(domain.nodeinfo, status=200)
@extend_schema(operation_id="admin_get_federation_domain_stats")
@rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs):
domain = self.get_object()
return response.Response(domain.get_stats(), status=200)
action = decorators.action_route(serializers.ManageDomainActionSerializer)
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")
.prefetch_related("instance_policy")
)
serializer_class = serializers.ManageActorSerializer
filterset_class = filters.ManageActorFilterSet
required_scope = "instance:accounts"
required_permissions = ["moderation"]
ordering_fields = [
"name",
"preferred_username",
"domain",
"fid",
"creation_date",
"last_fetch_date",
"uploads_count",
"outbox_activities_count",
"instance_policy",
]
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
@extend_schema(operation_id="admin_get_account_stats")
@rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs):
obj = self.get_object()
return response.Response(obj.get_stats(), status=200)
action = decorators.action_route(serializers.ManageActorActionSerializer)
class ManageInstancePolicyViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
queryset = (
moderation_models.InstancePolicy.objects.all()
.order_by("-creation_date")
.select_related()
)
serializer_class = serializers.ManageInstancePolicySerializer
filterset_class = filters.ManageInstancePolicyFilterSet
required_scope = "instance:policies"
ordering_fields = ["id", "creation_date"]
def perform_create(self, serializer):
serializer.save(actor=self.request.user.actor)
class ManageReportViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
moderation_models.Report.objects.all()
.order_by("-creation_date")
.select_related(
"submitter", "target_owner", "assigned_to", "target_content_type"
)
.prefetch_related("target")
.prefetch_related(
Prefetch(
"notes",
queryset=moderation_models.Note.objects.order_by(
"creation_date"
).select_related("author"),
to_attr="_prefetched_notes",
)
)
)
serializer_class = serializers.ManageReportSerializer
filterset_class = filters.ManageReportFilterSet
required_scope = "instance:reports"
ordering_fields = ["id", "creation_date", "handled_date"]
def perform_update(self, serializer):
is_handled = serializer.instance.is_handled
if not is_handled and serializer.validated_data.get("is_handled"):
# report was resolved, we assign to the mod making the request
serializer.save(assigned_to=self.request.user.actor)
else:
serializer.save()
class ManageNoteViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.CreateModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
moderation_models.Note.objects.all()
.order_by("-creation_date")
.select_related("author", "target_content_type")
.prefetch_related("target")
)
serializer_class = serializers.ManageNoteSerializer
filterset_class = filters.ManageNoteFilterSet
required_scope = "instance:notes"
ordering_fields = ["id", "creation_date"]
def perform_create(self, serializer):
author = self.request.user.actor
return serializer.save(author=author)
class ManageTagViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
mixins.CreateModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "name"
queryset = (
tags_models.Tag.objects.all()
.order_by("-creation_date")
.annotate(items_count=Count("tagged_items"))
.annotate(length=Length("name"))
.annotate(tag_deterministic=Collate("name", "und-x-icu"))
)
serializer_class = serializers.ManageImportRequestSerializer
filter_class = filters.ManageImportRequestFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["library"]
ordering_fields = ["creation_date", "imported_date"]
serializer_class = serializers.ManageTagSerializer
filterset_class = filters.ManageTagFilterSet
required_scope = "instance:libraries"
ordering_fields = ["id", "creation_date", "name", "items_count", "length"]
@list_route(methods=["post"])
def get_queryset(self):
queryset = super().get_queryset()
from django.contrib.contenttypes.models import ContentType
album_ct = ContentType.objects.get_for_model(music_models.Album)
track_ct = ContentType.objects.get_for_model(music_models.Track)
artist_ct = ContentType.objects.get_for_model(music_models.Artist)
queryset = queryset.annotate(
_albums_count=Count(
"tagged_items", filter=Q(tagged_items__content_type=album_ct)
),
_tracks_count=Count(
"tagged_items", filter=Q(tagged_items__content_type=track_ct)
),
_artists_count=Count(
"tagged_items", filter=Q(tagged_items__content_type=artist_ct)
),
)
return queryset
@rest_decorators.action(methods=["post"], detail=False)
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageImportRequestActionSerializer(
serializer = serializers.ManageTagActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
class ManageUserRequestViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
moderation_models.UserRequest.objects.all()
.order_by("-creation_date")
.select_related("submitter", "assigned_to")
.prefetch_related(
Prefetch(
"notes",
queryset=moderation_models.Note.objects.order_by(
"creation_date"
).select_related("author"),
to_attr="_prefetched_notes",
)
)
)
serializer_class = serializers.ManageUserRequestSerializer
filterset_class = filters.ManageUserRequestFilterSet
required_scope = "instance:requests"
ordering_fields = ["id", "creation_date", "handled_date"]
def get_queryset(self):
queryset = super().get_queryset()
if self.action in ["update", "partial_update"]:
# approved requests cannot be edited
queryset = queryset.exclude(status="approved")
return queryset
@transaction.atomic
def perform_update(self, serializer):
old_status = serializer.instance.status
new_status = serializer.validated_data.get("status")
if old_status != new_status and new_status != "pending":
# report was resolved, we assign to the mod making the request
serializer.save(assigned_to=self.request.user.actor)
common_utils.on_commit(
moderation_tasks.user_request_handle.delay,
user_request_id=serializer.instance.pk,
new_status=new_status,
old_status=old_status,
)
else:
serializer.save()
class ManageChannelViewSet(
MultipleLookupDetailMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
url_lookups = [
{
"lookup_field": "uuid",
"validator": serializers.serializers.UUIDField().to_internal_value,
},
{
"lookup_field": "username",
"validator": federation_utils.get_actor_data_from_username,
"get_query": lambda v: Q(
actor__domain=v["domain"],
actor__preferred_username__iexact=v["username"],
),
},
]
queryset = (
audio_models.Channel.objects.all()
.order_by("-id")
.select_related(
"attributed_to",
"actor",
)
.prefetch_related(
Prefetch(
"artist",
queryset=(
music_models.Artist.objects.all()
.order_by("-id")
.select_related("attributed_to", "attachment_cover", "channel")
.annotate(_tracks_count=Count("artist_credit__tracks"))
.annotate(_albums_count=Count("artist_credit__albums"))
.prefetch_related(music_views.TAG_PREFETCH)
),
)
)
)
serializer_class = serializers.ManageChannelSerializer
filterset_class = filters.ManageChannelFilterSet
required_scope = "instance:libraries"
ordering_fields = ["creation_date", "name"]
@extend_schema(operation_id="admin_get_channel_stats")
@rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs):
channel = self.get_object()
tracks = music_models.Track.objects.filter(
Q(artist_credit__artist=channel.artist)
| Q(album__artist_credit__artist=channel.artist)
)
data = get_stats(tracks, channel, ignore_fields=["libraries", "channels"])
data["follows"] = channel.actor.received_follows.count()
return response.Response(data, status=200)
def get_serializer_context(self):
context = super().get_serializer_context()
context["description"] = self.action in ["retrieve", "create", "update"]
return context
from funkwhale_api.common import admin
from . import models
@admin.register(models.InstancePolicy)
class InstancePolicyAdmin(admin.ModelAdmin):
list_display = [
"actor",
"target_domain",
"target_actor",
"creation_date",
"block_all",
"reject_media",
"silence_activity",
"silence_notifications",
]
list_filter = [
"block_all",
"reject_media",
"silence_activity",
"silence_notifications",
]
search_fields = [
"actor__fid",
"target_domain__name",
"target_domain__actor__fid",
"summary",
]
list_select_related = True
@admin.register(models.Report)
class ReportAdmin(admin.ModelAdmin):
list_display = [
"uuid",
"submitter",
"type",
"assigned_to",
"is_handled",
"creation_date",
"handled_date",
]
list_filter = ["type", "is_handled"]
search_fields = ["summary"]
list_select_related = True
@admin.register(models.UserFilter)
class UserFilterAdmin(admin.ModelAdmin):
list_display = ["uuid", "user", "target_artist", "creation_date"]
search_fields = ["target_artist__name", "user__username", "user__email"]
list_select_related = True
from django.apps import AppConfig, apps
from . import mrf
class ModerationConfig(AppConfig):
name = "funkwhale_api.moderation"
def ready(self):
super().ready()
app_names = [app.name for app in apps.app_configs.values()]
mrf.inbox.autodiscover(app_names)
import pycountry
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import serializers
from funkwhale_api.common import preferences as common_preferences
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from . import models
moderation = types.Section("moderation")
@global_preferences_registry.register
class AllowListEnabled(types.BooleanPreference):
section = moderation
name = "allow_list_enabled"
verbose_name = "Enable allow-listing"
help_text = "If enabled, only interactions with explicitly allowed domains will be authorized."
default = False
@global_preferences_registry.register
class AllowListPublic(types.BooleanPreference):
section = moderation
name = "allow_list_public"
verbose_name = "Publish your allowed-domains list"
help_text = (
"If enabled, everyone will be able to retrieve the list of domains you allowed. "
"This is useful on open setups, to help people decide if they want to join your pod, or to "
"make your moderation policy public."
)
default = False
@global_preferences_registry.register
class UnauthenticatedReportTypes(common_preferences.StringListPreference):
show_in_api = True
section = moderation
name = "unauthenticated_report_types"
default = ["takedown_request", "illegal_content"]
verbose_name = "Accountless report categories"
help_text = "A list of categories for which external users (without an account) can submit a report"
choices = models.REPORT_TYPES
field_kwargs = {"choices": choices, "required": False}
@global_preferences_registry.register
class SignupApprovalEnabled(types.BooleanPreference):
show_in_api = True
section = moderation
name = "signup_approval_enabled"
verbose_name = "Enable manual sign-up validation"
help_text = "If enabled, new registrations will go to a moderation queue and need to be reviewed by moderators."
default = False
CUSTOM_FIELDS_TYPES = [
"short_text",
"long_text",
]
class CustomFieldSerializer(serializers.Serializer):
label = serializers.CharField()
required = serializers.BooleanField(default=True)
input_type = serializers.ChoiceField(choices=CUSTOM_FIELDS_TYPES)
class CustomFormSerializer(serializers.Serializer):
help_text = common_serializers.ContentSerializer(required=False, allow_null=True)
fields = serializers.ListField(
child=CustomFieldSerializer(), min_length=0, max_length=10, required=False
)
def validate_help_text(self, v):
if not v:
return
v["html"] = common_utils.render_html(
v["text"], content_type=v["content_type"], permissive=True
)
return v
@global_preferences_registry.register
class SignupFormCustomization(common_preferences.SerializedPreference):
show_in_api = True
section = moderation
name = "signup_form_customization"
verbose_name = "Sign-up form customization"
help_text = "Configure custom fields and help text for your sign-up form"
required = False
default = {}
data_serializer_class = CustomFormSerializer
@global_preferences_registry.register
class Languages(common_preferences.StringListPreference):
show_in_api = True
section = moderation
name = "languages"
default = ["en"]
verbose_name = "Moderation languages"
help_text = (
"The language(s) spoken by the server moderator(s). Set this to inform users "
"what languages they should write reports and requests in."
)
choices = [(lang.alpha_3, lang.name) for lang in pycountry.languages]
field_kwargs = {"choices": choices, "required": False}
import factory
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import factories as federation_factories
from funkwhale_api.music import factories as music_factories
from funkwhale_api.users import factories as users_factories
from . import serializers
@registry.register
class InstancePolicyFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
summary = factory.Faker("paragraph")
actor = factory.SubFactory(federation_factories.ActorFactory)
block_all = True
is_active = True
class Meta:
model = "moderation.InstancePolicy"
class Params:
for_domain = factory.Trait(
target_domain=factory.SubFactory(federation_factories.DomainFactory)
)
for_actor = factory.Trait(
target_actor=factory.SubFactory(federation_factories.ActorFactory)
)
@registry.register
class UserFilterFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(users_factories.UserFactory)
target_artist = None
class Meta:
model = "moderation.UserFilter"
class Params:
for_artist = factory.Trait(
target_artist=factory.SubFactory(music_factories.ArtistFactory)
)
@registry.register
class NoteFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
author = factory.SubFactory(federation_factories.ActorFactory)
target = None
summary = factory.Faker("paragraph")
class Meta:
model = "moderation.Note"
@registry.register
class ReportFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
submitter = factory.SubFactory(federation_factories.ActorFactory)
target = factory.SubFactory(music_factories.ArtistFactory)
summary = factory.Faker("paragraph")
type = "other"
class Meta:
model = "moderation.Report"
class Params:
anonymous = factory.Trait(actor=None, submitter_email=factory.Faker("email"))
local = factory.Trait(fid=None)
assigned = factory.Trait(
assigned_to=factory.SubFactory(federation_factories.ActorFactory)
)
@factory.post_generation
def _set_target_owner(self, create, extracted, **kwargs):
if not self.target:
return
self.target_owner = serializers.get_target_owner(self.target)
@registry.register
class UserRequestFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
submitter = factory.SubFactory(federation_factories.ActorFactory, local=True)
class Meta:
model = "moderation.UserRequest"
class Params:
signup = factory.Trait(
submitter=factory.SubFactory(federation_factories.ActorFactory, local=True),
type="signup",
)
assigned = factory.Trait(
assigned_to=factory.SubFactory(federation_factories.ActorFactory)
)
from django.db.models import Q
from django_filters import rest_framework as filters
USER_FILTER_CONFIG = {
"ARTIST": {"target_artist": ["pk"]},
"CHANNEL": {"target_artist": ["artist__pk"]},
"ALBUM": {"target_artist": ["artist_credit__artist__pk"]},
"TRACK": {
"target_artist": [
"artist_credit__artist__pk",
"album__artist_credit__artist__pk",
]
},
"LISTENING": {
"target_artist": [
"track__album__artist_credit__artist__pk",
"track__artist_credit__artist__pk",
]
},
"TRACK_FAVORITE": {
"target_artist": [
"track__album__artist_credit__artist__pk",
"track__artist_credit__artist__pk",
]
},
}
def get_filtered_content_query(config, user):
final_query = None
for filter_field, model_fields in config.items():
query = None
ids = user.content_filters.values_list(filter_field, flat=True)
for model_field in model_fields:
q = Q(**{f"{model_field}__in": ids})
if query:
query |= q
else:
query = q
final_query = query
return final_query
class HiddenContentFilterSet(filters.FilterSet):
"""
A filterset that include a "hidden" param:
- hidden=true : list user hidden/filtered objects
- hidden=false : list all objects user hidden/filtered objects
- not specified: hidden=false
Usage:
class MyFilterSet(HiddenContentFilterSet):
class Meta:
hidden_content_fields_mapping = {'target_artist': ['pk']}
Will map UserContentFilter.artist values to the pk field of the filtered model.
"""
hidden = filters.BooleanFilter(field_name="_", method="filter_hidden_content")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.data = self.data.copy()
self.data.setdefault("hidden", False)
def filter_hidden_content(self, queryset, name, value):
user = self.request.user
if not user.is_authenticated:
# no filter to apply
return queryset
config = self.__class__.Meta.hidden_content_fields_mapping
final_query = get_filtered_content_query(config, user)
if value:
return queryset.filter(final_query)
else:
return queryset.exclude(final_query)
import json
import logging
import sys
import uuid
from django.core import validators
from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.common import session
from funkwhale_api.federation import models
from funkwhale_api.moderation import mrf
def is_uuid(v):
try:
uuid.UUID(v)
except ValueError:
return False
return True
def is_url(v):
validator = validators.URLValidator()
try:
validator(v)
except (ValueError, validators.ValidationError):
return False
return True
class Command(BaseCommand):
help = "Check a given message against all or a specific MRF rule"
def add_arguments(self, parser):
parser.add_argument(
"type",
type=str,
choices=["inbox"],
help=("The type of MRF. Only inbox is supported at the moment"),
)
parser.add_argument(
"input",
nargs="?",
help=(
"The path to a file containing JSON data. Use - to read from stdin. "
"If no input is provided, registered MRF policies will be listed "
"instead.",
),
)
parser.add_argument(
"--policy",
"-p",
dest="policies",
nargs="+",
default=False,
help="Restrict to a list of MRF policies that will be applied, in that order",
)
def handle(self, *args, **options):
logger = logging.getLogger("funkwhale.mrf")
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler(stream=sys.stderr))
input = options["input"]
if not input:
registry = getattr(mrf, options["type"])
self.stdout.write(
"No input given, listing registered policies for '{}' MRF:".format(
options["type"]
)
)
for name in registry.keys():
self.stdout.write(f"- {name}")
return
raw_content = None
content = None
if input == "-":
raw_content = sys.stdin.read()
elif is_uuid(input):
self.stderr.write("UUID provided, retrieving payload from db")
content = models.Activity.objects.get(uuid=input).payload
elif is_url(input):
response = session.get_session().get(
input,
headers={"Accept": "application/activity+json"},
)
response.raise_for_status()
content = response.json()
else:
with open(input, "rb") as f:
raw_content = f.read()
content = json.loads(raw_content) if content is None else content
policies = options["policies"] or []
registry = getattr(mrf, options["type"])
for policy in policies:
if policy not in registry:
raise CommandError(
"Unknown policy '{}' for MRF '{}'".format(policy, options["type"])
)
payload, updated = registry.apply(content, policies=policies)
if not payload:
self.stderr.write("Payload was discarded by MRF")
elif updated:
self.stderr.write("Payload was modified by MRF")
self.stderr.write("Initial payload:\n")
self.stdout.write(json.dumps(content, indent=2, sort_keys=True))
self.stderr.write("Modified payload:\n")
self.stdout.write(json.dumps(payload, indent=2, sort_keys=True))
else:
self.stderr.write("Payload left untouched by MRF")
# Generated by Django 2.0.9 on 2019-01-07 06:06
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
('federation', '0016_auto_20181227_1605'),
]
operations = [
migrations.CreateModel(
name='InstancePolicy',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('is_active', models.BooleanField(default=True)),
('summary', models.TextField(blank=True, max_length=10000, null=True)),
('block_all', models.BooleanField(default=False)),
('silence_activity', models.BooleanField(default=False)),
('silence_notifications', models.BooleanField(default=False)),
('reject_media', models.BooleanField(default=False)),
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_instance_policies', to='federation.Actor')),
('target_actor', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='instance_policy', to='federation.Actor')),
('target_domain', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='instance_policy', to='federation.Domain')),
],
),
]
# Generated by Django 2.1.5 on 2019-02-13 09:27
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("music", "0037_auto_20190103_1757"),
("moderation", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="UserFilter",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
(
"target_artist",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_filters",
to="music.Artist",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="content_filters",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AlterUniqueTogether(
name="userfilter", unique_together={("user", "target_artist")}
),
]
# Generated by Django 2.2.3 on 2019-08-01 08:34
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("federation", "0020_auto_20190730_0846"),
("moderation", "0002_auto_20190213_0927"),
]
operations = [
migrations.CreateModel(
name="Report",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("fid", models.URLField(db_index=True, max_length=500, unique=True)),
("url", models.URLField(blank=True, max_length=500, null=True)),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("summary", models.TextField(max_length=50000, null=True)),
("handled_date", models.DateTimeField(null=True)),
("is_handled", models.BooleanField(default=False)),
(
"type",
models.CharField(
choices=[
("takedown_request", "Takedown request"),
("invalid_metadata", "Invalid metadata"),
("illegal_content", "Illegal content"),
("offensive_content", "Offensive content"),
("other", "Other"),
],
max_length=40,
),
),
("submitter_email", models.EmailField(max_length=254, null=True)),
("target_id", models.IntegerField(null=True)),
(
"target_state",
django.contrib.postgres.fields.jsonb.JSONField(null=True),
),
(
"submitter",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="reports",
to="federation.Actor",
),
),
(
"assigned_to",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="assigned_reports",
to="federation.Actor",
),
),
(
"target_content_type",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.ContentType",
),
),
(
"target_owner",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="federation.Actor",
),
),
],
options={"abstract": False},
)
]
# Generated by Django 2.2.4 on 2019-08-29 09:08
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('federation', '0020_auto_20190730_0846'),
('contenttypes', '0002_remove_content_type_name'),
('moderation', '0003_report'),
]
operations = [
migrations.CreateModel(
name='Note',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('summary', models.TextField(max_length=50000)),
('target_id', models.IntegerField(null=True)),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderation_notes', to='federation.Actor')),
('target_content_type', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
],
),
]
# Generated by Django 3.0.4 on 2020-03-17 08:20
import django.contrib.postgres.fields.jsonb
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('federation', '0025_auto_20200317_0820'),
('moderation', '0004_note'),
]
operations = [
migrations.AlterField(
model_name='report',
name='summary',
field=models.TextField(blank=True, max_length=50000, null=True),
),
migrations.CreateModel(
name='UserRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(blank=True, max_length=500, null=True)),
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('handled_date', models.DateTimeField(null=True)),
('type', models.CharField(choices=[('signup', 'Sign-up')], max_length=40)),
('status', models.CharField(choices=[('pending', 'Pending'), ('refused', 'Refused'), ('approved', 'approved')], default='pending', max_length=40)),
('metadata', django.contrib.postgres.fields.jsonb.JSONField(null=True)),
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_requests', to='federation.Actor')),
('submitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests', to='federation.Actor')),
],
options={
'abstract': False,
},
),
]
# Generated by Django 3.0.8 on 2020-08-03 12:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('moderation', '0005_auto_20200317_0820'),
]
operations = [
migrations.RemoveField(
model_name='userrequest',
name='url',
),
migrations.AlterField(
model_name='userrequest',
name='status',
field=models.CharField(choices=[('pending', 'Pending'), ('refused', 'Refused'), ('approved', 'Approved')], default='pending', max_length=40),
),
]
# Generated by Django 3.2.13 on 2022-06-27 19:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('moderation', '0006_auto_20200803_1222'),
]
operations = [
migrations.AlterField(
model_name='report',
name='target_state',
field=models.JSONField(null=True),
),
migrations.AlterField(
model_name='userrequest',
name='metadata',
field=models.JSONField(null=True),
),
]
import urllib.parse
import uuid
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import JSONField
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.common import models as common_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
class InstancePolicyQuerySet(models.QuerySet):
def active(self):
return self.filter(is_active=True)
def matching_url(self, *urls):
if not urls:
return self.none()
query = None
for url in urls:
new_query = self.matching_url_query(url)
if query:
query = query | new_query
else:
query = new_query
return self.filter(query)
def matching_url_query(self, url):
parsed = urllib.parse.urlparse(url)
return models.Q(target_domain_id=parsed.hostname) | models.Q(
target_actor__fid=url
)
class InstancePolicy(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
actor = models.ForeignKey(
"federation.Actor",
related_name="created_instance_policies",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
target_domain = models.OneToOneField(
"federation.Domain",
related_name="instance_policy",
on_delete=models.CASCADE,
null=True,
blank=True,
)
target_actor = models.OneToOneField(
"federation.Actor",
related_name="instance_policy",
on_delete=models.CASCADE,
null=True,
blank=True,
)
creation_date = models.DateTimeField(default=timezone.now)
is_active = models.BooleanField(default=True)
# a summary explaining why the policy is in place
summary = models.TextField(max_length=10000, null=True, blank=True)
# either block everything (simpler, but less granularity)
block_all = models.BooleanField(default=False)
# or pick individual restrictions below
# do not show in timelines/notifications, except for actual followers
silence_activity = models.BooleanField(default=False)
silence_notifications = models.BooleanField(default=False)
# do not download any media from the target
reject_media = models.BooleanField(default=False)
objects = InstancePolicyQuerySet.as_manager()
@property
def target(self):
if self.target_actor:
return {"type": "actor", "obj": self.target_actor}
if self.target_domain_id:
return {"type": "domain", "obj": self.target_domain}
class UserFilter(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
target_artist = models.ForeignKey(
"music.Artist", on_delete=models.CASCADE, related_name="user_filters"
)
user = models.ForeignKey(
"users.User", on_delete=models.CASCADE, related_name="content_filters"
)
class Meta:
unique_together = ("user", "target_artist")
@property
def target(self):
if self.target_artist:
return {"type": "artist", "obj": self.target_artist}
REPORT_TYPES = [
("takedown_request", "Takedown request"),
("invalid_metadata", "Invalid metadata"),
("illegal_content", "Illegal content"),
("offensive_content", "Offensive content"),
("other", "Other"),
]
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, 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)
submitter_email = models.EmailField(null=True)
submitter = models.ForeignKey(
"federation.Actor",
related_name="reports",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
assigned_to = models.ForeignKey(
"federation.Actor",
related_name="assigned_reports",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
target_id = models.IntegerField(null=True)
target_content_type = models.ForeignKey(
ContentType, null=True, on_delete=models.CASCADE
)
target = GenericForeignKey("target_content_type", "target_id")
target_owner = models.ForeignKey(
"federation.Actor", on_delete=models.SET_NULL, null=True, blank=True
)
# frozen state of the target being reported, to ensure we still have info in the event of a
# delete
target_state = JSONField(null=True)
notes = GenericRelation(
"Note", content_type_field="target_content_type", object_id_field="target_id"
)
objects = common_models.GenericTargetQuerySet.as_manager()
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse("federation:reports-detail", kwargs={"uuid": self.uuid})
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)
class Note(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
summary = models.TextField(max_length=50000)
author = models.ForeignKey(
"federation.Actor", related_name="moderation_notes", on_delete=models.CASCADE
)
target_id = models.IntegerField(null=True)
target_content_type = models.ForeignKey(
ContentType, null=True, on_delete=models.CASCADE
)
target = GenericForeignKey("target_content_type", "target_id")
USER_REQUEST_TYPES = [
("signup", "Sign-up"),
]
USER_REQUEST_STATUSES = [
("pending", "Pending"),
("refused", "Refused"),
("approved", "Approved"),
]
class UserRequest(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
handled_date = models.DateTimeField(null=True)
type = models.CharField(max_length=40, choices=USER_REQUEST_TYPES)
status = models.CharField(
max_length=40, choices=USER_REQUEST_STATUSES, default="pending"
)
submitter = models.ForeignKey(
"federation.Actor",
related_name="requests",
on_delete=models.CASCADE,
)
assigned_to = models.ForeignKey(
"federation.Actor",
related_name="assigned_requests",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
metadata = JSONField(null=True)
notes = GenericRelation(
"Note", content_type_field="target_content_type", object_id_field="target_id"
)
@receiver(pre_save, sender=Report)
def set_handled_date(sender, instance, **kwargs):
if instance.is_handled and not instance.handled_date:
instance.handled_date = timezone.now()
elif not instance.is_handled:
instance.handled_date = None
"""
Inspired from the MRF logic from Pleroma, see https://docs-develop.pleroma.social/mrf.html
To support pluggable / customizable moderation using a programming language if
our exposed features aren't enough.
"""
import logging
import persisting_theory
logger = logging.getLogger("funkwhale.mrf")
class MRFException(Exception):
pass
class Discard(MRFException):
pass
class Skip(MRFException):
pass
class Registry(persisting_theory.Registry):
look_into = "mrf_policies"
def __init__(self, name=""):
self.name = name
super().__init__()
def apply(self, payload, **kwargs):
policy_names = kwargs.pop("policies", [])
if not policy_names:
policies = self.items()
else:
logger.debug(
"[MRF.%s] Running restricted list of policies %s…",
self.name,
", ".join(policy_names),
)
policies = [(name, self[name]) for name in policy_names]
updated = False
for policy_name, policy in policies:
logger.debug("[MRF.%s] Applying mrf policy '%s'", self.name, policy_name)
try:
new_payload = policy(payload, **kwargs)
except Skip as e:
logger.debug(
"[MRF.%s] Skipped policy %s because '%s'",
self.name,
policy_name,
str(e),
)
continue
except Discard as e:
logger.info(
"[MRF.%s] Discarded message per policy '%s' because '%s'",
self.name,
policy_name,
str(e),
)
return (None, False)
except Exception:
logger.exception(
"[MRF.%s] Error while applying policy '%s'!", self.name, policy_name
)
continue
if new_payload:
updated = True
payload = new_payload
return payload, updated
inbox = Registry("inbox")