Commit 95497e76 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

See #170: channels ui (listeners)

parent b74517ff
...@@ -88,6 +88,9 @@ v1_patterns += [ ...@@ -88,6 +88,9 @@ v1_patterns += [
url(r"^token/?$", jwt_views.obtain_jwt_token, name="token"), url(r"^token/?$", jwt_views.obtain_jwt_token, name="token"),
url(r"^token/refresh/?$", jwt_views.refresh_jwt_token, name="token_refresh"), url(r"^token/refresh/?$", jwt_views.refresh_jwt_token, name="token_refresh"),
url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"), url(r"^rate-limit/?$", common_views.RateLimitView.as_view(), name="rate-limit"),
url(
r"^text-preview/?$", common_views.TextPreviewView.as_view(), name="text-preview"
),
] ]
urlpatterns = [ urlpatterns = [
......
...@@ -70,6 +70,8 @@ class ChannelCreateSerializer(serializers.Serializer): ...@@ -70,6 +70,8 @@ class ChannelCreateSerializer(serializers.Serializer):
@transaction.atomic @transaction.atomic
def create(self, validated_data): def create(self, validated_data):
from . import views
description = validated_data.get("description") description = validated_data.get("description")
artist = music_models.Artist.objects.create( artist = music_models.Artist.objects.create(
attributed_to=validated_data["attributed_to"], attributed_to=validated_data["attributed_to"],
...@@ -99,10 +101,11 @@ class ChannelCreateSerializer(serializers.Serializer): ...@@ -99,10 +101,11 @@ class ChannelCreateSerializer(serializers.Serializer):
actor=validated_data["attributed_to"], actor=validated_data["attributed_to"],
) )
channel.save() channel.save()
channel = views.ChannelViewSet.queryset.get(pk=channel.pk)
return channel return channel
def to_representation(self, obj): def to_representation(self, obj):
return ChannelSerializer(obj).data return ChannelSerializer(obj, context=self.context).data
NOOP = object() NOOP = object()
...@@ -181,7 +184,7 @@ class ChannelUpdateSerializer(serializers.Serializer): ...@@ -181,7 +184,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
return obj return obj
def to_representation(self, obj): def to_representation(self, obj):
return ChannelSerializer(obj).data return ChannelSerializer(obj, context=self.context).data
class ChannelSerializer(serializers.ModelSerializer): class ChannelSerializer(serializers.ModelSerializer):
...@@ -261,7 +264,8 @@ def rss_serialize_item(upload): ...@@ -261,7 +264,8 @@ def rss_serialize_item(upload):
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}], "link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
"enclosure": [ "enclosure": [
{ {
"url": upload.listen_url, # we enforce MP3, since it's the only format supported everywhere
"url": federation_utils.full_url(upload.get_listen_url(to="mp3")),
"length": upload.size or 0, "length": upload.size or 0,
"type": upload.mimetype or "audio/mpeg", "type": upload.mimetype or "audio/mpeg",
} }
...@@ -271,7 +275,6 @@ def rss_serialize_item(upload): ...@@ -271,7 +275,6 @@ def rss_serialize_item(upload):
data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}] data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}]
data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}] data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}]
data["description"] = [{"value": upload.track.description.as_plain_text}] data["description"] = [{"value": upload.track.description.as_plain_text}]
data["content:encoded"] = data["itunes:summary"]
if upload.track.attachment_cover: if upload.track.attachment_cover:
data["itunes:image"] = [ data["itunes:image"] = [
......
...@@ -6,7 +6,7 @@ from rest_framework import response ...@@ -6,7 +6,7 @@ from rest_framework import response
from rest_framework import viewsets from rest_framework import viewsets
from django import http from django import http
from django.db.models import Prefetch from django.db.models import Count, Prefetch
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from funkwhale_api.common import permissions from funkwhale_api.common import permissions
...@@ -18,6 +18,12 @@ from funkwhale_api.users.oauth import permissions as oauth_permissions ...@@ -18,6 +18,12 @@ from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters, models, renderers, serializers from . import filters, models, renderers, serializers
ARTIST_PREFETCH_QS = (
music_models.Artist.objects.select_related("description", "attachment_cover",)
.prefetch_related(music_views.TAG_PREFETCH)
.annotate(_tracks_count=Count("tracks"))
)
class ChannelsMixin(object): class ChannelsMixin(object):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
...@@ -44,12 +50,7 @@ class ChannelViewSet( ...@@ -44,12 +50,7 @@ class ChannelViewSet(
"library", "library",
"attributed_to", "attributed_to",
"actor", "actor",
Prefetch( Prefetch("artist", queryset=ARTIST_PREFETCH_QS),
"artist",
queryset=music_models.Artist.objects.select_related(
"attachment_cover", "description"
).prefetch_related(music_views.TAG_PREFETCH,),
),
) )
.order_by("-creation_date") .order_by("-creation_date")
) )
...@@ -131,7 +132,12 @@ class ChannelViewSet( ...@@ -131,7 +132,12 @@ class ChannelViewSet(
def get_serializer_context(self): def get_serializer_context(self):
context = super().get_serializer_context() context = super().get_serializer_context()
context["subscriptions_count"] = self.action in ["retrieve", "create", "update"] context["subscriptions_count"] = self.action in [
"retrieve",
"create",
"update",
"partial_update",
]
return context return context
...@@ -148,8 +154,8 @@ class SubscriptionsViewSet( ...@@ -148,8 +154,8 @@ class SubscriptionsViewSet(
.prefetch_related( .prefetch_related(
"target__channel__library", "target__channel__library",
"target__channel__attributed_to", "target__channel__attributed_to",
"target__channel__artist__description",
"actor", "actor",
Prefetch("target__channel__artist", queryset=ARTIST_PREFETCH_QS),
) )
.order_by("-creation_date") .order_by("-creation_date")
) )
...@@ -171,10 +177,12 @@ class SubscriptionsViewSet( ...@@ -171,10 +177,12 @@ class SubscriptionsViewSet(
to have a performant endpoint and avoid lots of queries just to display to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI subscription status in the UI
""" """
subscriptions = list(self.get_queryset().values_list("uuid", flat=True)) subscriptions = list(
self.get_queryset().values_list("uuid", "target__channel__uuid")
)
payload = { payload = {
"results": [str(u) for u in subscriptions], "results": [{"uuid": str(u[0]), "channel": u[1]} for u in subscriptions],
"count": len(subscriptions), "count": len(subscriptions),
} }
return response.Response(payload, status=200) return response.Response(payload, status=200)
...@@ -176,6 +176,8 @@ class ActorScopeFilter(filters.CharFilter): ...@@ -176,6 +176,8 @@ class ActorScopeFilter(filters.CharFilter):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def filter(self, queryset, value): def filter(self, queryset, value):
from funkwhale_api.federation import models as federation_models
if not value: if not value:
return queryset return queryset
...@@ -189,6 +191,17 @@ class ActorScopeFilter(filters.CharFilter): ...@@ -189,6 +191,17 @@ class ActorScopeFilter(filters.CharFilter):
qs = self.filter_me(user=user, queryset=queryset) qs = self.filter_me(user=user, queryset=queryset)
elif value.lower() == "all": elif value.lower() == "all":
return queryset return queryset
elif value.lower().startswith("actor:"):
full_username = value.split("actor:", 1)[1]
username, domain = full_username.split("@")
try:
actor = federation_models.Actor.objects.get(
preferred_username=username, domain_id=domain,
)
except federation_models.Actor.DoesNotExist:
return queryset.none()
return queryset.filter(**{self.actor_field: actor})
else: else:
return queryset.none() return queryset.none()
......
...@@ -201,7 +201,7 @@ class AttachmentQuerySet(models.QuerySet): ...@@ -201,7 +201,7 @@ class AttachmentQuerySet(models.QuerySet):
class Attachment(models.Model): class Attachment(models.Model):
# Remote URL where the attachment can be fetched # Remote URL where the attachment can be fetched
url = models.URLField(max_length=500, null=True) url = models.URLField(max_length=500, null=True, blank=True)
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4) uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
# Actor associated with the attachment # Actor associated with the attachment
actor = models.ForeignKey( actor = models.ForeignKey(
......
...@@ -303,6 +303,7 @@ def attach_content(obj, field, content_data): ...@@ -303,6 +303,7 @@ def attach_content(obj, field, content_data):
if existing: if existing:
getattr(obj, field).delete() getattr(obj, field).delete()
setattr(obj, field, None)
if not content_data: if not content_data:
return return
......
...@@ -181,3 +181,15 @@ class AttachmentViewSet( ...@@ -181,3 +181,15 @@ class AttachmentViewSet(
if instance.actor is None or instance.actor != self.request.user.actor: if instance.actor is None or instance.actor != self.request.user.actor:
raise exceptions.PermissionDenied() raise exceptions.PermissionDenied()
instance.delete() instance.delete()
class TextPreviewView(views.APIView):
permission_classes = []
def post(self, request, *args, **kwargs):
payload = request.data
if "text" not in payload:
return response.Response({"detail": "Invalid input"}, status=400)
data = {"rendered": utils.render_html(payload["text"], "text/markdown")}
return response.Response(data, status=200)
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers from rest_framework import serializers
from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.users import serializers as users_serializers
from . import filters from . import filters
from . import models from . import models
...@@ -169,3 +172,27 @@ class FetchSerializer(serializers.ModelSerializer): ...@@ -169,3 +172,27 @@ class FetchSerializer(serializers.ModelSerializer):
"creation_date", "creation_date",
"fetch_date", "fetch_date",
] ]
class FullActorSerializer(serializers.Serializer):
fid = serializers.URLField()
url = serializers.URLField()
domain = serializers.CharField(source="domain_id")
creation_date = serializers.DateTimeField()
last_fetch_date = serializers.DateTimeField()
name = serializers.CharField()
preferred_username = serializers.CharField()
full_username = serializers.CharField()
type = serializers.CharField()
is_local = serializers.BooleanField()
is_channel = serializers.SerializerMethodField()
manually_approves_followers = serializers.BooleanField()
user = users_serializers.UserBasicSerializer()
summary = common_serializers.ContentSerializer(source="summary_obj")
icon = common_serializers.AttachmentSerializer(source="attachment_icon")
def get_is_channel(self, o):
try:
return bool(o.channel)
except ObjectDoesNotExist:
return False
...@@ -8,5 +8,6 @@ router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-fol ...@@ -8,5 +8,6 @@ router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-fol
router.register(r"inbox", api_views.InboxItemViewSet, "inbox") router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
router.register(r"libraries", api_views.LibraryViewSet, "libraries") router.register(r"libraries", api_views.LibraryViewSet, "libraries")
router.register(r"domains", api_views.DomainViewSet, "domains") router.register(r"domains", api_views.DomainViewSet, "domains")
router.register(r"actors", api_views.ActorViewSet, "actors")
urlpatterns = router.urls urlpatterns = router.urls
...@@ -12,6 +12,7 @@ from rest_framework import viewsets ...@@ -12,6 +12,7 @@ from rest_framework import viewsets
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common.permissions import ConditionalAuthentication from funkwhale_api.common.permissions import ConditionalAuthentication
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import views as music_views
from funkwhale_api.users.oauth import permissions as oauth_permissions from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import activity from . import activity
...@@ -218,3 +219,34 @@ class DomainViewSet( ...@@ -218,3 +219,34 @@ class DomainViewSet(
if preferences.get("moderation__allow_list_enabled"): if preferences.get("moderation__allow_list_enabled"):
qs = qs.filter(allowed=True) qs = qs.filter(allowed=True)
return qs return qs
class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = models.Actor.objects.select_related(
"user", "channel", "summary_obj", "attachment_icon"
)
permission_classes = [ConditionalAuthentication]
serializer_class = api_serializers.FullActorSerializer
lookup_field = "full_username"
lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
def get_object(self):
queryset = self.get_queryset()
username, domain = self.kwargs["full_username"].split("@", 1)
return queryset.get(preferred_username=username, domain_id=domain)
def get_queryset(self):
qs = super().get_queryset()
qs = qs.exclude(
domain__instance_policy__is_active=True,
domain__instance_policy__block_all=True,
)
if preferences.get("moderation__allow_list_enabled"):
qs = qs.filter(domain__allowed=True)
return qs
libraries = decorators.action(methods=["get"], detail=True)(
music_views.get_libraries(
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
)
)
...@@ -253,7 +253,6 @@ class APIActorSerializer(serializers.ModelSerializer): ...@@ -253,7 +253,6 @@ class APIActorSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Actor model = models.Actor
fields = [ fields = [
"id",
"fid", "fid",
"url", "url",
"creation_date", "creation_date",
......
...@@ -876,6 +876,12 @@ class Upload(models.Model): ...@@ -876,6 +876,12 @@ class Upload(models.Model):
def listen_url(self): def listen_url(self):
return self.track.listen_url + "?upload={}".format(self.uuid) return self.track.listen_url + "?upload={}".format(self.uuid)
def get_listen_url(self, to=None):
url = self.listen_url
if to:
url += "&to={}".format(to)
return url
@property @property
def listen_url_no_download(self): def listen_url_no_download(self):
# Not using reverse because this is slow # Not using reverse because this is slow
......
...@@ -156,6 +156,19 @@ def serialize_artist_simple(artist): ...@@ -156,6 +156,19 @@ def serialize_artist_simple(artist):
else None else None
) )
if "attachment_cover" in artist._state.fields_cache:
data["cover"] = (
cover_field.to_representation(artist.attachment_cover)
if artist.attachment_cover
else None
)
if getattr(artist, "_tracks_count", None) is not None:
data["tracks_count"] = artist._tracks_count
if getattr(artist, "_prefetched_tagged_items", None) is not None:
data["tags"] = [ti.tag.name for ti in artist._prefetched_tagged_items]
return data return data
......
...@@ -5,6 +5,29 @@ from rest_framework import renderers ...@@ -5,6 +5,29 @@ from rest_framework import renderers
import funkwhale_api import funkwhale_api
# from https://stackoverflow.com/a/8915039
# because I want to avoid a lxml dependency just for outputting cdata properly
# in a RSS feed
def CDATA(text=None):
element = ET.Element("![CDATA[")
element.text = text
return element
ET._original_serialize_xml = ET._serialize_xml
def _serialize_xml(write, elem, qnames, namespaces, **kwargs):
if elem.tag == "![CDATA[":
write("<%s%s]]>" % (elem.tag, elem.text))
return
return ET._original_serialize_xml(write, elem, qnames, namespaces, **kwargs)
ET._serialize_xml = ET._serialize["xml"] = _serialize_xml
# end of tweaks
def structure_payload(data): def structure_payload(data):
payload = { payload = {
"funkwhaleVersion": funkwhale_api.__version__, "funkwhaleVersion": funkwhale_api.__version__,
...@@ -56,7 +79,7 @@ def dict_to_xml_tree(root_tag, d, parent=None): ...@@ -56,7 +79,7 @@ def dict_to_xml_tree(root_tag, d, parent=None):
if key == "value": if key == "value":
root.text = str(value) root.text = str(value)
elif key == "cdata_value": elif key == "cdata_value":
root.text = "<![CDATA[{}]]>".format(str(value)) root.append(CDATA(value))
else: else:
root.set(key, str(value)) root.set(key, str(value))
return root return root
...@@ -229,8 +229,8 @@ class User(AbstractUser): ...@@ -229,8 +229,8 @@ class User(AbstractUser):
self.last_activity = now self.last_activity = now
self.save(update_fields=["last_activity"]) self.save(update_fields=["last_activity"])
def create_actor(self): def create_actor(self, **kwargs):
self.actor = create_actor(self) self.actor = create_actor(self, **kwargs)
self.save(update_fields=["actor"]) self.save(update_fields=["actor"])
return self.actor return self.actor
...@@ -264,15 +264,10 @@ class User(AbstractUser): ...@@ -264,15 +264,10 @@ class User(AbstractUser):
def full_username(self): def full_username(self):
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME) return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
@property def get_avatar(self):
def avatar_path(self): if not self.actor:
if not self.avatar: return
return None return self.actor.attachment_icon
try:
return self.avatar.path
except NotImplementedError:
# external storage
return self.avatar.name
def generate_code(length=10): def generate_code(length=10):
...@@ -399,8 +394,9 @@ def get_actor_data(username, **kwargs): ...@@ -399,8 +394,9 @@ def get_actor_data(username, **kwargs):
} }
def create_actor(user): def create_actor(user, **kwargs):
args = get_actor_data(user.username) args = get_actor_data(user.username)
args.update(kwargs)
private, public = keys.get_key_pair() private, public = keys.get_key_pair()
args["private_key"] = private.decode("utf-8") args["private_key"] = private.decode("utf-8")
args["public_key"] = public.decode("utf-8") args["public_key"] = public.decode("utf-8")
......
...@@ -90,17 +90,12 @@ class UserActivitySerializer(activity_serializers.ModelSerializer): ...@@ -90,17 +90,12 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
class UserBasicSerializer(serializers.ModelSerializer): class UserBasicSerializer(serializers.ModelSerializer):
avatar = serializers.SerializerMethodField() avatar = common_serializers.AttachmentSerializer(source="get_avatar")
class Meta: class Meta:
model = models.User model = models.User
fields = ["id", "username", "name", "date_joined", "avatar"] fields = ["id", "username", "name", "date_joined", "avatar"]
def get_avatar(self, o):
return common_serializers.AttachmentSerializer(
o.actor.attachment_icon if o.actor else None
).data
class UserWriteSerializer(serializers.ModelSerializer): class UserWriteSerializer(serializers.ModelSerializer):
summary = common_serializers.ContentSerializer(required=False, allow_null=True) summary = common_serializers.ContentSerializer(required=False, allow_null=True)
...@@ -140,19 +135,12 @@ class UserWriteSerializer(serializers.ModelSerializer): ...@@ -140,19 +135,12 @@ class UserWriteSerializer(serializers.ModelSerializer):
obj.actor.save(update_fields=["attachment_icon"]) obj.actor.save(update_fields=["attachment_icon"])
return obj return obj
def to_representation(self, obj):
repr = super().to_representation(obj)
repr["avatar"] = common_serializers.AttachmentSerializer(
obj.actor.attachment_icon
).data
return repr
class UserReadSerializer(serializers.ModelSerializer):