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
  • 1.4.1-upgrade-release
  • 1121-download
  • 1218-smartplaylist_backend
  • 1373-login-form-move-reset-your-password-link
  • 1381-progress-bars
  • 1481
  • 1518-update-django-allauth
  • 1645
  • 1675-widget-improperly-configured-missing-resource-id
  • 1675-widget-improperly-configured-missing-resource-id-2
  • 1704-required-props-are-not-always-passed
  • 1716-add-frontend-tests-again
  • 1749-smtp-uri-configuration
  • 1930-first-upload-in-a-batch-always-fails
  • 1976-update-documentation-links-in-readme-files
  • 2054-player-layout
  • 2063-funkwhale-connection-interrupted-every-so-often-requires-network-reset-page-refresh
  • 2091-iii-6-improve-visuals-layout
  • 2151-refused-to-load-spa-manifest-json-2
  • 2154-add-to-playlist-pop-up-hidden-by-now-playing-screen
  • 2155-can-t-see-the-episode-list-of-a-podcast-as-an-anonymous-user-with-anonymous-access-enabled
  • 2156-add-management-command-to-change-file-ref-for-in-place-imported-files-to-s3
  • 2192-clear-queue-bug-when-random-shuffle-is-enabled
  • 2205-channel-page-pagination-link-dont-working
  • 2215-custom-logger-does-not-work-at-all-with-webkit-and-blink-based-browsers
  • 2228-troi-real-world-review
  • 2274-implement-new-upload-api
  • 2303-allow-users-to-own-tagged-items
  • 2395-far-right-filter
  • 2405-front-buttont-trigger-third-party-hook
  • 2408-troi-create-missing-tracks
  • 2416-revert-library-drop
  • 2429-fix-popover-auto-close
  • 2448-complete-tags
  • 2452-fetch-third-party-metadata
  • 2467-fix-radio-builder
  • 2469-Fix-search-bar-in-ManageUploads
  • 2476-deep-upload-links
  • 2480-add-notification-number-badges
  • 2482-upgrade-about-page-to-use-new-ui
  • 2487-fix-accessibility-according-to-WCAG
  • 2490-experiment-use-rstore
  • 2490-experimental-use-simple-data-store
  • 2490-fix-search-modal
  • 2490-search-modal
  • 2501-fix-compatibility-with-older-browsers
  • 2502-drop-uno-and-jquery
  • 2533-allow-followers-in-user-activiy-privacy-level
  • 2539-drop-ansible-installation-method-in-favor-of-docker
  • 2550-22-user-interfaces-for-federation
  • 2560-default-modal-width
  • 623-test
  • 653-enable-starting-embedded-player-at-a-specific-position-in-track
  • activitypub-overview
  • album-sliders
  • arne/2091-improve-visuals
  • back-option-for-edits
  • chore/2406-compose-modularity-scope
  • develop
  • develop-password-reset
  • env-file-cleanup
  • feat/2091-improve-visuals
  • feature/2481-vui-translations
  • fix-amd64-docker-build-gfortran
  • fix-channel-creation
  • fix-front-node-version
  • fix-gitpod
  • fix-plugins-dev-setup
  • fix-rate-limit-serializer
  • fix-schema-channel-metadata-choices
  • flupsi/2803-improve-visuals
  • flupsi/2804-new-upload-process
  • funkwhale-fix_pwa_manifest
  • funkwhale-petitminion-2136-bug-fix-prune-skipped-upload
  • funkwhale-ui-buttons
  • georg/add-typescript
  • gitpod/test-1866
  • global-button-experiment
  • global-buttons
  • juniorjpdj/pkg-repo
  • manage-py-reference
  • merge-review
  • minimal-python-version
  • petitminion-develop-patch-84496
  • pin-mutagen-to-1.46
  • pipenv
  • plugins
  • plugins-v2
  • plugins-v3
  • pre-release/1.3.0
  • prune_skipped_uploads_docs
  • refactor/homepage
  • renovate/front-all-dependencies
  • renovate/front-major-all-dependencies
  • schema-updates
  • small-gitpod-improvements
  • spectacular_schema
  • stable
  • tempArne
  • ui-buttons
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
  • 1.1.2
  • 1.1.3
  • 1.1.4
  • 1.2.0
  • 1.2.0-rc1
  • 1.2.0-rc2
  • 1.2.0-testing
  • 1.2.0-testing2
  • 1.2.0-testing3
  • 1.2.0-testing4
  • 1.2.1
  • 1.2.10
  • 1.2.2
  • 1.2.3
  • 1.2.4
  • 1.2.5
  • 1.2.6
  • 1.2.6-1
  • 1.2.7
  • 1.2.8
  • 1.2.9
  • 1.3.0
  • 1.3.0-rc1
  • 1.3.0-rc2
  • 1.3.0-rc3
  • 1.3.0-rc4
  • 1.3.0-rc5
  • 1.3.0-rc6
  • 1.3.1
  • 1.3.2
  • 1.3.3
  • 1.3.4
  • 1.4.0
  • 1.4.0-rc1
  • 1.4.0-rc2
  • 1.4.1
  • 2.0.0-alpha.1
  • 2.0.0-alpha.2
200 results

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
  • 1.0.1
  • 1108-remove-jwt-and-switch-to-oauth-for-ui-auth
  • 1121-download
  • 1278-embed-isn-t-available-in-the-front-end-for-channel-tracks
  • 1299-encoding-problem-in-rss-feeds
  • 1303-failing-to-refetch-federated-tracks
  • 1311-feedparser-requires-update-to-accomodate-python-3-9
  • 1346-selectoreventloop-required-instead-got-uvloop-loop
  • 1356-update-packages
  • develop
  • master
  • plugins
  • plugins-v2
  • plugins-v3
  • set-sast-config-1
  • set-sast-config-2
  • tracemallocmiddleware
  • 0.1
  • 0.10
  • 0.11
  • 0.12
  • 0.13
  • 0.14
  • 0.14.1
  • 0.14.2
  • 0.15
  • 0.16
  • 0.16.1
  • 0.16.2
  • 0.16.3
  • 0.17
  • 0.18
  • 0.18.1
  • 0.18.2
  • 0.18.3
  • 0.19.0
  • 0.19.0-rc1
  • 0.19.0-rc2
  • 0.19.1
  • 0.2
  • 0.2.1
  • 0.2.2
  • 0.2.3
  • 0.2.4
  • 0.2.5
  • 0.2.6
  • 0.20.0
  • 0.20.0-rc1
  • 0.20.1
  • 0.21
  • 0.21-rc1
  • 0.21-rc2
  • 0.21.1
  • 0.21.2
  • 0.3
  • 0.3.1
  • 0.3.2
  • 0.3.3
  • 0.3.4
  • 0.3.5
  • 0.4
  • 0.5
  • 0.5.1
  • 0.5.2
  • 0.5.3
  • 0.5.4
  • 0.6
  • 0.6.1
  • 0.7
  • 0.8
  • 0.9
  • 0.9.1
  • 1.0
  • 1.0-rc1
  • 1.0.1
  • 1.1
  • 1.1-rc1
  • 1.1-rc2
  • 1.1.1
79 results
Show changes
Showing
with 874 additions and 270 deletions
from rest_framework import mixins, viewsets
from django.db.models import Prefetch from django.db.models import Prefetch
from rest_framework import mixins, viewsets
from config import plugins from config import plugins
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions 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 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 funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, serializers
class ListeningViewSet( class ListeningViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
...@@ -19,11 +18,8 @@ class ListeningViewSet( ...@@ -19,11 +18,8 @@ class ListeningViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
serializer_class = serializers.ListeningSerializer serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related( queryset = models.Listening.objects.all().select_related("actor__attachment_icon")
"user__actor__attachment_icon"
)
permission_classes = [ permission_classes = [
oauth_permissions.ScopePermission, oauth_permissions.ScopePermission,
...@@ -32,6 +28,7 @@ class ListeningViewSet( ...@@ -32,6 +28,7 @@ class ListeningViewSet(
required_scope = "listenings" required_scope = "listenings"
anonymous_policy = "setting" anonymous_policy = "setting"
owner_checks = ["write"] owner_checks = ["write"]
owner_field = "actor.user"
filterset_class = filters.ListeningFilter filterset_class = filters.ListeningFilter
def get_serializer_class(self): def get_serializer_class(self):
...@@ -41,22 +38,41 @@ class ListeningViewSet( ...@@ -41,22 +38,41 @@ class ListeningViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
r = super().perform_create(serializer) r = super().perform_create(serializer)
instance = serializer.instance
plugins.trigger_hook( plugins.trigger_hook(
plugins.LISTENING_CREATED, plugins.LISTENING_CREATED,
listening=serializer.instance, listening=instance,
confs=plugins.get_confs(self.request.user), 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) record.send(serializer.instance)
return r return r
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
queryset = queryset.filter( 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) 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)) return queryset.prefetch_related(Prefetch("track", queryset=tracks))
def get_serializer_context(self): def get_serializer_context(self):
......
from django.forms import widgets import pycountry
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
from django.forms import widgets
from dynamic_preferences import types from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
...@@ -73,7 +73,7 @@ class InstanceContactEmail(types.StringPreference): ...@@ -73,7 +73,7 @@ class InstanceContactEmail(types.StringPreference):
name = "contact_email" name = "contact_email"
verbose_name = "Contact email" verbose_name = "Contact email"
default = "" 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} field_kwargs = {"required": False}
...@@ -171,3 +171,18 @@ class Banner(ImagePreference): ...@@ -171,3 +171,18 @@ class Banner(ImagePreference):
default = None default = None
help_text = "This banner will be displayed on your pod's landing and about page. At least 600x100px recommended." help_text = "This banner will be displayed on your pod's landing and about page. At least 600x100px recommended."
field_kwargs = {"required": False} 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
from django.urls import reverse
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")
auth_required = all_preferences.get("common__api_authentication_required")
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"),
"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"),
"endpoints": {"knownNodes": None, "channels": None, "libraries": None},
},
}
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"]},
"downloads": {"total": statistics["downloads"]},
}
if not auth_required:
data["metadata"]["endpoints"]["knownNodes"] = federation_utils.full_url(
reverse("api:v1:federation:domains-list")
)
if not auth_required and preferences.get("federation__public_index"):
data["metadata"]["endpoints"]["libraries"] = federation_utils.full_url(
reverse("federation:index:index-libraries")
)
data["metadata"]["endpoints"]["channels"] = federation_utils.full_url(
reverse("federation:index:index-channels")
)
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 import datetime
from django.db.models import Sum from django.db.models import Count, F, Sum
from django.utils import timezone from django.utils import timezone
from funkwhale_api.favorites.models import TrackFavorite from funkwhale_api.favorites.models import TrackFavorite
...@@ -22,6 +22,39 @@ def get(): ...@@ -22,6 +22,39 @@ def get():
} }
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(): def get_users():
qs = User.objects.filter(is_active=True) qs = User.objects.filter(is_active=True)
now = timezone.now() now = timezone.now()
......
from django.conf.urls import url from django.urls import re_path
from funkwhale_api.common import routers from funkwhale_api.common import routers
from . import views from . import views
...@@ -7,7 +8,7 @@ admin_router = routers.OptionalSlashRouter() ...@@ -7,7 +8,7 @@ admin_router = routers.OptionalSlashRouter()
admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings") admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
urlpatterns = [ urlpatterns = [
url(r"^nodeinfo/2.0/?$", views.NodeInfo.as_view(), name="nodeinfo-2.0"), re_path(r"^nodeinfo/2.0/?$", views.NodeInfo20.as_view(), name="nodeinfo-2.0"),
url(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"), re_path(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
url(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"), re_path(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"),
] + admin_router.urls ] + 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
import json import json
import logging import logging
from pathlib import Path
from django.conf import settings from cache_memoize import cache_memoize
from django.urls import reverse
from dynamic_preferences.api import serializers 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 import viewsets as preferences_viewsets
from dynamic_preferences.api.serializers import GlobalPreferenceSerializer
from dynamic_preferences.registries import global_preferences_registry 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 rest_framework.response import Response
from funkwhale_api.common import middleware from funkwhale_api import __version__ as funkwhale_version
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.federation import utils as federation_utils 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 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 NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" # noqa
...@@ -28,50 +37,170 @@ class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet): ...@@ -28,50 +37,170 @@ class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
required_scope = "instance:settings" required_scope = "instance:settings"
class InstanceSettings(views.APIView): class InstanceSettings(generics.GenericAPIView):
permission_classes = [] permission_classes = []
authentication_classes = [] authentication_classes = []
serializer_class = GlobalPreferenceSerializer
def get(self, request, *args, **kwargs): def get_queryset(self):
manager = global_preferences_registry.manager() manager = global_preferences_registry.manager()
manager.all() manager.all()
all_preferences = manager.model.objects.all().order_by("section", "name") all_preferences = manager.model.objects.all().order_by("section", "name")
api_preferences = [ api_preferences = [
p for p in all_preferences if getattr(p.preference, "show_in_api", False) 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) return Response(data, status=200)
class NodeInfo(views.APIView): @method_decorator(ensure_csrf_cookie, name="dispatch")
class NodeInfo20(views.APIView):
permission_classes = [] permission_classes = []
authentication_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
)
def get(self, request, *args, **kwargs):
try:
data = nodeinfo.get()
except ValueError:
logger.warn("nodeinfo returned invalid json")
data = {}
return Response(data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)
class NodeInfo21(NodeInfo20):
serializer_class = serializers.NodeInfo21Serializer
class SpaManifest(views.APIView): @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 = [] permission_classes = []
authentication_classes = [] authentication_classes = []
serializer_class = serializers.SpaManifestSerializer
renderer_classes = [ActivityStreamRenderer]
def get(self, request, *args, **kwargs): @extend_schema(operation_id="get_spa_manifest")
existing_manifest = middleware.get_spa_file( def get(self, request):
settings.FUNKWHALE_SPA_HTML_ROOT, "manifest.json" manifest = PWA_MANIFEST.copy()
)
parsed_manifest = json.loads(existing_manifest)
parsed_manifest["short_name"] = settings.APP_NAME
parsed_manifest["start_url"] = federation_utils.full_url("/")
instance_name = preferences.get("instance__name") instance_name = preferences.get("instance__name")
if instance_name: if instance_name:
parsed_manifest["short_name"] = instance_name manifest["short_name"] = instance_name
parsed_manifest["name"] = instance_name manifest["name"] = instance_name
instance_description = preferences.get("instance__short_description") instance_description = preferences.get("instance__short_description")
if instance_description: if instance_description:
parsed_manifest["description"] = instance_description manifest["description"] = instance_description
return Response(parsed_manifest, status=200) 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 import forms
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Collate
import django_filters
from django_filters import rest_framework as filters 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 fields
from funkwhale_api.common import filters as common_filters from funkwhale_api.common import filters as common_filters
from funkwhale_api.common import search from funkwhale_api.common import search
from funkwhale_api.audio import models as audio_models
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.moderation import models as moderation_models from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import utils as moderation_utils from funkwhale_api.moderation import utils as moderation_utils
from funkwhale_api.music import models as music_models 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.tags import models as tags_models
from funkwhale_api.users import models as users_models
class ActorField(forms.CharField): class ActorField(forms.CharField):
...@@ -98,12 +97,15 @@ class ManageAlbumFilterSet(filters.FilterSet): ...@@ -98,12 +97,15 @@ class ManageAlbumFilterSet(filters.FilterSet):
search_fields={ search_fields={
"title": {"to": "title"}, "title": {"to": "title"},
"fid": {"to": "fid"}, "fid": {"to": "fid"},
"artist": {"to": "artist__name"}, "artist": {"to": "artist_credit__artist__name"},
"mbid": {"to": "mbid"}, "mbid": {"to": "mbid"},
}, },
filter_fields={ filter_fields={
"uuid": {"to": "uuid"}, "uuid": {"to": "uuid"},
"artist_id": {"to": "artist_id", "field": forms.IntegerField()}, "artist_id": {
"to": "artist_credit__artist_id",
"field": forms.IntegerField(),
},
"domain": { "domain": {
"handler": lambda v: federation_utils.get_domain_query_from_url(v) "handler": lambda v: federation_utils.get_domain_query_from_url(v)
}, },
...@@ -119,7 +121,7 @@ class ManageAlbumFilterSet(filters.FilterSet): ...@@ -119,7 +121,7 @@ class ManageAlbumFilterSet(filters.FilterSet):
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ["title", "mbid", "fid", "artist"] fields = ["title", "mbid", "fid", "artist_credit"]
class ManageTrackFilterSet(filters.FilterSet): class ManageTrackFilterSet(filters.FilterSet):
...@@ -129,9 +131,9 @@ class ManageTrackFilterSet(filters.FilterSet): ...@@ -129,9 +131,9 @@ class ManageTrackFilterSet(filters.FilterSet):
"title": {"to": "title"}, "title": {"to": "title"},
"fid": {"to": "fid"}, "fid": {"to": "fid"},
"mbid": {"to": "mbid"}, "mbid": {"to": "mbid"},
"artist": {"to": "artist__name"}, "artist": {"to": "artist_credit__artist__name"},
"album": {"to": "album__title"}, "album": {"to": "album__title"},
"album_artist": {"to": "album__artist__name"}, "album_artist": {"to": "album__artist_credit__artist__name"},
"copyright": {"to": "copyright"}, "copyright": {"to": "copyright"},
}, },
filter_fields={ filter_fields={
...@@ -158,7 +160,7 @@ class ManageTrackFilterSet(filters.FilterSet): ...@@ -158,7 +160,7 @@ class ManageTrackFilterSet(filters.FilterSet):
class Meta: class Meta:
model = music_models.Track model = music_models.Track
fields = ["title", "mbid", "fid", "artist", "album", "license"] fields = ["title", "mbid", "fid", "artist_credit", "album", "license"]
class ManageLibraryFilterSet(filters.FilterSet): class ManageLibraryFilterSet(filters.FilterSet):
...@@ -372,6 +374,13 @@ class ManageTagFilterSet(filters.FilterSet): ...@@ -372,6 +374,13 @@ class ManageTagFilterSet(filters.FilterSet):
model = tags_models.Tag model = tags_models.Tag
fields = [] fields = []
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.annotate(tag_deterministic=Collate("name", "und-x-icu"))
)
class ManageReportFilterSet(filters.FilterSet): class ManageReportFilterSet(filters.FilterSet):
q = fields.SmartSearchFilter( q = fields.SmartSearchFilter(
......
from django.conf import settings from django.conf import settings
from django.db import transaction 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 rest_framework import serializers
from funkwhale_api.audio import models as audio_models from funkwhale_api.audio import models as audio_models
from funkwhale_api.common import fields as common_fields from funkwhale_api.common import fields as common_fields
from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils 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 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.federation import tasks as federation_tasks
from funkwhale_api.moderation import models as moderation_models from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers from funkwhale_api.moderation import serializers as moderation_serializers
...@@ -49,7 +50,7 @@ class ManageUserSimpleSerializer(serializers.ModelSerializer): ...@@ -49,7 +50,7 @@ class ManageUserSimpleSerializer(serializers.ModelSerializer):
class ManageUserSerializer(serializers.ModelSerializer): class ManageUserSerializer(serializers.ModelSerializer):
permissions = PermissionsSerializer(source="*") permissions = PermissionsSerializer(source="*")
upload_quota = serializers.IntegerField(allow_null=True) upload_quota = serializers.IntegerField(allow_null=True, required=False)
actor = serializers.SerializerMethodField() actor = serializers.SerializerMethodField()
class Meta: class Meta:
...@@ -66,8 +67,8 @@ class ManageUserSerializer(serializers.ModelSerializer): ...@@ -66,8 +67,8 @@ class ManageUserSerializer(serializers.ModelSerializer):
"date_joined", "date_joined",
"last_activity", "last_activity",
"permissions", "permissions",
"privacy_level",
"upload_quota", "upload_quota",
"privacy_level",
"full_username", "full_username",
) )
read_only_fields = [ read_only_fields = [
...@@ -84,12 +85,11 @@ class ManageUserSerializer(serializers.ModelSerializer): ...@@ -84,12 +85,11 @@ class ManageUserSerializer(serializers.ModelSerializer):
permissions = validated_data.pop("permissions", {}) permissions = validated_data.pop("permissions", {})
if permissions: if permissions:
for p, value in permissions.items(): for p, value in permissions.items():
setattr(instance, "permission_{}".format(p), value) setattr(instance, f"permission_{p}", value)
instance.save( instance.save(update_fields=[f"permission_{p}" for p in permissions.keys()])
update_fields=["permission_{}".format(p) for p in permissions.keys()]
)
return instance return instance
@extend_schema_field(OpenApiTypes.OBJECT)
def get_actor(self, obj): def get_actor(self, obj):
if obj.actor: if obj.actor:
return ManageBaseActorSerializer(obj.actor).data return ManageBaseActorSerializer(obj.actor).data
...@@ -98,12 +98,28 @@ class ManageUserSerializer(serializers.ModelSerializer): ...@@ -98,12 +98,28 @@ class ManageUserSerializer(serializers.ModelSerializer):
class ManageInvitationSerializer(serializers.ModelSerializer): class ManageInvitationSerializer(serializers.ModelSerializer):
users = ManageUserSimpleSerializer(many=True, required=False) users = ManageUserSimpleSerializer(many=True, required=False)
owner = ManageUserSimpleSerializer(required=False) owner = ManageUserSimpleSerializer(required=False)
invited_user = ManageUserSimpleSerializer(required=False)
code = serializers.CharField(required=False, allow_null=True) code = serializers.CharField(required=False, allow_null=True)
class Meta: class Meta:
model = users_models.Invitation model = users_models.Invitation
fields = ("id", "owner", "code", "expiration_date", "creation_date", "users") fields = (
read_only_fields = ["id", "expiration_date", "owner", "creation_date", "users"] "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): def validate_code(self, value):
if not value: if not value:
...@@ -151,10 +167,10 @@ class ManageDomainSerializer(serializers.ModelSerializer): ...@@ -151,10 +167,10 @@ class ManageDomainSerializer(serializers.ModelSerializer):
"nodeinfo_fetch_date", "nodeinfo_fetch_date",
] ]
def get_actors_count(self, o): def get_actors_count(self, o) -> int:
return getattr(o, "actors_count", 0) 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) return getattr(o, "outbox_activities_count", 0)
...@@ -211,13 +227,13 @@ class ManageBaseActorSerializer(serializers.ModelSerializer): ...@@ -211,13 +227,13 @@ class ManageBaseActorSerializer(serializers.ModelSerializer):
] ]
read_only_fields = ["creation_date", "instance_policy"] 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 return o.domain_id == settings.FEDERATION_HOSTNAME
class ManageActorSerializer(ManageBaseActorSerializer): class ManageActorSerializer(ManageBaseActorSerializer):
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
user = ManageUserSerializer() user = ManageUserSerializer(allow_null=True)
class Meta: class Meta:
model = federation_models.Actor model = federation_models.Actor
...@@ -228,7 +244,7 @@ class ManageActorSerializer(ManageBaseActorSerializer): ...@@ -228,7 +244,7 @@ class ManageActorSerializer(ManageBaseActorSerializer):
] ]
read_only_fields = ["creation_date", "instance_policy"] 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) return getattr(o, "uploads_count", 0)
...@@ -242,7 +258,7 @@ class ManageActorActionSerializer(common_serializers.ActionSerializer): ...@@ -242,7 +258,7 @@ class ManageActorActionSerializer(common_serializers.ActionSerializer):
common_utils.on_commit(federation_tasks.purge_actors.delay, ids=list(ids)) 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"]) type = serializers.ChoiceField(choices=["domain", "actor"])
id = serializers.CharField() id = serializers.CharField()
...@@ -264,7 +280,7 @@ class TargetSerializer(serializers.Serializer): ...@@ -264,7 +280,7 @@ class TargetSerializer(serializers.Serializer):
class ManageInstancePolicySerializer(serializers.ModelSerializer): class ManageInstancePolicySerializer(serializers.ModelSerializer):
target = TargetSerializer() target = ManageTargetSerializer()
actor = federation_fields.ActorRelatedField(read_only=True) actor = federation_fields.ActorRelatedField(read_only=True)
class Meta: class Meta:
...@@ -353,6 +369,7 @@ class ManageBaseAlbumSerializer(serializers.ModelSerializer): ...@@ -353,6 +369,7 @@ class ManageBaseAlbumSerializer(serializers.ModelSerializer):
"tracks_count", "tracks_count",
] ]
@extend_schema_field(OpenApiTypes.INT)
def get_tracks_count(self, o): def get_tracks_count(self, o):
return getattr(o, "_tracks_count", None) return getattr(o, "_tracks_count", None)
...@@ -378,13 +395,13 @@ class ManageNestedTrackSerializer(serializers.ModelSerializer): ...@@ -378,13 +395,13 @@ class ManageNestedTrackSerializer(serializers.ModelSerializer):
class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer): class ManageNestedAlbumSerializer(ManageBaseAlbumSerializer):
tracks_count = serializers.SerializerMethodField() tracks_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + ["tracks_count"] fields = ManageBaseAlbumSerializer.Meta.fields + ["tracks_count"]
@extend_schema_field(OpenApiTypes.INT)
def get_tracks_count(self, obj): def get_tracks_count(self, obj):
return getattr(obj, "tracks_count", None) return getattr(obj, "tracks_count", None)
...@@ -397,7 +414,7 @@ class ManageArtistSerializer( ...@@ -397,7 +414,7 @@ class ManageArtistSerializer(
tracks_count = serializers.SerializerMethodField() tracks_count = serializers.SerializerMethodField()
albums_count = serializers.SerializerMethodField() albums_count = serializers.SerializerMethodField()
channel = serializers.SerializerMethodField() channel = serializers.SerializerMethodField()
cover = music_serializers.cover_field cover = music_serializers.CoverField(allow_null=True)
class Meta: class Meta:
model = music_models.Artist model = music_models.Artist
...@@ -411,16 +428,20 @@ class ManageArtistSerializer( ...@@ -411,16 +428,20 @@ class ManageArtistSerializer(
"content_category", "content_category",
] ]
@extend_schema_field(OpenApiTypes.INT)
def get_tracks_count(self, obj): def get_tracks_count(self, obj):
return getattr(obj, "_tracks_count", None) return getattr(obj, "_tracks_count", None)
@extend_schema_field(OpenApiTypes.INT)
def get_albums_count(self, obj): def get_albums_count(self, obj):
return getattr(obj, "_albums_count", None) return getattr(obj, "_albums_count", None)
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_tags(self, obj): def get_tags(self, obj):
tagged_items = getattr(obj, "_prefetched_tagged_items", []) tagged_items = getattr(obj, "_prefetched_tagged_items", [])
return [ti.tag.name for ti in tagged_items] return [ti.tag.name for ti in tagged_items]
@extend_schema_field(OpenApiTypes.STR)
def get_channel(self, obj): def get_channel(self, obj):
if "channel" in obj._state.fields_cache and obj.get_channel(): if "channel" in obj._state.fields_cache and obj.get_channel():
return str(obj.channel.uuid) return str(obj.channel.uuid)
...@@ -430,44 +451,53 @@ class ManageNestedArtistSerializer(ManageBaseArtistSerializer): ...@@ -430,44 +451,53 @@ class ManageNestedArtistSerializer(ManageBaseArtistSerializer):
pass pass
class ManageNestedArtistCreditSerializer(ManageBaseArtistSerializer):
artist = ManageNestedArtistSerializer()
class Meta:
model = music_models.ArtistCredit
fields = ["artist"]
class ManageAlbumSerializer( class ManageAlbumSerializer(
music_serializers.OptionalDescriptionMixin, ManageBaseAlbumSerializer music_serializers.OptionalDescriptionMixin, ManageBaseAlbumSerializer
): ):
attributed_to = ManageBaseActorSerializer() attributed_to = ManageBaseActorSerializer()
artist = ManageNestedArtistSerializer() artist_credit = ManageNestedArtistCreditSerializer(many=True)
tags = serializers.SerializerMethodField() tags = serializers.SerializerMethodField()
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + [ fields = ManageBaseAlbumSerializer.Meta.fields + [
"artist", "artist_credit",
"attributed_to", "attributed_to",
"tags", "tags",
"tracks_count", "tracks_count",
] ]
def get_tracks_count(self, o): def get_tracks_count(self, o) -> int:
return len(o.tracks.all()) return len(o.tracks.all())
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_tags(self, obj): def get_tags(self, obj):
tagged_items = getattr(obj, "_prefetched_tagged_items", []) tagged_items = getattr(obj, "_prefetched_tagged_items", [])
return [ti.tag.name for ti in tagged_items] return [ti.tag.name for ti in tagged_items]
class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer): class ManageTrackAlbumSerializer(ManageBaseAlbumSerializer):
artist = ManageNestedArtistSerializer() artist_credit = ManageNestedArtistCreditSerializer(many=True)
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ManageBaseAlbumSerializer.Meta.fields + ["artist"] fields = ManageBaseAlbumSerializer.Meta.fields + ["artist_credit"]
class ManageTrackSerializer( class ManageTrackSerializer(
music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer music_serializers.OptionalDescriptionMixin, ManageNestedTrackSerializer
): ):
artist = ManageNestedArtistSerializer() artist_credit = ManageNestedArtistCreditSerializer(many=True)
album = ManageTrackAlbumSerializer() album = ManageTrackAlbumSerializer(allow_null=True)
attributed_to = ManageBaseActorSerializer() attributed_to = ManageBaseActorSerializer(allow_null=True)
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
tags = serializers.SerializerMethodField() tags = serializers.SerializerMethodField()
cover = music_serializers.cover_field cover = music_serializers.cover_field
...@@ -475,7 +505,7 @@ class ManageTrackSerializer( ...@@ -475,7 +505,7 @@ class ManageTrackSerializer(
class Meta: class Meta:
model = music_models.Track model = music_models.Track
fields = ManageNestedTrackSerializer.Meta.fields + [ fields = ManageNestedTrackSerializer.Meta.fields + [
"artist", "artist_credit",
"album", "album",
"attributed_to", "attributed_to",
"uploads_count", "uploads_count",
...@@ -483,9 +513,11 @@ class ManageTrackSerializer( ...@@ -483,9 +513,11 @@ class ManageTrackSerializer(
"cover", "cover",
] ]
@extend_schema_field(OpenApiTypes.INT)
def get_uploads_count(self, obj): def get_uploads_count(self, obj):
return getattr(obj, "uploads_count", None) return getattr(obj, "uploads_count", None)
@extend_schema_field({"type": "array", "items": {"type": "string"}})
def get_tags(self, obj): def get_tags(self, obj):
tagged_items = getattr(obj, "_prefetched_tagged_items", []) tagged_items = getattr(obj, "_prefetched_tagged_items", [])
return [ti.tag.name for ti in tagged_items] return [ti.tag.name for ti in tagged_items]
...@@ -540,7 +572,6 @@ class ManageLibrarySerializer(serializers.ModelSerializer): ...@@ -540,7 +572,6 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
domain = serializers.CharField(source="domain_name") domain = serializers.CharField(source="domain_name")
actor = ManageBaseActorSerializer() actor = ManageBaseActorSerializer()
uploads_count = serializers.SerializerMethodField() uploads_count = serializers.SerializerMethodField()
followers_count = serializers.SerializerMethodField()
class Meta: class Meta:
model = music_models.Library model = music_models.Library
...@@ -550,14 +581,11 @@ class ManageLibrarySerializer(serializers.ModelSerializer): ...@@ -550,14 +581,11 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
"fid", "fid",
"url", "url",
"name", "name",
"description",
"domain", "domain",
"is_local", "is_local",
"creation_date", "creation_date",
"privacy_level", "privacy_level",
"uploads_count", "uploads_count",
"followers_count",
"followers_url",
"actor", "actor",
] ]
read_only_fields = [ read_only_fields = [
...@@ -570,11 +598,8 @@ class ManageLibrarySerializer(serializers.ModelSerializer): ...@@ -570,11 +598,8 @@ class ManageLibrarySerializer(serializers.ModelSerializer):
"creation_date", "creation_date",
] ]
def get_uploads_count(self, obj): def get_uploads_count(self, obj) -> int:
return getattr(obj, "_uploads_count", obj.uploads_count) return getattr(obj, "_uploads_count", int(obj.uploads_count))
def get_followers_count(self, obj):
return getattr(obj, "followers_count", None)
class ManageNestedLibrarySerializer(serializers.ModelSerializer): class ManageNestedLibrarySerializer(serializers.ModelSerializer):
...@@ -589,12 +614,10 @@ class ManageNestedLibrarySerializer(serializers.ModelSerializer): ...@@ -589,12 +614,10 @@ class ManageNestedLibrarySerializer(serializers.ModelSerializer):
"fid", "fid",
"url", "url",
"name", "name",
"description",
"domain", "domain",
"is_local", "is_local",
"creation_date", "creation_date",
"privacy_level", "privacy_level",
"followers_url",
"actor", "actor",
] ]
...@@ -603,6 +626,7 @@ class ManageUploadSerializer(serializers.ModelSerializer): ...@@ -603,6 +626,7 @@ class ManageUploadSerializer(serializers.ModelSerializer):
track = ManageNestedTrackSerializer() track = ManageNestedTrackSerializer()
library = ManageNestedLibrarySerializer() library = ManageNestedLibrarySerializer()
domain = serializers.CharField(source="domain_name") domain = serializers.CharField(source="domain_name")
import_metadata = music_serializers.ImportMetadataField()
class Meta: class Meta:
model = music_models.Upload model = music_models.Upload
...@@ -636,7 +660,6 @@ class ManageUploadSerializer(serializers.ModelSerializer): ...@@ -636,7 +660,6 @@ class ManageUploadSerializer(serializers.ModelSerializer):
class ManageTagSerializer(ManageBaseAlbumSerializer): class ManageTagSerializer(ManageBaseAlbumSerializer):
tracks_count = serializers.SerializerMethodField() tracks_count = serializers.SerializerMethodField()
albums_count = serializers.SerializerMethodField() albums_count = serializers.SerializerMethodField()
artists_count = serializers.SerializerMethodField() artists_count = serializers.SerializerMethodField()
...@@ -652,12 +675,15 @@ class ManageTagSerializer(ManageBaseAlbumSerializer): ...@@ -652,12 +675,15 @@ class ManageTagSerializer(ManageBaseAlbumSerializer):
"artists_count", "artists_count",
] ]
@extend_schema_field(OpenApiTypes.INT)
def get_tracks_count(self, obj): def get_tracks_count(self, obj):
return getattr(obj, "_tracks_count", None) return getattr(obj, "_tracks_count", None)
@extend_schema_field(OpenApiTypes.INT)
def get_albums_count(self, obj): def get_albums_count(self, obj):
return getattr(obj, "_albums_count", None) return getattr(obj, "_albums_count", None)
@extend_schema_field(OpenApiTypes.INT)
def get_artists_count(self, obj): def get_artists_count(self, obj):
return getattr(obj, "_artists_count", None) return getattr(obj, "_artists_count", None)
...@@ -689,11 +715,13 @@ class ManageNoteSerializer(ManageBaseNoteSerializer): ...@@ -689,11 +715,13 @@ class ManageNoteSerializer(ManageBaseNoteSerializer):
class ManageReportSerializer(serializers.ModelSerializer): class ManageReportSerializer(serializers.ModelSerializer):
assigned_to = ManageBaseActorSerializer() assigned_to = ManageBaseActorSerializer(allow_null=True, required=False)
target_owner = ManageBaseActorSerializer() target_owner = ManageBaseActorSerializer(required=False)
submitter = ManageBaseActorSerializer() submitter = ManageBaseActorSerializer(required=False)
target = moderation_serializers.TARGET_FIELD target = moderation_serializers.TARGET_FIELD
notes = serializers.SerializerMethodField() notes = ManageBaseNoteSerializer(
allow_null=True, source="_prefetched_notes", many=True, default=[]
)
class Meta: class Meta:
model = moderation_models.Report model = moderation_models.Report
...@@ -728,10 +756,6 @@ class ManageReportSerializer(serializers.ModelSerializer): ...@@ -728,10 +756,6 @@ class ManageReportSerializer(serializers.ModelSerializer):
"summary", "summary",
] ]
def get_notes(self, o):
notes = getattr(o, "_prefetched_notes", [])
return ManageBaseNoteSerializer(notes, many=True).data
class ManageUserRequestSerializer(serializers.ModelSerializer): class ManageUserRequestSerializer(serializers.ModelSerializer):
assigned_to = ManageBaseActorSerializer() assigned_to = ManageBaseActorSerializer()
...@@ -761,6 +785,7 @@ class ManageUserRequestSerializer(serializers.ModelSerializer): ...@@ -761,6 +785,7 @@ class ManageUserRequestSerializer(serializers.ModelSerializer):
"metadata", "metadata",
] ]
@extend_schema_field(ManageBaseNoteSerializer)
def get_notes(self, o): def get_notes(self, o):
notes = getattr(o, "_prefetched_notes", []) notes = getattr(o, "_prefetched_notes", [])
return ManageBaseNoteSerializer(notes, many=True).data return ManageBaseNoteSerializer(notes, many=True).data
......
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 funkwhale_api.common import routers
from . import views from . import views
...@@ -31,14 +33,16 @@ other_router.register(r"channels", views.ManageChannelViewSet, "channels") ...@@ -31,14 +33,16 @@ other_router.register(r"channels", views.ManageChannelViewSet, "channels")
other_router.register(r"tags", views.ManageTagViewSet, "tags") other_router.register(r"tags", views.ManageTagViewSet, "tags")
urlpatterns = [ urlpatterns = [
url( re_path(
r"^federation/", r"^federation/",
include((federation_router.urls, "federation"), namespace="federation"), include((federation_router.urls, "federation"), namespace="federation"),
), ),
url(r"^library/", include((library_router.urls, "instance"), namespace="library")), re_path(
url( r"^library/", include((library_router.urls, "instance"), namespace="library")
),
re_path(
r"^moderation/", r"^moderation/",
include((moderation_router.urls, "moderation"), namespace="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 ] + other_router.urls
from rest_framework import mixins, response, viewsets
from rest_framework import decorators as rest_decorators
from django.db import transaction from django.db import transaction
from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery from django.db.models import Count, OuterRef, Prefetch, Q, Subquery, Sum
from django.db.models.functions import Coalesce, Length from django.db.models.functions import Coalesce, Collate, Length
from django.shortcuts import get_object_or_404 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.audio import models as audio_models
from funkwhale_api.common.mixins import MultipleLookupDetailMixin from funkwhale_api.common import decorators
from funkwhale_api.common import models as common_models 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 import utils as common_utils
from funkwhale_api.common.mixins import MultipleLookupDetailMixin
from funkwhale_api.favorites import models as favorites_models from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import tasks as federation_tasks from funkwhale_api.federation import tasks as federation_tasks
from funkwhale_api.federation import utils as federation_utils from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.history import models as history_models from funkwhale_api.history import models as history_models
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.moderation import models as moderation_models
from funkwhale_api.moderation import tasks as moderation_tasks 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.playlists import models as playlists_models from funkwhale_api.playlists import models as playlists_models
from funkwhale_api.tags import models as tags_models from funkwhale_api.tags import models as tags_models
from funkwhale_api.users import models as users_models from funkwhale_api.users import models as users_models
from . import filters, serializers from . import filters, serializers
...@@ -84,8 +84,8 @@ class ManageArtistViewSet( ...@@ -84,8 +84,8 @@ class ManageArtistViewSet(
music_models.Artist.objects.all() music_models.Artist.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "attachment_cover", "channel") .select_related("attributed_to", "attachment_cover", "channel")
.annotate(_tracks_count=Count("tracks", distinct=True)) .annotate(_tracks_count=Count("artist_credit__tracks", distinct=True))
.annotate(_albums_count=Count("albums", distinct=True)) .annotate(_albums_count=Count("artist_credit__albums", distinct=True))
.prefetch_related(music_views.TAG_PREFETCH) .prefetch_related(music_views.TAG_PREFETCH)
) )
serializer_class = serializers.ManageArtistSerializer serializer_class = serializers.ManageArtistSerializer
...@@ -93,11 +93,12 @@ class ManageArtistViewSet( ...@@ -93,11 +93,12 @@ class ManageArtistViewSet(
required_scope = "instance:libraries" required_scope = "instance:libraries"
ordering_fields = ["creation_date", "name"] ordering_fields = ["creation_date", "name"]
@extend_schema(operation_id="admin_get_library_artist_stats")
@rest_decorators.action(methods=["get"], detail=True) @rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
artist = self.get_object() artist = self.get_object()
tracks = music_models.Track.objects.filter( 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) data = get_stats(tracks, artist)
return response.Response(data, status=200) return response.Response(data, status=200)
...@@ -127,14 +128,15 @@ class ManageAlbumViewSet( ...@@ -127,14 +128,15 @@ class ManageAlbumViewSet(
queryset = ( queryset = (
music_models.Album.objects.all() music_models.Album.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "artist", "attachment_cover") .select_related("attributed_to", "attachment_cover")
.prefetch_related("tracks") .prefetch_related("tracks", "artist_credit__artist")
) )
serializer_class = serializers.ManageAlbumSerializer serializer_class = serializers.ManageAlbumSerializer
filterset_class = filters.ManageAlbumFilterSet filterset_class = filters.ManageAlbumFilterSet
required_scope = "instance:libraries" required_scope = "instance:libraries"
ordering_fields = ["creation_date", "title", "release_date"] ordering_fields = ["creation_date", "title", "release_date"]
@extend_schema(operation_id="admin_get_library_album_stats")
@rest_decorators.action(methods=["get"], detail=True) @rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
album = self.get_object() album = self.get_object()
...@@ -175,10 +177,10 @@ class ManageTrackViewSet( ...@@ -175,10 +177,10 @@ class ManageTrackViewSet(
queryset = ( queryset = (
music_models.Track.objects.all() music_models.Track.objects.all()
.order_by("-id") .order_by("-id")
.select_related( .prefetch_related(
"attributed_to", "attributed_to",
"artist", "artist_credit",
"album__artist", "album__artist_credit",
"album__attachment_cover", "album__attachment_cover",
"attachment_cover", "attachment_cover",
) )
...@@ -196,6 +198,7 @@ class ManageTrackViewSet( ...@@ -196,6 +198,7 @@ class ManageTrackViewSet(
"disc_number", "disc_number",
] ]
@extend_schema(operation_id="admin_get_track_stats")
@rest_decorators.action(methods=["get"], detail=True) @rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
track = self.get_object() track = self.get_object()
...@@ -257,6 +260,7 @@ class ManageLibraryViewSet( ...@@ -257,6 +260,7 @@ class ManageLibraryViewSet(
filterset_class = filters.ManageLibraryFilterSet filterset_class = filters.ManageLibraryFilterSet
required_scope = "instance:libraries" required_scope = "instance:libraries"
@extend_schema(operation_id="admin_get_library_stats")
@rest_decorators.action(methods=["get"], detail=True) @rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
library = self.get_object() library = self.get_object()
...@@ -269,11 +273,11 @@ class ManageLibraryViewSet( ...@@ -269,11 +273,11 @@ class ManageLibraryViewSet(
) )
artists = set( artists = set(
music_models.Album.objects.filter(pk__in=albums).values_list( music_models.Album.objects.filter(pk__in=albums).values_list(
"artist", flat=True "artist_credit__artist", flat=True
) )
) | set( ) | set(
music_models.Track.objects.filter(pk__in=tracks).values_list( music_models.Track.objects.filter(pk__in=tracks).values_list(
"artist", flat=True "artist_credit__artist", flat=True
) )
) )
...@@ -309,7 +313,11 @@ class ManageUploadViewSet( ...@@ -309,7 +313,11 @@ class ManageUploadViewSet(
queryset = ( queryset = (
music_models.Upload.objects.all() music_models.Upload.objects.all()
.order_by("-id") .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 serializer_class = serializers.ManageUploadSerializer
filterset_class = filters.ManageUploadFilterSet filterset_class = filters.ManageUploadFilterSet
...@@ -424,6 +432,7 @@ class ManageDomainViewSet( ...@@ -424,6 +432,7 @@ class ManageDomainViewSet(
domain.refresh_from_db() domain.refresh_from_db()
return response.Response(domain.nodeinfo, status=200) return response.Response(domain.nodeinfo, status=200)
@extend_schema(operation_id="admin_get_federation_domain_stats")
@rest_decorators.action(methods=["get"], detail=True) @rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
domain = self.get_object() domain = self.get_object()
...@@ -468,6 +477,7 @@ class ManageActorViewSet( ...@@ -468,6 +477,7 @@ class ManageActorViewSet(
return obj return obj
@extend_schema(operation_id="admin_get_account_stats")
@rest_decorators.action(methods=["get"], detail=True) @rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
obj = self.get_object() obj = self.get_object()
...@@ -529,7 +539,7 @@ class ManageReportViewSet( ...@@ -529,7 +539,7 @@ class ManageReportViewSet(
def perform_update(self, serializer): def perform_update(self, serializer):
is_handled = serializer.instance.is_handled 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 # report was resolved, we assign to the mod making the request
serializer.save(assigned_to=self.request.user.actor) serializer.save(assigned_to=self.request.user.actor)
else: else:
...@@ -573,6 +583,7 @@ class ManageTagViewSet( ...@@ -573,6 +583,7 @@ class ManageTagViewSet(
.order_by("-creation_date") .order_by("-creation_date")
.annotate(items_count=Count("tagged_items")) .annotate(items_count=Count("tagged_items"))
.annotate(length=Length("name")) .annotate(length=Length("name"))
.annotate(tag_deterministic=Collate("name", "und-x-icu"))
) )
serializer_class = serializers.ManageTagSerializer serializer_class = serializers.ManageTagSerializer
filterset_class = filters.ManageTagFilterSet filterset_class = filters.ManageTagFilterSet
...@@ -668,7 +679,6 @@ class ManageChannelViewSet( ...@@ -668,7 +679,6 @@ class ManageChannelViewSet(
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
url_lookups = [ url_lookups = [
{ {
"lookup_field": "uuid", "lookup_field": "uuid",
...@@ -686,7 +696,10 @@ class ManageChannelViewSet( ...@@ -686,7 +696,10 @@ class ManageChannelViewSet(
queryset = ( queryset = (
audio_models.Channel.objects.all() audio_models.Channel.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "actor",) .select_related(
"attributed_to",
"actor",
)
.prefetch_related( .prefetch_related(
Prefetch( Prefetch(
"artist", "artist",
...@@ -694,8 +707,8 @@ class ManageChannelViewSet( ...@@ -694,8 +707,8 @@ class ManageChannelViewSet(
music_models.Artist.objects.all() music_models.Artist.objects.all()
.order_by("-id") .order_by("-id")
.select_related("attributed_to", "attachment_cover", "channel") .select_related("attributed_to", "attachment_cover", "channel")
.annotate(_tracks_count=Count("tracks")) .annotate(_tracks_count=Count("artist_credit__tracks"))
.annotate(_albums_count=Count("albums")) .annotate(_albums_count=Count("artist_credit__albums"))
.prefetch_related(music_views.TAG_PREFETCH) .prefetch_related(music_views.TAG_PREFETCH)
), ),
) )
...@@ -706,11 +719,13 @@ class ManageChannelViewSet( ...@@ -706,11 +719,13 @@ class ManageChannelViewSet(
required_scope = "instance:libraries" required_scope = "instance:libraries"
ordering_fields = ["creation_date", "name"] ordering_fields = ["creation_date", "name"]
@extend_schema(operation_id="admin_get_channel_stats")
@rest_decorators.action(methods=["get"], detail=True) @rest_decorators.action(methods=["get"], detail=True)
def stats(self, request, *args, **kwargs): def stats(self, request, *args, **kwargs):
channel = self.get_object() channel = self.get_object()
tracks = music_models.Track.objects.filter( tracks = music_models.Track.objects.filter(
Q(artist=channel.artist) | Q(album__artist=channel.artist) Q(artist_credit__artist=channel.artist)
| Q(album__artist_credit__artist=channel.artist)
) )
data = get_stats(tracks, channel, ignore_fields=["libraries", "channels"]) data = get_stats(tracks, channel, ignore_fields=["libraries", "channels"])
data["follows"] = channel.actor.received_follows.count() data["follows"] = channel.actor.received_follows.count()
......
import pycountry
from dynamic_preferences import types from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import preferences as common_preferences from funkwhale_api.common import preferences as common_preferences
...@@ -17,7 +17,7 @@ class AllowListEnabled(types.BooleanPreference): ...@@ -17,7 +17,7 @@ class AllowListEnabled(types.BooleanPreference):
section = moderation section = moderation
name = "allow_list_enabled" name = "allow_list_enabled"
verbose_name = "Enable allow-listing" 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 default = False
...@@ -93,3 +93,18 @@ class SignupFormCustomization(common_preferences.SerializedPreference): ...@@ -93,3 +93,18 @@ class SignupFormCustomization(common_preferences.SerializedPreference):
required = False required = False
default = {} default = {}
data_serializer_class = CustomFormSerializer 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 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.federation import factories as federation_factories
from funkwhale_api.music import factories as music_factories from funkwhale_api.music import factories as music_factories
from funkwhale_api.users import factories as users_factories from funkwhale_api.users import factories as users_factories
...@@ -9,7 +9,7 @@ from . import serializers ...@@ -9,7 +9,7 @@ from . import serializers
@registry.register @registry.register
class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class InstancePolicyFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
summary = factory.Faker("paragraph") summary = factory.Faker("paragraph")
actor = factory.SubFactory(federation_factories.ActorFactory) actor = factory.SubFactory(federation_factories.ActorFactory)
block_all = True block_all = True
...@@ -28,7 +28,7 @@ class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -28,7 +28,7 @@ class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
@registry.register @registry.register
class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class UserFilterFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(users_factories.UserFactory) user = factory.SubFactory(users_factories.UserFactory)
target_artist = None target_artist = None
...@@ -42,7 +42,7 @@ class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -42,7 +42,7 @@ class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
@registry.register @registry.register
class NoteFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class NoteFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
author = factory.SubFactory(federation_factories.ActorFactory) author = factory.SubFactory(federation_factories.ActorFactory)
target = None target = None
summary = factory.Faker("paragraph") summary = factory.Faker("paragraph")
...@@ -52,7 +52,7 @@ class NoteFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -52,7 +52,7 @@ class NoteFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
@registry.register @registry.register
class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class ReportFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
submitter = factory.SubFactory(federation_factories.ActorFactory) submitter = factory.SubFactory(federation_factories.ActorFactory)
target = factory.SubFactory(music_factories.ArtistFactory) target = factory.SubFactory(music_factories.ArtistFactory)
summary = factory.Faker("paragraph") summary = factory.Faker("paragraph")
...@@ -77,7 +77,7 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -77,7 +77,7 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
@registry.register @registry.register
class UserRequestFactory(NoUpdateOnCreate, factory.DjangoModelFactory): class UserRequestFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
submitter = factory.SubFactory(federation_factories.ActorFactory, local=True) submitter = factory.SubFactory(federation_factories.ActorFactory, local=True)
class Meta: class Meta:
......
from django.db.models import Q from django.db.models import Q
from django_filters import rest_framework as filters from django_filters import rest_framework as filters
USER_FILTER_CONFIG = { USER_FILTER_CONFIG = {
"ARTIST": {"target_artist": ["pk"]}, "ARTIST": {"target_artist": ["pk"]},
"CHANNEL": {"target_artist": ["artist__pk"]}, "CHANNEL": {"target_artist": ["artist__pk"]},
"ALBUM": {"target_artist": ["artist__pk"]}, "ALBUM": {"target_artist": ["artist_credit__artist__pk"]},
"TRACK": {"target_artist": ["artist__pk", "album__artist__pk"]}, "TRACK": {
"LISTENING": {"target_artist": ["track__album__artist__pk", "track__artist__pk"]}, "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": { "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): ...@@ -21,7 +32,7 @@ def get_filtered_content_query(config, user):
query = None query = None
ids = user.content_filters.values_list(filter_field, flat=True) ids = user.content_filters.values_list(filter_field, flat=True)
for model_field in model_fields: for model_field in model_fields:
q = Q(**{"{}__in".format(model_field): ids}) q = Q(**{f"{model_field}__in": ids})
if query: if query:
query |= q query |= q
else: else:
...@@ -64,7 +75,7 @@ class HiddenContentFilterSet(filters.FilterSet): ...@@ -64,7 +75,7 @@ class HiddenContentFilterSet(filters.FilterSet):
config = self.__class__.Meta.hidden_content_fields_mapping config = self.__class__.Meta.hidden_content_fields_mapping
final_query = get_filtered_content_query(config, user) final_query = get_filtered_content_query(config, user)
if value is True: if value:
return queryset.filter(final_query) return queryset.filter(final_query)
else: else:
return queryset.exclude(final_query) return queryset.exclude(final_query)
import json import json
import logging
import sys import sys
import uuid import uuid
import logging
from django.core.management.base import BaseCommand, CommandError
from django.core import validators from django.core import validators
from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.common import session from funkwhale_api.common import session
from funkwhale_api.federation import models from funkwhale_api.federation import models
...@@ -71,7 +71,7 @@ class Command(BaseCommand): ...@@ -71,7 +71,7 @@ class Command(BaseCommand):
) )
) )
for name in registry.keys(): for name in registry.keys():
self.stdout.write("- {}".format(name)) self.stdout.write(f"- {name}")
return return
raw_content = None raw_content = None
content = None content = None
...@@ -82,7 +82,8 @@ class Command(BaseCommand): ...@@ -82,7 +82,8 @@ class Command(BaseCommand):
content = models.Activity.objects.get(uuid=input).payload content = models.Activity.objects.get(uuid=input).payload
elif is_url(input): elif is_url(input):
response = session.get_session().get( response = session.get_session().get(
input, headers={"Accept": "application/activity+json"}, input,
headers={"Accept": "application/activity+json"},
) )
response.raise_for_status() response.raise_for_status()
content = response.json() content = response.json()
......
# 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),
),
]
...@@ -3,8 +3,8 @@ import uuid ...@@ -3,8 +3,8 @@ import uuid
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.db import models from django.db import models
from django.db.models import JSONField
from django.db.models.signals import pre_save from django.db.models.signals import pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
...@@ -205,7 +205,9 @@ class UserRequest(models.Model): ...@@ -205,7 +205,9 @@ class UserRequest(models.Model):
max_length=40, choices=USER_REQUEST_STATUSES, default="pending" max_length=40, choices=USER_REQUEST_STATUSES, default="pending"
) )
submitter = models.ForeignKey( submitter = models.ForeignKey(
"federation.Actor", related_name="requests", on_delete=models.CASCADE, "federation.Actor",
related_name="requests",
on_delete=models.CASCADE,
) )
assigned_to = models.ForeignKey( assigned_to = models.ForeignKey(
"federation.Actor", "federation.Actor",
...@@ -224,7 +226,7 @@ class UserRequest(models.Model): ...@@ -224,7 +226,7 @@ class UserRequest(models.Model):
@receiver(pre_save, sender=Report) @receiver(pre_save, sender=Report)
def set_handled_date(sender, instance, **kwargs): def set_handled_date(sender, instance, **kwargs):
if instance.is_handled is True and not instance.handled_date: if instance.is_handled and not instance.handled_date:
instance.handled_date = timezone.now() instance.handled_date = timezone.now()
elif not instance.is_handled: elif not instance.is_handled:
instance.handled_date = None instance.handled_date = None