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 1337 additions and 330 deletions
# Generated by Django 5.1.6 on 2025-09-12 08:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("history", "0004_listening_actor_listening_fid_listening_url"),
]
operations = [
migrations.AddField(
model_name="listening",
name="privacy_level",
field=models.CharField(
choices=[
("me", "Only me"),
("followers", "Me and my followers"),
("instance", "Everyone on my instance, and my followers"),
("everyone", "Everyone, including people on other instances"),
],
max_length=30,
default="me",
),
),
]
import uuid
from django.db import models
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.common import fields
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
from funkwhale_api.music.models import Track
class Listening(models.Model):
class ListeningQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
pass
class Listening(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE
)
user = models.ForeignKey(
"users.User",
actor = models.ForeignKey(
"federation.Actor",
related_name="listenings",
null=True,
blank=True,
on_delete=models.CASCADE,
null=False,
blank=False,
)
privacy_level = fields.get_privacy_field()
session_key = models.CharField(max_length=100, null=True, blank=True)
source = models.CharField(max_length=100, null=True, blank=True)
federation_namespace = "listenings"
objects = ListeningQuerySet.as_manager()
class Meta:
ordering = ("-creation_date",)
def get_activity_url(self):
return "{}/listenings/tracks/{}".format(self.user.get_activity_url(), self.pk)
return f"{self.actor.get_absolute_url()}/listenings/tracks/{self.pk}"
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
if not self.privacy_level:
self.privacy_level = self.actor.user.privacy_level
return super().save(**kwargs)
......@@ -3,7 +3,6 @@ from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models
......@@ -11,46 +10,39 @@ from . import models
class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user")
actor = federation_serializers.APIActorSerializer()
published = serializers.DateTimeField(source="creation_date")
class Meta:
model = models.Listening
fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj):
return "Listen"
class ListeningSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
actor = serializers.SerializerMethodField()
actor = federation_serializers.APIActorSerializer(read_only=True)
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date", "actor")
fields = ("id", "actor", "track", "creation_date", "actor")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
validated_data["actor"] = self.context["user"].actor
return super().create(validated_data)
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
class ListeningWriteSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True, required=False)
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date")
fields = ("id", "actor", "track", "creation_date")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
validated_data["actor"] = self.context["user"].actor
return super().create(validated_data)
from rest_framework import mixins, viewsets
from django.db.models import Prefetch
from rest_framework import mixins, viewsets
from config import plugins
from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track
from funkwhale_api.federation import routes
from funkwhale_api.music import utils as music_utils
from . import filters, models, serializers
from funkwhale_api.music.models import Track
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers
class ListeningViewSet(
mixins.CreateModelMixin,
......@@ -17,9 +18,8 @@ class ListeningViewSet(
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related("user__actor")
queryset = models.Listening.objects.all().select_related("actor__attachment_icon")
permission_classes = [
oauth_permissions.ScopePermission,
......@@ -28,6 +28,7 @@ class ListeningViewSet(
required_scope = "listenings"
anonymous_policy = "setting"
owner_checks = ["write"]
owner_field = "actor.user"
filterset_class = filters.ListeningFilter
def get_serializer_class(self):
......@@ -37,17 +38,41 @@ class ListeningViewSet(
def perform_create(self, serializer):
r = super().perform_create(serializer)
instance = serializer.instance
plugins.trigger_hook(
plugins.LISTENING_CREATED,
listening=instance,
confs=plugins.get_confs(self.request.user),
)
routes.outbox.dispatch(
{"type": "Listen", "object": {"type": "Track"}},
context={
"track": instance.track,
"actor": instance.actor,
"id": instance.fid,
},
)
record.send(serializer.instance)
return r
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level")
fields.privacy_level_query(
self.request.user, "privacy_level", "actor__user"
)
tracks = Track.objects.with_playable_uploads(
)
tracks = (
Track.objects.with_playable_uploads(
music_utils.get_actor_from_request(self.request)
).select_related("artist", "album__artist", "attributed_to")
)
.prefetch_related(
"artist_credit",
"album__artist_credit__artist",
"artist_credit__artist__attachment_cover",
)
.select_related("attributed_to")
)
return queryset.prefetch_related(Prefetch("track", queryset=tracks))
def get_serializer_context(self):
......
from django.forms import widgets
import pycountry
from django.core.validators import FileExtensionValidator
from django.forms import widgets
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
raven = types.Section("raven")
instance = types.Section("instance")
ui = types.Section("ui")
......@@ -38,9 +37,7 @@ class InstanceLongDescription(types.StringPreference):
name = "long_description"
verbose_name = "Long description"
default = ""
help_text = (
"Instance long description, displayed in the about page (markdown allowed)."
)
help_text = "Instance long description, displayed in the about page."
widget = widgets.Textarea
field_kwargs = {"required": False}
......@@ -52,9 +49,7 @@ class InstanceTerms(types.StringPreference):
name = "terms"
verbose_name = "Terms of service"
default = ""
help_text = (
"Terms of service and privacy policy for your instance (markdown allowed)."
)
help_text = "Terms of service and privacy policy for your instance."
widget = widgets.Textarea
field_kwargs = {"required": False}
......@@ -66,7 +61,7 @@ class InstanceRules(types.StringPreference):
name = "rules"
verbose_name = "Rules"
default = ""
help_text = "Rules/Code of Conduct (markdown allowed)."
help_text = "Rules/Code of Conduct."
widget = widgets.Textarea
field_kwargs = {"required": False}
......@@ -78,7 +73,7 @@ class InstanceContactEmail(types.StringPreference):
name = "contact_email"
verbose_name = "Contact email"
default = ""
help_text = "A contact email for visitors who need to contact an admin or moderator"
help_text = "A contact e-mail address for visitors who need to contact an admin or moderator"
field_kwargs = {"required": False}
......@@ -111,37 +106,6 @@ class InstanceFunkwhaleSupportMessageEnabled(types.BooleanPreference):
)
@global_preferences_registry.register
class RavenDSN(types.StringPreference):
show_in_api = True
section = raven
name = "front_dsn"
default = "https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4"
verbose_name = "Raven DSN key (front-end)"
help_text = (
"A Raven DSN key used to report front-ent errors to "
"a sentry instance. Keeping the default one will report errors to "
"Funkwhale developers."
)
field_kwargs = {"required": False}
@global_preferences_registry.register
class InstanceNodeinfoEnabled(types.BooleanPreference):
show_in_api = False
section = instance
name = "nodeinfo_enabled"
default = True
verbose_name = "Enable nodeinfo endpoint"
help_text = (
"This endpoint is needed for your about page to work. "
"It's also helpful for the various monitoring "
"tools that map and analyzize the fediverse, "
"but you can disable it completely if needed."
)
@global_preferences_registry.register
class InstanceNodeinfoPrivate(types.BooleanPreference):
show_in_api = False
......@@ -207,3 +171,18 @@ class Banner(ImagePreference):
default = None
help_text = "This banner will be displayed on your pod's landing and about page. At least 600x100px recommended."
field_kwargs = {"required": False}
@global_preferences_registry.register
class Location(types.ChoicePreference):
show_in_api = True
section = instance
name = "location"
verbose_name = "Server Location"
default = ""
choices = [(country.alpha_2, country.name) for country in pycountry.countries]
help_text = (
"The country or territory in which your server is located. This is displayed in the server's Nodeinfo "
"endpoint."
)
field_kwargs = {"choices": choices, "required": False}
import memoize.djangocache
import funkwhale_api
from funkwhale_api.common import preferences
from funkwhale_api.federation import actors, models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import utils as music_utils
from . import stats
store = memoize.djangocache.Cache("default")
memo = memoize.Memoizer(store, namespace="instance:stats")
def get():
all_preferences = preferences.all()
share_stats = all_preferences.get("instance__nodeinfo_stats_enabled")
allow_list_enabled = all_preferences.get("moderation__allow_list_enabled")
allow_list_public = all_preferences.get("moderation__allow_list_public")
banner = all_preferences.get("instance__banner")
unauthenticated_report_types = all_preferences.get(
"moderation__unauthenticated_report_types"
)
if allow_list_enabled and allow_list_public:
allowed_domains = list(
federation_models.Domain.objects.filter(allowed=True)
.order_by("name")
.values_list("name", flat=True)
)
else:
allowed_domains = None
data = {
"version": "2.0",
"software": {"name": "funkwhale", "version": funkwhale_api.__version__},
"protocols": ["activitypub"],
"services": {"inbound": [], "outbound": []},
"openRegistrations": all_preferences.get("users__registration_enabled"),
"usage": {"users": {"total": 0, "activeHalfyear": 0, "activeMonth": 0}},
"metadata": {
"actorId": actors.get_service_actor().fid,
"private": all_preferences.get("instance__nodeinfo_private"),
"shortDescription": all_preferences.get("instance__short_description"),
"longDescription": all_preferences.get("instance__long_description"),
"rules": all_preferences.get("instance__rules"),
"contactEmail": all_preferences.get("instance__contact_email"),
"terms": all_preferences.get("instance__terms"),
"nodeName": all_preferences.get("instance__name"),
"banner": federation_utils.full_url(banner.url) if banner else None,
"defaultUploadQuota": all_preferences.get("users__upload_quota"),
"library": {
"federationEnabled": all_preferences.get("federation__enabled"),
"federationNeedsApproval": all_preferences.get(
"federation__music_needs_approval"
),
"anonymousCanListen": not all_preferences.get(
"common__api_authentication_required"
),
},
"supportedUploadExtensions": music_utils.SUPPORTED_EXTENSIONS,
"allowList": {"enabled": allow_list_enabled, "domains": allowed_domains},
"reportTypes": [
{"type": t, "label": l, "anonymous": t in unauthenticated_report_types}
for t, l in moderation_models.REPORT_TYPES
],
"funkwhaleSupportMessageEnabled": all_preferences.get(
"instance__funkwhale_support_message_enabled"
),
"instanceSupportMessage": all_preferences.get("instance__support_message"),
},
}
if share_stats:
getter = memo(lambda: stats.get(), max_age=600)
statistics = getter()
data["usage"]["users"]["total"] = statistics["users"]["total"]
data["usage"]["users"]["activeHalfyear"] = statistics["users"][
"active_halfyear"
]
data["usage"]["users"]["activeMonth"] = statistics["users"]["active_month"]
data["metadata"]["library"]["tracks"] = {"total": statistics["tracks"]}
data["metadata"]["library"]["artists"] = {"total": statistics["artists"]}
data["metadata"]["library"]["albums"] = {"total": statistics["albums"]}
data["metadata"]["library"]["music"] = {"hours": statistics["music_duration"]}
data["metadata"]["usage"] = {
"favorites": {"tracks": {"total": statistics["track_favorites"]}},
"listenings": {"total": statistics["listenings"]},
}
return data
{
"name": "Funkwhale",
"categories": ["music", "entertainment"],
"short_name": "Funkwhale",
"description": "Your free and federated audio platform",
"icons": [
{
"src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"prefer_related_applications": true,
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=audio.funkwhale.ffa",
"id": "audio.funkwhale.ffa"
},
{
"platform": "f-droid",
"url": "https://f-droid.org/en/packages/audio.funkwhale.ffa/",
"id": "audio.funkwhale.ffa"
}
],
"shortcuts": [
{
"name": "Search",
"url": "/search",
"icons": []
},
{
"name": "Library",
"url": "/library",
"icons": []
},
{
"name": "Channels",
"url": "/subscriptions",
"icons": []
}
]
}
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from funkwhale_api.federation.utils import full_url
class SoftwareSerializer(serializers.Serializer):
name = serializers.SerializerMethodField()
version = serializers.CharField()
def get_name(self, obj) -> str:
return "funkwhale"
class SoftwareSerializer_v2(SoftwareSerializer):
repository = serializers.SerializerMethodField()
homepage = serializers.SerializerMethodField()
def get_repository(self, obj):
return "https://dev.funkwhale.audio/funkwhale/funkwhale"
def get_homepage(self, obj):
return "https://funkwhale.audio"
class ServicesSerializer(serializers.Serializer):
inbound = serializers.ListField(child=serializers.CharField(), default=[])
outbound = serializers.ListField(child=serializers.CharField(), default=[])
class UsersUsageSerializer(serializers.Serializer):
total = serializers.IntegerField()
activeHalfyear = serializers.SerializerMethodField()
activeMonth = serializers.SerializerMethodField()
def get_activeHalfyear(self, obj) -> int:
return obj.get("active_halfyear", 0)
def get_activeMonth(self, obj) -> int:
return obj.get("active_month", 0)
class UsageSerializer(serializers.Serializer):
users = UsersUsageSerializer()
localPosts = serializers.IntegerField(required=False)
localComments = serializers.IntegerField(required=False)
class TotalCountSerializer(serializers.Serializer):
total = serializers.SerializerMethodField()
def get_total(self, obj) -> int:
return obj
class TotalHoursSerializer(serializers.Serializer):
hours = serializers.SerializerMethodField()
def get_hours(self, obj) -> int:
return obj
class NodeInfoLibrarySerializer(serializers.Serializer):
federationEnabled = serializers.BooleanField()
anonymousCanListen = serializers.BooleanField()
tracks = TotalCountSerializer(default=0)
artists = TotalCountSerializer(default=0)
albums = TotalCountSerializer(default=0)
music = TotalHoursSerializer(source="music_duration", default=0)
class AllowListStatSerializer(serializers.Serializer):
enabled = serializers.BooleanField()
domains = serializers.ListField(child=serializers.CharField())
class ReportTypeSerializer(serializers.Serializer):
type = serializers.CharField()
label = serializers.CharField()
anonymous = serializers.BooleanField()
class EndpointsSerializer(serializers.Serializer):
knownNodes = serializers.URLField(default=None)
channels = serializers.URLField(default=None)
libraries = serializers.URLField(default=None)
class MetadataUsageFavoriteSerializer(serializers.Serializer):
tracks = serializers.SerializerMethodField()
@extend_schema_field(TotalCountSerializer)
def get_tracks(self, obj):
return TotalCountSerializer(obj).data
class MetadataUsageSerializer(serializers.Serializer):
favorites = MetadataUsageFavoriteSerializer(source="track_favorites")
listenings = TotalCountSerializer()
downloads = TotalCountSerializer()
class MetadataSerializer(serializers.Serializer):
actorId = serializers.CharField()
private = serializers.SerializerMethodField()
shortDescription = serializers.SerializerMethodField()
longDescription = serializers.SerializerMethodField()
contactEmail = serializers.SerializerMethodField()
nodeName = serializers.SerializerMethodField()
banner = serializers.SerializerMethodField()
defaultUploadQuota = serializers.SerializerMethodField()
supportedUploadExtensions = serializers.ListField(child=serializers.CharField())
allowList = serializers.SerializerMethodField()
funkwhaleSupportMessageEnabled = serializers.SerializerMethodField()
instanceSupportMessage = serializers.SerializerMethodField()
usage = MetadataUsageSerializer(source="stats", required=False)
def get_private(self, obj) -> bool:
return obj["preferences"].get("instance__nodeinfo_private")
def get_shortDescription(self, obj) -> str:
return obj["preferences"].get("instance__short_description")
def get_longDescription(self, obj) -> str:
return obj["preferences"].get("instance__long_description")
def get_contactEmail(self, obj) -> str:
return obj["preferences"].get("instance__contact_email")
def get_nodeName(self, obj) -> str:
return obj["preferences"].get("instance__name")
@extend_schema_field(serializers.CharField)
def get_banner(self, obj) -> (str, None):
if obj["preferences"].get("instance__banner"):
return full_url(obj["preferences"].get("instance__banner").url)
return None
def get_defaultUploadQuota(self, obj) -> int:
return obj["preferences"].get("users__upload_quota")
@extend_schema_field(AllowListStatSerializer)
def get_allowList(self, obj):
return AllowListStatSerializer(
{
"enabled": obj["preferences"].get("moderation__allow_list_enabled"),
"domains": obj["allowed_domains"] or None,
}
).data
def get_funkwhaleSupportMessageEnabled(self, obj) -> bool:
return obj["preferences"].get("instance__funkwhale_support_message_enabled")
def get_instanceSupportMessage(self, obj) -> str:
return obj["preferences"].get("instance__support_message")
@extend_schema_field(MetadataUsageSerializer)
def get_usage(self, obj):
return MetadataUsageSerializer(obj["stats"]).data
class Metadata20Serializer(MetadataSerializer):
library = serializers.SerializerMethodField()
reportTypes = ReportTypeSerializer(source="report_types", many=True)
endpoints = EndpointsSerializer()
rules = serializers.SerializerMethodField()
terms = serializers.SerializerMethodField()
def get_rules(self, obj) -> str:
return obj["preferences"].get("instance__rules")
def get_terms(self, obj) -> str:
return obj["preferences"].get("instance__terms")
@extend_schema_field(NodeInfoLibrarySerializer)
def get_library(self, obj):
data = obj["stats"] or {}
data["federationEnabled"] = obj["preferences"].get("federation__enabled")
data["anonymousCanListen"] = not obj["preferences"].get(
"common__api_authentication_required"
)
return NodeInfoLibrarySerializer(data).data
class MetadataContentLocalSerializer(serializers.Serializer):
artists = serializers.IntegerField()
releases = serializers.IntegerField()
recordings = serializers.IntegerField()
hoursOfContent = serializers.IntegerField()
class MetadataContentCategorySerializer(serializers.Serializer):
name = serializers.CharField()
count = serializers.IntegerField()
class MetadataContentSerializer(serializers.Serializer):
local = MetadataContentLocalSerializer()
topMusicCategories = MetadataContentCategorySerializer(many=True)
topPodcastCategories = MetadataContentCategorySerializer(many=True)
class Metadata21Serializer(MetadataSerializer):
languages = serializers.ListField(child=serializers.CharField())
location = serializers.CharField()
content = MetadataContentSerializer()
features = serializers.ListField(child=serializers.CharField())
codeOfConduct = serializers.SerializerMethodField()
def get_codeOfConduct(self, obj) -> str:
return (
full_url("/about/pod#rules")
if obj["preferences"].get("instance__rules")
else ""
)
class NodeInfo20Serializer(serializers.Serializer):
version = serializers.SerializerMethodField()
software = SoftwareSerializer()
protocols = serializers.SerializerMethodField()
services = ServicesSerializer(default={})
openRegistrations = serializers.SerializerMethodField()
usage = serializers.SerializerMethodField()
metadata = serializers.SerializerMethodField()
def get_version(self, obj) -> str:
return "2.0"
def get_protocols(self, obj) -> list:
return ["activitypub"]
def get_services(self, obj) -> object:
return {"inbound": [], "outbound": []}
def get_openRegistrations(self, obj) -> bool:
return obj["preferences"]["users__registration_enabled"]
@extend_schema_field(UsageSerializer)
def get_usage(self, obj):
usage = None
if obj["preferences"]["instance__nodeinfo_stats_enabled"]:
usage = obj["stats"]
else:
usage = {"users": {"total": 0, "activeMonth": 0, "activeHalfyear": 0}}
return UsageSerializer(usage).data
@extend_schema_field(Metadata20Serializer)
def get_metadata(self, obj):
return Metadata20Serializer(obj).data
class NodeInfo21Serializer(NodeInfo20Serializer):
version = serializers.SerializerMethodField()
software = SoftwareSerializer_v2()
def get_version(self, obj) -> str:
return "2.1"
@extend_schema_field(UsageSerializer)
def get_usage(self, obj):
usage = None
if obj["preferences"]["instance__nodeinfo_stats_enabled"]:
usage = obj["stats"]
usage["localPosts"] = 0
usage["localComments"] = 0
else:
usage = {
"users": {"total": 0, "activeMonth": 0, "activeHalfyear": 0},
"localPosts": 0,
"localComments": 0,
}
return UsageSerializer(usage).data
@extend_schema_field(Metadata21Serializer)
def get_metadata(self, obj):
return Metadata21Serializer(obj).data
class SpaManifestIconSerializer(serializers.Serializer):
src = serializers.CharField()
sizes = serializers.CharField()
type = serializers.CharField()
class SpaManifestRelatedApplicationsSerializer(serializers.Serializer):
platform = serializers.CharField()
url = serializers.URLField()
id = serializers.CharField()
class SpaManifestShortcutSerializer(serializers.Serializer):
name = serializers.CharField()
url = serializers.CharField()
icons = SpaManifestIconSerializer(many=True, required=False)
class SpaManifestSerializer(serializers.Serializer):
name = serializers.CharField(default="Funkwhale")
short_name = serializers.CharField(default="Funkwhale")
display = serializers.CharField(required=False)
background_color = serializers.CharField(required=False)
lang = serializers.CharField(required=False)
categories = serializers.ListField(child=serializers.CharField(), required=False)
description = serializers.CharField(required=False)
icons = SpaManifestIconSerializer(many=True, required=False)
start_url = serializers.CharField(required=False)
prefer_related_applications = serializers.BooleanField(required=False)
related_applications = SpaManifestRelatedApplicationsSerializer(
many=True, required=False
)
shortcuts = SpaManifestShortcutSerializer(many=True, required=False)
import datetime
from django.db.models import Sum
from django.db.models import Count, F, Sum
from django.utils import timezone
from funkwhale_api.favorites.models import TrackFavorite
......@@ -17,10 +17,44 @@ def get():
"artists": get_artists(),
"track_favorites": get_track_favorites(),
"listenings": get_listenings(),
"downloads": get_downloads(),
"music_duration": get_music_duration(),
}
def get_content():
return {
"local": {
"artists": get_artists(),
"releases": get_albums(),
"recordings": get_tracks(),
"hoursOfContent": get_music_duration(),
},
"topMusicCategories": get_top_music_categories(),
"topPodcastCategories": get_top_podcast_categories(),
}
def get_top_music_categories():
return (
models.Track.objects.filter(artist_credit__artist__content_category="music")
.exclude(tagged_items__tag_id=None)
.values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name"))
.order_by("-count")[:3]
)
def get_top_podcast_categories():
return (
models.Track.objects.filter(artist_credit__artist__content_category="podcast")
.exclude(tagged_items__tag_id=None)
.values(name=F("tagged_items__tag__name"))
.annotate(count=Count("name"))
.order_by("-count")[:3]
)
def get_users():
qs = User.objects.filter(is_active=True)
now = timezone.now()
......@@ -43,15 +77,19 @@ def get_track_favorites():
def get_tracks():
return models.Track.objects.count()
return models.Track.objects.local().count()
def get_albums():
return models.Album.objects.count()
return models.Album.objects.local().count()
def get_artists():
return models.Artist.objects.count()
return models.Artist.objects.local().count()
def get_downloads():
return models.Track.objects.aggregate(d=Sum("downloads_count"))["d"] or 0
def get_music_duration():
......
from django.conf.urls import url
from django.urls import re_path
from funkwhale_api.common import routers
from . import views
......@@ -7,6 +8,7 @@ admin_router = routers.OptionalSlashRouter()
admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
urlpatterns = [
url(r"^nodeinfo/2.0/?$", views.NodeInfo.as_view(), name="nodeinfo-2.0"),
url(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
re_path(r"^nodeinfo/2.0/?$", views.NodeInfo20.as_view(), name="nodeinfo-2.0"),
re_path(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
re_path(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"),
] + admin_router.urls
from django.urls import re_path
from funkwhale_api.common import routers
from . import views
admin_router = routers.OptionalSlashRouter()
admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
urlpatterns = [
re_path(r"^nodeinfo/2.1/?$", views.NodeInfo21.as_view(), name="nodeinfo-2.1"),
re_path(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
re_path(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"),
] + admin_router.urls
from dynamic_preferences.api import serializers
import json
import logging
from pathlib import Path
from cache_memoize import cache_memoize
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie
from drf_spectacular.utils import extend_schema
from dynamic_preferences.api import viewsets as preferences_viewsets
from dynamic_preferences.api.serializers import GlobalPreferenceSerializer
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import views
from rest_framework import generics, views
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from funkwhale_api import __version__ as funkwhale_version
from funkwhale_api.common import preferences
from funkwhale_api.common.renderers import ActivityStreamRenderer
from funkwhale_api.federation.actors import get_service_actor
from funkwhale_api.federation.models import Domain
from funkwhale_api.moderation.models import REPORT_TYPES
from funkwhale_api.music.utils import SUPPORTED_EXTENSIONS
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import nodeinfo
from . import serializers, stats
NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" # noqa
logger = logging.getLogger(__name__)
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
pagination_class = None
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "instance:settings"
class InstanceSettings(views.APIView):
class InstanceSettings(generics.GenericAPIView):
permission_classes = []
authentication_classes = []
serializer_class = GlobalPreferenceSerializer
def get(self, request, *args, **kwargs):
def get_queryset(self):
manager = global_preferences_registry.manager()
manager.all()
all_preferences = manager.model.objects.all().order_by("section", "name")
api_preferences = [
p for p in all_preferences if getattr(p.preference, "show_in_api", False)
]
data = serializers.GlobalPreferenceSerializer(api_preferences, many=True).data
return api_preferences
@extend_schema(operation_id="get_instance_settings")
def get(self, request):
queryset = self.get_queryset()
data = GlobalPreferenceSerializer(queryset, many=True).data
return Response(data, status=200)
class NodeInfo(views.APIView):
@method_decorator(ensure_csrf_cookie, name="dispatch")
class NodeInfo20(views.APIView):
permission_classes = []
authentication_classes = []
serializer_class = serializers.NodeInfo20Serializer
renderer_classes = (JSONRenderer,)
@extend_schema(
responses=serializers.NodeInfo20Serializer, operation_id="getNodeInfo20"
)
def get(self, request):
pref = preferences.all()
if (
pref["moderation__allow_list_public"]
and pref["moderation__allow_list_enabled"]
):
allowed_domains = list(
Domain.objects.filter(allowed=True)
.order_by("name")
.values_list("name", flat=True)
)
else:
allowed_domains = None
data = {
"software": {"version": funkwhale_version},
"services": {"inbound": ["atom1.0"], "outbound": ["atom1.0"]},
"preferences": pref,
"stats": cache_memoize(600, prefix="memoize:instance:stats")(stats.get)()
if pref["instance__nodeinfo_stats_enabled"]
else None,
"actorId": get_service_actor().fid,
"supportedUploadExtensions": SUPPORTED_EXTENSIONS,
"allowed_domains": allowed_domains,
"report_types": [
{
"type": t,
"label": l,
"anonymous": t
in pref.get("moderation__unauthenticated_report_types"),
}
for t, l in REPORT_TYPES
],
"endpoints": {},
}
if not pref.get("common__api_authentication_required"):
if pref.get("instance__nodeinfo_stats_enabled"):
data["endpoints"]["knownNodes"] = reverse(
"api:v1:federation:domains-list"
)
if pref.get("federation__public_index"):
data["endpoints"]["libraries"] = reverse(
"federation:index:index-libraries"
)
data["endpoints"]["channels"] = reverse(
"federation:index:index-channels"
)
serializer = self.serializer_class(data)
return Response(
serializer.data, status=200, content_type=NODEINFO_2_CONTENT_TYPE
)
class NodeInfo21(NodeInfo20):
serializer_class = serializers.NodeInfo21Serializer
@extend_schema(
responses=serializers.NodeInfo21Serializer, operation_id="getNodeInfo21"
)
def get(self, request):
pref = preferences.all()
if (
pref["moderation__allow_list_public"]
and pref["moderation__allow_list_enabled"]
):
allowed_domains = list(
Domain.objects.filter(allowed=True)
.order_by("name")
.values_list("name", flat=True)
)
else:
allowed_domains = None
data = {
"software": {"version": funkwhale_version},
"services": {"inbound": ["atom1.0"], "outbound": ["atom1.0"]},
"preferences": pref,
"stats": cache_memoize(600, prefix="memoize:instance:stats")(stats.get)()
if pref["instance__nodeinfo_stats_enabled"]
else None,
"actorId": get_service_actor().fid,
"supportedUploadExtensions": SUPPORTED_EXTENSIONS,
"allowed_domains": allowed_domains,
"languages": pref.get("moderation__languages"),
"location": pref.get("instance__location"),
"content": cache_memoize(600, prefix="memoize:instance:content")(
stats.get_content
)()
if pref["instance__nodeinfo_stats_enabled"]
else None,
"features": [
"channels",
"podcasts",
],
}
if not pref.get("common__api_authentication_required"):
data["features"].append("anonymousCanListen")
if pref.get("federation__enabled"):
data["features"].append("federation")
if pref.get("music__only_allow_musicbrainz_tagged_files"):
data["features"].append("onlyMbidTaggedContent")
serializer = self.serializer_class(data)
return Response(
serializer.data, status=200, content_type=NODEINFO_2_CONTENT_TYPE
)
PWA_MANIFEST_PATH = Path(__file__).parent / "pwa-manifest.json"
PWA_MANIFEST: dict = json.loads(PWA_MANIFEST_PATH.read_text(encoding="utf-8"))
class SpaManifest(generics.GenericAPIView):
permission_classes = []
authentication_classes = []
serializer_class = serializers.SpaManifestSerializer
renderer_classes = [ActivityStreamRenderer]
def get(self, request, *args, **kwargs):
if not preferences.get("instance__nodeinfo_enabled"):
return Response(status=404)
data = nodeinfo.get()
return Response(data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)
@extend_schema(operation_id="get_spa_manifest")
def get(self, request):
manifest = PWA_MANIFEST.copy()
instance_name = preferences.get("instance__name")
if instance_name:
manifest["short_name"] = instance_name
manifest["name"] = instance_name
instance_description = preferences.get("instance__short_description")
if instance_description:
manifest["description"] = instance_description
serializer = self.get_serializer(manifest)
return Response(
serializer.data, status=200, content_type="application/manifest+json"
)
#!/usr/bin/env python3
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
try:
import django
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
if len(sys.argv) > 1 and sys.argv[1] in ["fw", "funkwhale"]:
django.setup()
from funkwhale_api.cli import main as cli
sys.argv = sys.argv[1:]
cli.invoke()
else:
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
import django_filters
from django import forms
from django.db.models import Q
import django_filters
from django.db.models.functions import Collate
from django_filters import rest_framework as filters
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import fields
from funkwhale_api.common import filters as common_filters
from funkwhale_api.common import search
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import utils as moderation_utils
from funkwhale_api.music import models as music_models
from funkwhale_api.users import models as users_models
from funkwhale_api.tags import models as tags_models
from funkwhale_api.users import models as users_models
class ActorField(forms.CharField):
......@@ -34,6 +34,34 @@ def get_actor_filter(actor_field):
return {"field": ActorField(), "handler": handler}
class ManageChannelFilterSet(filters.FilterSet):
q = fields.SmartSearchFilter(
config=search.SearchConfig(
search_fields={
"name": {"to": "artist__name"},
"username": {"to": "artist__name"},
"fid": {"to": "artist__fid"},
"rss": {"to": "rss_url"},
},
filter_fields={
"uuid": {"to": "uuid"},
"category": {"to": "artist__content_category"},
"domain": {
"handler": lambda v: federation_utils.get_domain_query_from_url(
v, url_field="attributed_to__fid"
)
},
"tag": {"to": "artist__tagged_items__tag__name", "distinct": True},
"account": get_actor_filter("attributed_to"),
},
)
)
class Meta:
model = audio_models.Channel
fields = []
class ManageArtistFilterSet(filters.FilterSet):
q = fields.SmartSearchFilter(
config=search.SearchConfig(
......@@ -52,6 +80,7 @@ class ManageArtistFilterSet(filters.FilterSet):
"field": forms.IntegerField(),
"distinct": True,
},
"category": {"to": "content_category"},
"tag": {"to": "tagged_items__tag__name", "distinct": True},
},
)
......@@ -59,7 +88,7 @@ class ManageArtistFilterSet(filters.FilterSet):
class Meta:
model = music_models.Artist
fields = ["q", "name", "mbid", "fid"]
fields = ["name", "mbid", "fid", "content_category"]
class ManageAlbumFilterSet(filters.FilterSet):
......@@ -68,12 +97,15 @@ class ManageAlbumFilterSet(filters.FilterSet):
search_fields={
"title": {"to": "title"},
"fid": {"to": "fid"},
"artist": {"to": "artist__name"},
"artist": {"to": "artist_credit__artist__name"},
"mbid": {"to": "mbid"},
},
filter_fields={
"uuid": {"to": "uuid"},
"artist_id": {"to": "artist_id", "field": forms.IntegerField()},
"artist_id": {
"to": "artist_credit__artist_id",
"field": forms.IntegerField(),
},
"domain": {
"handler": lambda v: federation_utils.get_domain_query_from_url(v)
},
......@@ -89,7 +121,7 @@ class ManageAlbumFilterSet(filters.FilterSet):
class Meta:
model = music_models.Album
fields = ["q", "title", "mbid", "fid", "artist"]
fields = ["title", "mbid", "fid", "artist_credit"]
class ManageTrackFilterSet(filters.FilterSet):
......@@ -99,9 +131,9 @@ class ManageTrackFilterSet(filters.FilterSet):
"title": {"to": "title"},
"fid": {"to": "fid"},
"mbid": {"to": "mbid"},
"artist": {"to": "artist__name"},
"artist": {"to": "artist_credit__artist__name"},
"album": {"to": "album__title"},
"album_artist": {"to": "album__artist__name"},
"album_artist": {"to": "album__artist_credit__artist__name"},
"copyright": {"to": "copyright"},
},
filter_fields={
......@@ -128,7 +160,7 @@ class ManageTrackFilterSet(filters.FilterSet):
class Meta:
model = music_models.Track
fields = ["q", "title", "mbid", "fid", "artist", "album", "license"]
fields = ["title", "mbid", "fid", "artist_credit", "album", "license"]
class ManageLibraryFilterSet(filters.FilterSet):
......@@ -174,7 +206,7 @@ class ManageLibraryFilterSet(filters.FilterSet):
class Meta:
model = music_models.Library
fields = ["q", "name", "fid", "privacy_level", "domain"]
fields = ["name", "fid", "privacy_level"]
class ManageUploadFilterSet(filters.FilterSet):
......@@ -219,10 +251,7 @@ class ManageUploadFilterSet(filters.FilterSet):
class Meta:
model = music_models.Upload
fields = [
"q",
"fid",
"privacy_level",
"domain",
"mimetype",
"import_reference",
"import_status",
......@@ -245,7 +274,7 @@ class ManageDomainFilterSet(filters.FilterSet):
class Meta:
model = federation_models.Domain
fields = ["name", "allowed"]
fields = ["name"]
class ManageActorFilterSet(filters.FilterSet):
......@@ -270,7 +299,7 @@ class ManageActorFilterSet(filters.FilterSet):
class Meta:
model = federation_models.Actor
fields = ["q", "domain", "type", "manually_approves_followers", "local"]
fields = ["domain", "type", "manually_approves_followers"]
def filter_local(self, queryset, name, value):
return queryset.local(value)
......@@ -290,7 +319,6 @@ class ManageUserFilterSet(filters.FilterSet):
class Meta:
model = users_models.User
fields = [
"q",
"is_active",
"privacy_level",
"is_staff",
......@@ -307,7 +335,7 @@ class ManageInvitationFilterSet(filters.FilterSet):
class Meta:
model = users_models.Invitation
fields = ["q", "is_open"]
fields = []
def filter_is_open(self, queryset, field_name, value):
if value is None:
......@@ -332,14 +360,10 @@ class ManageInstancePolicyFilterSet(filters.FilterSet):
class Meta:
model = moderation_models.InstancePolicy
fields = [
"q",
"block_all",
"silence_activity",
"silence_notifications",
"reject_media",
"target_domain",
"target_account_domain",
"target_account_username",
]
......@@ -348,7 +372,14 @@ class ManageTagFilterSet(filters.FilterSet):
class Meta:
model = tags_models.Tag
fields = ["q"]
fields = []
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.annotate(tag_deterministic=Collate("name", "und-x-icu"))
)
class ManageReportFilterSet(filters.FilterSet):
......@@ -374,7 +405,7 @@ class ManageReportFilterSet(filters.FilterSet):
class Meta:
model = moderation_models.Report
fields = ["q", "is_handled", "type", "submitter_email"]
fields = ["is_handled", "type", "submitter_email"]
class ManageNoteFilterSet(filters.FilterSet):
......@@ -393,4 +424,27 @@ class ManageNoteFilterSet(filters.FilterSet):
class Meta:
model = moderation_models.Note
fields = ["q"]
fields = []
class ManageUserRequestFilterSet(filters.FilterSet):
q = fields.SmartSearchFilter(
config=search.SearchConfig(
search_fields={
"username": {"to": "submitter__preferred_username"},
"uuid": {"to": "uuid"},
},
filter_fields={
"uuid": {"to": "uuid"},
"id": {"to": "id"},
"status": {"to": "status"},
"category": {"to": "type"},
"submitter": get_actor_filter("submitter"),
"assigned_to": get_actor_filter("assigned_to"),
},
)
)
class Meta:
model = moderation_models.UserRequest
fields = ["status", "type"]
from django.conf import settings
from django.db import transaction
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import fields as common_fields
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import fields as federation_fields
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
......@@ -48,7 +50,7 @@ class ManageUserSimpleSerializer(serializers.ModelSerializer):
class ManageUserSerializer(serializers.ModelSerializer):
permissions = PermissionsSerializer(source="*")
upload_quota = serializers.IntegerField(allow_null=True)
upload_quota = serializers.IntegerField(allow_null=True, required=False)
actor = serializers.SerializerMethodField()
class Meta:
......@@ -65,8 +67,8 @@ class ManageUserSerializer(serializers.ModelSerializer):
"date_joined",
"last_activity",
"permissions",
"privacy_level",
"upload_quota",
"privacy_level",
"full_username",
)
read_only_fields = [
......@@ -83,12 +85,11 @@ class ManageUserSerializer(serializers.ModelSerializer):
permissions = validated_data.pop("permissions", {})
if permissions:
for p, value in permissions.items():
setattr(instance, "permission_{}".format(p), value)
instance.save(
update_fields=["permission_{}".format(p) for p in permissions.keys()]
)
setattr(instance, f"permission_{p}", value)
instance.save(update_fields=[f"permission_{p}" for p in permissions.keys()])
return instance
@extend_schema_field(OpenApiTypes.OBJECT)
def get_actor(self, obj):
if obj.actor:
return ManageBaseActorSerializer(obj.actor).data
......@@ -97,12 +98,28 @@ class ManageUserSerializer(serializers.ModelSerializer):
class ManageInvitationSerializer(serializers.ModelSerializer):
users = ManageUserSimpleSerializer(many=True, required=False)
owner = ManageUserSimpleSerializer(required=False)
invited_user = ManageUserSimpleSerializer(required=False)
code = serializers.CharField(required=False, allow_null=True)
class Meta:
model = users_models.Invitation
fields = ("id", "owner", "code", "expiration_date", "creation_date", "users")
read_only_fields = ["id", "expiration_date", "owner", "creation_date", "users"]
fields = (
"id",
"owner",
"invited_user",
"code",
"expiration_date",
"creation_date",
"users",
)
read_only_fields = [
"id",
"expiration_date",
"owner",
"invited_user",
"creation_date",
"users",
]
def validate_code(self, value):
if not value:
......@@ -150,10 +167,10 @@ class ManageDomainSerializer(serializers.ModelSerializer):
"nodeinfo_fetch_date",
]
def get_actors_count(self, o):
def get_actors_count(self, o) -> int:
return getattr(o, "actors_count", 0)
def get_outbox_activities_count(self, o):
def get_outbox_activities_count(self, o) -> int:
return getattr(o, "outbox_activities_count", 0)
......@@ -210,13 +227,13 @@ class ManageBaseActorSerializer(serializers.ModelSerializer):
]
read_only_fields = ["creation_date", "instance_policy"]
def get_is_local(self, o):
def get_is_local(self, o) -> bool:
return o.domain_id == settings.FEDERATION_HOSTNAME
class ManageActorSerializer(ManageBaseActorSerializer):
uploads_count = serializers.SerializerMethodField()
user = ManageUserSerializer()
user = ManageUserSerializer(allow_null=True)
class Meta:
model = federation_models.Actor
......@@ -227,7 +244,7 @@ class ManageActorSerializer(ManageBaseActorSerializer):
]
read_only_fields = ["creation_date", "instance_policy"]
def get_uploads_count(self, o):
def get_uploads_count(self, o) -> int:
return getattr(o, "uploads_count", 0)
......@@ -241,7 +258,7 @@ class ManageActorActionSerializer(common_serializers.ActionSerializer):
common_utils.on_commit(federation_tasks.purge_actors.delay, ids=list(ids))
class TargetSerializer(serializers.Serializer):
class ManageTargetSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["domain", "actor"])
id = serializers.CharField()
......@@ -263,7 +280,7 @@ class TargetSerializer(serializers.Serializer):
class ManageInstancePolicySerializer(serializers.ModelSerializer):
target = TargetSerializer()
target = ManageTargetSerializer()
actor = federation_fields.ActorRelatedField(read_only=True)
class Meta:
......@@ -335,6 +352,7 @@ class ManageBaseArtistSerializer(serializers.ModelSerializer):
class ManageBaseAlbumSerializer(serializers.ModelSerializer):
cover = music_serializers.cover_field
domain = serializers.CharField(source="domain_name")
tracks_count = serializers.SerializerMethodField()
class Meta:
model = music_models.Album
......@@ -348,8 +366,13 @@ class ManageBaseAlbumSerializer(serializers.ModelSerializer):
"cover",
"domain",
"is_local",
"tracks_count",
]
@extend_schema_field(OpenApiTypes.INT)
def get_tracks_count(self, o):
return getattr(o, "_tracks_count", None)
class ManageNestedTrackSerializer(serializers.ModelSerializer):
domain = serializers.CharField(source="domain_name")
......@@ -372,89 +395,129 @@ class ManageNestedTrackSerializer(serializers.ModelSerializer):
class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer):
tracks_count = serializers.SerializerMethodField()
class Meta:
model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + ["tracks_count"]
@extend_schema_field(OpenApiTypes.INT)
def get_tracks_count(self, obj):
return getattr(obj, "tracks_count", None)
class ManageArtistSerializer(ManageBaseArtistSerializer):
albums = ManageNestedAlbumSerializer(many=True)
tracks = ManageNestedTrackSerializer(many=True)
class ManageArtistSerializer(
music_serializers.OptionalDescriptionMixin, ManageBaseArtistSerializer
):
attributed_to = ManageBaseActorSerializer()
tags = serializers.SerializerMethodField()
tracks_count = serializers.SerializerMethodField()
albums_count = serializers.SerializerMethodField()
channel = serializers.SerializerMethodField()
cover = music_serializers.CoverField(allow_null=True)
class Meta:
model = music_models.Artist
fields = ManageBaseArtistSerializer.Meta.fields + [
"albums",
"tracks",
"tracks_count",
"albums_count",
"attributed_to",
"tags",
"cover",
"channel",
"content_category",
]
@extend_schema_field(OpenApiTypes.INT)
def get_tracks_count(self, obj):
return getattr(obj, "_tracks_count", None)
@extend_schema_field(OpenApiTypes.INT)
def get_albums_count(self, obj):
return getattr(obj, "_albums_count", None)
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_tags(self, obj):
tagged_items = getattr(obj, "_prefetched_tagged_items", [])
return [ti.tag.name for ti in tagged_items]
@extend_schema_field(OpenApiTypes.STR)
def get_channel(self, obj):
if "channel" in obj._state.fields_cache and obj.get_channel():
return str(obj.channel.uuid)
class ManageNestedArtistSerializer(ManageBaseArtistSerializer):
pass
class ManageAlbumSerializer(ManageBaseAlbumSerializer):
tracks = ManageNestedTrackSerializer(many=True)
attributed_to = ManageBaseActorSerializer()
class ManageNestedArtistCreditSerializer(ManageBaseArtistSerializer):
artist = ManageNestedArtistSerializer()
class Meta:
model = music_models.ArtistCredit
fields = ["artist"]
class ManageAlbumSerializer(
music_serializers.OptionalDescriptionMixin, ManageBaseAlbumSerializer
):
attributed_to = ManageBaseActorSerializer()
artist_credit = ManageNestedArtistCreditSerializer(many=True)
tags = serializers.SerializerMethodField()
class Meta:
model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + [
"artist",
"tracks",
"artist_credit",
"attributed_to",
"tags",
"tracks_count",
]
def get_tracks_count(self, o) -> int:
return len(o.tracks.all())
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_tags(self, obj):
tagged_items = getattr(obj, "_prefetched_tagged_items", [])
return [ti.tag.name for ti in tagged_items]
class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer):
artist = ManageNestedArtistSerializer()
artist_credit = ManageNestedArtistCreditSerializer(many=True)
class Meta:
model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + ["artist"]
fields = ManageBaseAlbumSerializer.Meta.fields + ["artist_credit"]
class ManageTrackSerializer(ManageNestedTrackSerializer):
artist = ManageNestedArtistSerializer()
album = ManageTrackAlbumSerializer()
attributed_to = ManageBaseActorSerializer()
class ManageTrackSerializer(
music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer
):
artist_credit = ManageNestedArtistCreditSerializer(many=True)
album = ManageTrackAlbumSerializer(allow_null=True)
attributed_to = ManageBaseActorSerializer(allow_null=True)
uploads_count = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField()
cover = music_serializers.cover_field
class Meta:
model = music_models.Track
fields = ManageNestedTrackSerializer.Meta.fields + [
"artist",
"artist_credit",
"album",
"attributed_to",
"uploads_count",
"tags",
"cover",
]
@extend_schema_field(OpenApiTypes.INT)
def get_uploads_count(self, obj):
return getattr(obj, "uploads_count", None)
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_tags(self, obj):
tagged_items = getattr(obj, "_prefetched_tagged_items", [])
return [ti.tag.name for ti in tagged_items]
......@@ -509,7 +572,6 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
domain = serializers.CharField(source="domain_name")
actor = ManageBaseActorSerializer()
uploads_count = serializers.SerializerMethodField()
followers_count = serializers.SerializerMethodField()
class Meta:
model = music_models.Library
......@@ -519,14 +581,11 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
"fid",
"url",
"name",
"description",
"domain",
"is_local",
"creation_date",
"privacy_level",
"uploads_count",
"followers_count",
"followers_url",
"actor",
]
read_only_fields = [
......@@ -539,11 +598,8 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
"creation_date",
]
def get_uploads_count(self, obj):
return getattr(obj, "_uploads_count", obj.uploads_count)
def get_followers_count(self, obj):
return getattr(obj, "followers_count", None)
def get_uploads_count(self, obj) -> int:
return getattr(obj, "_uploads_count", int(obj.uploads_count))
class ManageNestedLibrarySerializer(serializers.ModelSerializer):
......@@ -558,12 +614,10 @@ class ManageNestedLibrarySerializer(serializers.ModelSerializer):
"fid",
"url",
"name",
"description",
"domain",
"is_local",
"creation_date",
"privacy_level",
"followers_url",
"actor",
]
......@@ -572,6 +626,7 @@ class ManageUploadSerializer(serializers.ModelSerializer):
track = ManageNestedTrackSerializer()
library = ManageNestedLibrarySerializer()
domain = serializers.CharField(source="domain_name")
import_metadata = music_serializers.ImportMetadataField()
class Meta:
model = music_models.Upload
......@@ -605,7 +660,6 @@ class ManageUploadSerializer(serializers.ModelSerializer):
class ManageTagSerializer(ManageBaseAlbumSerializer):
tracks_count = serializers.SerializerMethodField()
albums_count = serializers.SerializerMethodField()
artists_count = serializers.SerializerMethodField()
......@@ -621,12 +675,15 @@ class ManageTagSerializer(ManageBaseAlbumSerializer):
"artists_count",
]
@extend_schema_field(OpenApiTypes.INT)
def get_tracks_count(self, obj):
return getattr(obj, "_tracks_count", None)
@extend_schema_field(OpenApiTypes.INT)
def get_albums_count(self, obj):
return getattr(obj, "_albums_count", None)
@extend_schema_field(OpenApiTypes.INT)
def get_artists_count(self, obj):
return getattr(obj, "_artists_count", None)
......@@ -658,11 +715,13 @@ class ManageNoteSerializer(ManageBaseNoteSerializer):
class ManageReportSerializer(serializers.ModelSerializer):
assigned_to = ManageBaseActorSerializer()
target_owner = ManageBaseActorSerializer()
submitter = ManageBaseActorSerializer()
assigned_to = ManageBaseActorSerializer(allow_null=True, required=False)
target_owner = ManageBaseActorSerializer(required=False)
submitter = ManageBaseActorSerializer(required=False)
target = moderation_serializers.TARGET_FIELD
notes = serializers.SerializerMethodField()
notes = ManageBaseNoteSerializer(
allow_null=True, source="_prefetched_notes", many=True, default=[]
)
class Meta:
model = moderation_models.Report
......@@ -697,6 +756,56 @@ class ManageReportSerializer(serializers.ModelSerializer):
"summary",
]
class ManageUserRequestSerializer(serializers.ModelSerializer):
assigned_to = ManageBaseActorSerializer()
submitter = ManageBaseActorSerializer()
notes = serializers.SerializerMethodField()
class Meta:
model = moderation_models.UserRequest
fields = [
"id",
"uuid",
"creation_date",
"handled_date",
"type",
"status",
"assigned_to",
"submitter",
"notes",
"metadata",
]
read_only_fields = [
"id",
"uuid",
"submitter",
"creation_date",
"handled_date",
"metadata",
]
@extend_schema_field(ManageBaseNoteSerializer)
def get_notes(self, o):
notes = getattr(o, "_prefetched_notes", [])
return ManageBaseNoteSerializer(notes, many=True).data
class ManageChannelSerializer(serializers.ModelSerializer):
attributed_to = ManageBaseActorSerializer()
actor = ManageBaseActorSerializer()
artist = ManageArtistSerializer()
class Meta:
model = audio_models.Channel
fields = [
"id",
"uuid",
"creation_date",
"artist",
"attributed_to",
"actor",
"rss_url",
"metadata",
]
read_only_fields = fields
from django.conf.urls import include, url
from django.conf.urls import include
from django.urls import re_path
from funkwhale_api.common import routers
from . import views
......@@ -18,6 +20,7 @@ moderation_router.register(
r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies"
)
moderation_router.register(r"reports", views.ManageReportViewSet, "reports")
moderation_router.register(r"requests", views.ManageUserRequestViewSet, "requests")
moderation_router.register(r"notes", views.ManageNoteViewSet, "notes")
users_router = routers.OptionalSlashRouter()
......@@ -26,17 +29,20 @@ users_router.register(r"invitations", views.ManageInvitationViewSet, "invitation
other_router = routers.OptionalSlashRouter()
other_router.register(r"accounts", views.ManageActorViewSet, "accounts")
other_router.register(r"channels", views.ManageChannelViewSet, "channels")
other_router.register(r"tags", views.ManageTagViewSet, "tags")
urlpatterns = [
url(
re_path(
r"^federation/",
include((federation_router.urls, "federation"), namespace="federation"),
),
url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
url(
re_path(
r"^library/", include((library_router.urls, "instance"), namespace="library")
),
re_path(
r"^moderation/",
include((moderation_router.urls, "moderation"), namespace="moderation"),
),
url(r"^users/", include((users_router.urls, "instance"), namespace="users")),
re_path(r"^users/", include((users_router.urls, "instance"), namespace="users")),
] + other_router.urls
from rest_framework import mixins, response, viewsets
from rest_framework import decorators as rest_decorators
from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery
from django.db.models.functions import Coalesce, Length
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 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, decorators
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.music import views as music_views
from funkwhale_api.moderation import models as moderation_models
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 . import filters, serializers
def get_stats(tracks, target):
data = {}
def get_stats(tracks, target, ignore_fields=[]):
tracks = list(tracks.values_list("pk", flat=True))
uploads = music_models.Upload.objects.filter(track__in=tracks)
data["listenings"] = history_models.Listening.objects.filter(
track__in=tracks
).count()
data["mutations"] = common_models.Mutation.objects.get_for_target(target).count()
data["playlists"] = (
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()
.count()
)
data["track_favorites"] = favorites_models.TrackFavorite.objects.filter(
track__in=tracks
).count()
data["libraries"] = uploads.values_list("library", flat=True).distinct().count()
data["uploads"] = uploads.count()
data["reports"] = moderation_models.Report.objects.get_for_target(target).count()
),
"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
......@@ -64,28 +83,22 @@ class ManageArtistViewSet(
queryset = (
music_models.Artist.objects.all()
.order_by("-id")
.select_related("attributed_to")
.prefetch_related(
"tracks",
Prefetch(
"albums",
queryset=music_models.Album.objects.select_related(
"attachment_cover"
).annotate(tracks_count=Count("tracks")),
),
music_views.TAG_PREFETCH,
)
.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=artist) | Q(album__artist=artist)
Q(artist_credit__artist=artist) | Q(album__artist_credit__artist=artist)
)
data = get_stats(tracks, artist)
return response.Response(data, status=200)
......@@ -100,6 +113,11 @@ class ManageArtistViewSet(
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,
......@@ -110,14 +128,15 @@ class ManageAlbumViewSet(
queryset = (
music_models.Album.objects.all()
.order_by("-id")
.select_related("attributed_to", "artist", "attachment_cover")
.prefetch_related("tracks", music_views.TAG_PREFETCH)
.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()
......@@ -134,6 +153,11 @@ class ManageAlbumViewSet(
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"))
......@@ -153,8 +177,12 @@ class ManageTrackViewSet(
queryset = (
music_models.Track.objects.all()
.order_by("-id")
.select_related(
"attributed_to", "artist", "album__artist", "album__attachment_cover"
.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)
......@@ -170,6 +198,7 @@ class ManageTrackViewSet(
"disc_number",
]
@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()
......@@ -186,6 +215,11 @@ class ManageTrackViewSet(
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"))
......@@ -214,6 +248,7 @@ class ManageLibraryViewSet(
lookup_field = "uuid"
queryset = (
music_models.Library.objects.all()
.filter(channel=None)
.order_by("-id")
.select_related("actor")
.annotate(
......@@ -225,6 +260,7 @@ class ManageLibraryViewSet(
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()
......@@ -237,11 +273,11 @@ class ManageLibraryViewSet(
)
artists = set(
music_models.Album.objects.filter(pk__in=albums).values_list(
"artist", flat=True
"artist_credit__artist", flat=True
)
) | set(
music_models.Track.objects.filter(pk__in=tracks).values_list(
"artist", flat=True
"artist_credit__artist", flat=True
)
)
......@@ -277,7 +313,11 @@ class ManageUploadViewSet(
queryset = (
music_models.Upload.objects.all()
.order_by("-id")
.select_related("library__actor", "track__artist", "track__album__artist")
.prefetch_related(
"library__actor",
"track__artist_credit__artist",
"track__album__artist_credit__artist",
)
)
serializer_class = serializers.ManageUploadSerializer
filterset_class = filters.ManageUploadFilterSet
......@@ -353,8 +393,7 @@ class ManageDomainViewSet(
):
lookup_value_regex = r"[a-zA-Z0-9\-\.]+"
queryset = (
federation_models.Domain.objects.external()
.with_actors_count()
federation_models.Domain.objects.with_actors_count()
.with_outbox_activities_count()
.prefetch_related("instance_policy")
.order_by("name")
......@@ -371,6 +410,10 @@ class ManageDomainViewSet(
"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
......@@ -389,6 +432,7 @@ class ManageDomainViewSet(
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()
......@@ -433,10 +477,11 @@ class ManageActorViewSet(
return obj
@extend_schema(operation_id="admin_get_account_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)
obj = self.get_object()
return response.Response(obj.get_stats(), status=200)
action = decorators.action_route(serializers.ManageActorActionSerializer)
......@@ -494,7 +539,7 @@ class ManageReportViewSet(
def perform_update(self, serializer):
is_handled = serializer.instance.is_handled
if not is_handled and serializer.validated_data.get("is_handled") is True:
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:
......@@ -538,6 +583,7 @@ class ManageTagViewSet(
.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.ManageTagSerializer
filterset_class = filters.ManageTagFilterSet
......@@ -573,3 +619,119 @@ class ManageTagViewSet(
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
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
......@@ -13,7 +17,7 @@ class AllowListEnabled(types.BooleanPreference):
section = moderation
name = "allow_list_enabled"
verbose_name = "Enable allow-listing"
help_text = "If enabled, only interactions with explicitely allowed domains will be authorized."
help_text = "If enabled, only interactions with explicitly allowed domains will be authorized."
default = False
......@@ -40,3 +44,67 @@ class UnauthenticatedReportTypes(common_preferences.StringListPreference):
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 registry, NoUpdateOnCreate
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
......@@ -9,7 +9,7 @@ from . import serializers
@registry.register
class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
class InstancePolicyFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
summary = factory.Faker("paragraph")
actor = factory.SubFactory(federation_factories.ActorFactory)
block_all = True
......@@ -28,7 +28,7 @@ class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
@registry.register
class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
class UserFilterFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(users_factories.UserFactory)
target_artist = None
......@@ -42,7 +42,7 @@ class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
@registry.register
class NoteFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
class NoteFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
author = factory.SubFactory(federation_factories.ActorFactory)
target = None
summary = factory.Faker("paragraph")
......@@ -52,7 +52,7 @@ class NoteFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
@registry.register
class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
class ReportFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
submitter = factory.SubFactory(federation_factories.ActorFactory)
target = factory.SubFactory(music_factories.ArtistFactory)
summary = factory.Faker("paragraph")
......@@ -63,6 +63,7 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
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)
)
......@@ -73,3 +74,20 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
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__pk"]},
"TRACK": {"target_artist": ["artist__pk", "album__artist__pk"]},
"LISTENING": {"target_artist": ["track__album__artist__pk", "track__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__pk", "track__artist__pk"]
"target_artist": [
"track__album__artist_credit__artist__pk",
"track__artist_credit__artist__pk",
]
},
}
......@@ -21,7 +32,7 @@ def get_filtered_content_query(config, user):
query = None
ids = user.content_filters.values_list(filter_field, flat=True)
for model_field in model_fields:
q = Q(**{"{}__in".format(model_field): ids})
q = Q(**{f"{model_field}__in": ids})
if query:
query |= q
else:
......@@ -64,7 +75,7 @@ class HiddenContentFilterSet(filters.FilterSet):
config = self.__class__.Meta.hidden_content_fields_mapping
final_query = get_filtered_content_query(config, user)
if value is True:
if value:
return queryset.filter(final_query)
else:
return queryset.exclude(final_query)