音楽で楽しみましょう!-Let's have fun with music!-

Signed-off-by: Shin'ya Minazuki <shinyoukai@laidback.moe>
This commit is contained in:
Shin'ya Minazuki 2026-01-24 16:16:49 -03:00
commit 54c6d22102
517 changed files with 637 additions and 639 deletions

View file

View file

@ -0,0 +1,8 @@
from funkwhale_api.common.consumers import JsonAuthConsumer
class InstanceActivityConsumer(JsonAuthConsumer):
groups = ["instance_activity"]
def event_send(self, message):
self.send_json(message["data"])

View file

@ -0,0 +1,188 @@
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
instance = types.Section("instance")
ui = types.Section("ui")
@global_preferences_registry.register
class InstanceName(types.StringPreference):
show_in_api = True
section = instance
name = "name"
default = ""
verbose_name = "Public name"
help_text = "The public name of your instance, displayed in the about page."
field_kwargs = {"required": False}
@global_preferences_registry.register
class InstanceShortDescription(types.StringPreference):
show_in_api = True
section = instance
name = "short_description"
default = ""
verbose_name = "Short description"
help_text = "Instance succinct description, displayed in the about page."
field_kwargs = {"required": False}
@global_preferences_registry.register
class InstanceLongDescription(types.StringPreference):
show_in_api = True
section = instance
name = "long_description"
verbose_name = "Long description"
default = ""
help_text = "Instance long description, displayed in the about page."
widget = widgets.Textarea
field_kwargs = {"required": False}
@global_preferences_registry.register
class InstanceTerms(types.StringPreference):
show_in_api = True
section = instance
name = "terms"
verbose_name = "Terms of service"
default = ""
help_text = "Terms of service and privacy policy for your instance."
widget = widgets.Textarea
field_kwargs = {"required": False}
@global_preferences_registry.register
class InstanceRules(types.StringPreference):
show_in_api = True
section = instance
name = "rules"
verbose_name = "Rules"
default = ""
help_text = "Rules/Code of Conduct."
widget = widgets.Textarea
field_kwargs = {"required": False}
@global_preferences_registry.register
class InstanceContactEmail(types.StringPreference):
show_in_api = True
section = instance
name = "contact_email"
verbose_name = "Contact email"
default = ""
help_text = "A contact e-mail address for visitors who need to contact an admin or moderator"
field_kwargs = {"required": False}
@global_preferences_registry.register
class InstanceSupportMessage(types.StringPreference):
show_in_api = True
section = instance
name = "support_message"
verbose_name = "Support message"
default = ""
help_text = (
"A short message that will be displayed periodically to local users. "
"Use it to ask for financial support or anything else you might need. "
"(markdown allowed)."
)
widget = widgets.Textarea
field_kwargs = {"required": False}
@global_preferences_registry.register
class InstanceFunQuailSupportMessageEnabled(types.BooleanPreference):
show_in_api = True
section = instance
name = "funkwhale_support_message_enabled"
verbose_name = "FunQuail Support message"
default = True
help_text = (
"If this is enabled, we will periodically display a message to encourage "
"local users to support FunQuail."
)
@global_preferences_registry.register
class InstanceNodeinfoPrivate(types.BooleanPreference):
show_in_api = False
section = instance
name = "nodeinfo_private"
default = False
verbose_name = "Private mode in nodeinfo"
help_text = (
"Indicate in the nodeinfo endpoint that you do not want your instance "
"to be tracked by third-party services. "
"There is no guarantee these tools will honor this setting though."
)
@global_preferences_registry.register
class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
show_in_api = False
section = instance
name = "nodeinfo_stats_enabled"
default = True
verbose_name = "Enable usage and library stats in nodeinfo endpoint"
help_text = (
"Disable this if you don't want to share usage and library statistics "
"in the nodeinfo endpoint but don't want to disable it completely."
)
@global_preferences_registry.register
class CustomCSS(types.StringPreference):
show_in_api = True
section = ui
name = "custom_css"
verbose_name = "Custom CSS code"
default = ""
help_text = (
"Custom CSS code, to be included in a <style> tag on all pages. "
"Loading third-party resources such as fonts or images can affect the performance "
"of the app and the privacy of your users."
)
widget = widgets.Textarea
field_kwargs = {"required": False}
class ImageWidget(widgets.ClearableFileInput):
pass
class ImagePreference(types.FilePreference):
widget = ImageWidget
field_kwargs = {
"validators": [
FileExtensionValidator(allowed_extensions=["png", "jpg", "jpeg", "webp"])
]
}
@global_preferences_registry.register
class Banner(ImagePreference):
show_in_api = True
section = instance
name = "banner"
verbose_name = "Banner image"
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}

View file

@ -0,0 +1,48 @@
{
"name": "FunQuail",
"categories": ["music", "entertainment"],
"short_name": "FunQuail",
"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": []
}
]
}

View file

@ -0,0 +1,312 @@
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://funquail.laidback.moe"
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="FunQuail")
short_name = serializers.CharField(default="FunQuail")
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)

View file

@ -0,0 +1,99 @@
import datetime
from django.db.models import Count, F, Sum
from django.utils import timezone
from funkwhale_api.favorites.models import TrackFavorite
from funkwhale_api.history.models import Listening
from funkwhale_api.music import models
from funkwhale_api.users.models import User
def get():
return {
"users": get_users(),
"tracks": get_tracks(),
"albums": get_albums(),
"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__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__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()
active_month = now - datetime.timedelta(days=30)
active_halfyear = now - datetime.timedelta(days=30 * 6)
return {
"total": qs.count(),
"active_month": qs.filter(last_activity__gte=active_month).count(),
"active_halfyear": qs.filter(last_activity__gte=active_halfyear).count(),
}
return User.objects.count()
def get_listenings():
return Listening.objects.count()
def get_track_favorites():
return TrackFavorite.objects.count()
def get_tracks():
return models.Track.objects.local().count()
def get_albums():
return models.Album.objects.local().count()
def get_artists():
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():
seconds = models.Upload.objects.aggregate(d=Sum("duration"))["d"]
if seconds:
return seconds / 3600
return 0

View file

@ -0,0 +1,14 @@
from django.conf.urls import url
from funkwhale_api.common import routers
from . import views
admin_router = routers.OptionalSlashRouter()
admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
urlpatterns = [
url(r"^nodeinfo/2.0/?$", views.NodeInfo20.as_view(), name="nodeinfo-2.0"),
url(r"^settings/?$", views.InstanceSettings.as_view(), name="settings"),
url(r"^spa-manifest.json", views.SpaManifest.as_view(), name="spa-manifest"),
] + admin_router.urls

View file

@ -0,0 +1,7 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(r"^nodeinfo/2.1/?$", views.NodeInfo21.as_view(), name="nodeinfo-2.1"),
]

View file

@ -0,0 +1,203 @@
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 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 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(generics.GenericAPIView):
permission_classes = []
authentication_classes = []
serializer_class = GlobalPreferenceSerializer
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)
]
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)
@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.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,
"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")
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]
@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"
)