Commit 95497e76 authored by Eliot Berriot's avatar Eliot Berriot 💬

See #170: channels ui (listeners)

parent b74517ff
......@@ -88,6 +88,9 @@ v1_patterns += [
url(r"^token/?$", jwt_views.obtain_jwt_token, name="token"),
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"^text-preview/?$", common_views.TextPreviewView.as_view(), name="text-preview"
),
]
urlpatterns = [
......
......@@ -70,6 +70,8 @@ class ChannelCreateSerializer(serializers.Serializer):
@transaction.atomic
def create(self, validated_data):
from . import views
description = validated_data.get("description")
artist = music_models.Artist.objects.create(
attributed_to=validated_data["attributed_to"],
......@@ -99,10 +101,11 @@ class ChannelCreateSerializer(serializers.Serializer):
actor=validated_data["attributed_to"],
)
channel.save()
channel = views.ChannelViewSet.queryset.get(pk=channel.pk)
return channel
def to_representation(self, obj):
return ChannelSerializer(obj).data
return ChannelSerializer(obj, context=self.context).data
NOOP = object()
......@@ -181,7 +184,7 @@ class ChannelUpdateSerializer(serializers.Serializer):
return obj
def to_representation(self, obj):
return ChannelSerializer(obj).data
return ChannelSerializer(obj, context=self.context).data
class ChannelSerializer(serializers.ModelSerializer):
......@@ -261,7 +264,8 @@ def rss_serialize_item(upload):
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
"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,
"type": upload.mimetype or "audio/mpeg",
}
......@@ -271,7 +275,6 @@ def rss_serialize_item(upload):
data["itunes:subtitle"] = [{"value": upload.track.description.truncate(255)}]
data["itunes:summary"] = [{"cdata_value": upload.track.description.rendered}]
data["description"] = [{"value": upload.track.description.as_plain_text}]
data["content:encoded"] = data["itunes:summary"]
if upload.track.attachment_cover:
data["itunes:image"] = [
......
......@@ -6,7 +6,7 @@ from rest_framework import response
from rest_framework import viewsets
from django import http
from django.db.models import Prefetch
from django.db.models import Count, Prefetch
from django.db.utils import IntegrityError
from funkwhale_api.common import permissions
......@@ -18,6 +18,12 @@ from funkwhale_api.users.oauth import permissions as oauth_permissions
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):
def dispatch(self, request, *args, **kwargs):
......@@ -44,12 +50,7 @@ class ChannelViewSet(
"library",
"attributed_to",
"actor",
Prefetch(
"artist",
queryset=music_models.Artist.objects.select_related(
"attachment_cover", "description"
).prefetch_related(music_views.TAG_PREFETCH,),
),
Prefetch("artist", queryset=ARTIST_PREFETCH_QS),
)
.order_by("-creation_date")
)
......@@ -131,7 +132,12 @@ class ChannelViewSet(
def get_serializer_context(self):
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
......@@ -148,8 +154,8 @@ class SubscriptionsViewSet(
.prefetch_related(
"target__channel__library",
"target__channel__attributed_to",
"target__channel__artist__description",
"actor",
Prefetch("target__channel__artist", queryset=ARTIST_PREFETCH_QS),
)
.order_by("-creation_date")
)
......@@ -171,10 +177,12 @@ class SubscriptionsViewSet(
to have a performant endpoint and avoid lots of queries just to display
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 = {
"results": [str(u) for u in subscriptions],
"results": [{"uuid": str(u[0]), "channel": u[1]} for u in subscriptions],
"count": len(subscriptions),
}
return response.Response(payload, status=200)
......@@ -176,6 +176,8 @@ class ActorScopeFilter(filters.CharFilter):
super().__init__(*args, **kwargs)
def filter(self, queryset, value):
from funkwhale_api.federation import models as federation_models
if not value:
return queryset
......@@ -189,6 +191,17 @@ class ActorScopeFilter(filters.CharFilter):
qs = self.filter_me(user=user, queryset=queryset)
elif value.lower() == "all":
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:
return queryset.none()
......
......@@ -201,7 +201,7 @@ class AttachmentQuerySet(models.QuerySet):
class Attachment(models.Model):
# 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)
# Actor associated with the attachment
actor = models.ForeignKey(
......
......@@ -303,6 +303,7 @@ def attach_content(obj, field, content_data):
if existing:
getattr(obj, field).delete()
setattr(obj, field, None)
if not content_data:
return
......
......@@ -181,3 +181,15 @@ class AttachmentViewSet(
if instance.actor is None or instance.actor != self.request.user.actor:
raise exceptions.PermissionDenied()
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 funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models
from funkwhale_api.users import serializers as users_serializers
from . import filters
from . import models
......@@ -169,3 +172,27 @@ class FetchSerializer(serializers.ModelSerializer):
"creation_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
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
router.register(r"domains", api_views.DomainViewSet, "domains")
router.register(r"actors", api_views.ActorViewSet, "actors")
urlpatterns = router.urls
......@@ -12,6 +12,7 @@ from rest_framework import viewsets
from funkwhale_api.common import preferences
from funkwhale_api.common.permissions import ConditionalAuthentication
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 . import activity
......@@ -218,3 +219,34 @@ class DomainViewSet(
if preferences.get("moderation__allow_list_enabled"):
qs = qs.filter(allowed=True)
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):
class Meta:
model = models.Actor
fields = [
"id",
"fid",
"url",
"creation_date",
......
......@@ -876,6 +876,12 @@ class Upload(models.Model):
def listen_url(self):
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
def listen_url_no_download(self):
# Not using reverse because this is slow
......
......@@ -156,6 +156,19 @@ def serialize_artist_simple(artist):
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
......
......@@ -5,6 +5,29 @@ from rest_framework import renderers
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):
payload = {
"funkwhaleVersion": funkwhale_api.__version__,
......@@ -56,7 +79,7 @@ def dict_to_xml_tree(root_tag, d, parent=None):
if key == "value":
root.text = str(value)
elif key == "cdata_value":
root.text = "<![CDATA[{}]]>".format(str(value))
root.append(CDATA(value))
else:
root.set(key, str(value))
return root
......@@ -229,8 +229,8 @@ class User(AbstractUser):
self.last_activity = now
self.save(update_fields=["last_activity"])
def create_actor(self):
self.actor = create_actor(self)
def create_actor(self, **kwargs):
self.actor = create_actor(self, **kwargs)
self.save(update_fields=["actor"])
return self.actor
......@@ -264,15 +264,10 @@ class User(AbstractUser):
def full_username(self):
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
@property
def avatar_path(self):
if not self.avatar:
return None
try:
return self.avatar.path
except NotImplementedError:
# external storage
return self.avatar.name
def get_avatar(self):
if not self.actor:
return
return self.actor.attachment_icon
def generate_code(length=10):
......@@ -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.update(kwargs)
private, public = keys.get_key_pair()
args["private_key"] = private.decode("utf-8")
args["public_key"] = public.decode("utf-8")
......
......@@ -90,17 +90,12 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
class UserBasicSerializer(serializers.ModelSerializer):
avatar = serializers.SerializerMethodField()
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
class Meta:
model = models.User
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):
summary = common_serializers.ContentSerializer(required=False, allow_null=True)
......@@ -140,19 +135,12 @@ class UserWriteSerializer(serializers.ModelSerializer):
obj.actor.save(update_fields=["attachment_icon"])
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):
permissions = serializers.SerializerMethodField()
full_username = serializers.SerializerMethodField()
avatar = serializers.SerializerMethodField()
avatar = common_serializers.AttachmentSerializer(source="get_avatar")
class Meta:
model = models.User
......@@ -170,9 +158,6 @@ class UserReadSerializer(serializers.ModelSerializer):
"avatar",
]
def get_avatar(self, o):
return common_serializers.AttachmentSerializer(o.actor.attachment_icon).data
def get_permissions(self, o):
return o.get_permissions()
......
......@@ -185,7 +185,6 @@ def test_rss_item_serializer(factories):
"itunes:subtitle": [{"value": description.truncate(255)}],
"itunes:summary": [{"cdata_value": description.rendered}],
"description": [{"value": description.as_plain_text}],
"content:encoded": [{"cdata_value": description.rendered}],
"guid": [{"cdata_value": str(upload.uuid), "isPermalink": "false"}],
"pubDate": [{"value": serializers.rss_date(upload.creation_date)}],
"itunes:duration": [{"value": serializers.rss_duration(upload.duration)}],
......@@ -197,7 +196,11 @@ def test_rss_item_serializer(factories):
"itunes:image": [{"href": upload.track.attachment_cover.download_url_original}],
"link": [{"value": federation_utils.full_url(upload.track.get_absolute_url())}],
"enclosure": [
{"url": upload.listen_url, "length": upload.size, "type": upload.mimetype}
{
"url": federation_utils.full_url(upload.get_listen_url("mp3")),
"length": upload.size,
"type": upload.mimetype,
}
],
}
......
import uuid
import pytest
from django.urls import reverse
from funkwhale_api.audio import serializers
from funkwhale_api.audio import views
def test_channel_create(logged_in_api_client):
......@@ -23,8 +25,10 @@ def test_channel_create(logged_in_api_client):
assert response.status_code == 201
channel = actor.owned_channels.select_related("artist__description").latest("id")
expected = serializers.ChannelSerializer(channel).data
channel = views.ChannelViewSet.queryset.get(attributed_to=actor)
expected = serializers.ChannelSerializer(
channel, context={"subscriptions_count": True}
).data
assert response.data == expected
assert channel.artist.name == data["name"]
......@@ -43,6 +47,9 @@ def test_channel_create(logged_in_api_client):
def test_channel_detail(factories, logged_in_api_client):
channel = factories["audio.Channel"](artist__description=None)
url = reverse("api:v1:channels-detail", kwargs={"uuid": channel.uuid})
setattr(channel.artist, "_tracks_count", 0)
setattr(channel.artist, "_prefetched_tagged_items", [])
expected = serializers.ChannelSerializer(
channel, context={"subscriptions_count": True}
).data
......@@ -54,6 +61,8 @@ def test_channel_detail(factories, logged_in_api_client):
def test_channel_list(factories, logged_in_api_client):
channel = factories["audio.Channel"](artist__description=None)
setattr(channel.artist, "_tracks_count", 0)
setattr(channel.artist, "_prefetched_tagged_items", [])
url = reverse("api:v1:channels-list")
expected = serializers.ChannelSerializer(channel).data
response = logged_in_api_client.get(url)
......@@ -142,8 +151,11 @@ def test_channel_subscribe(factories, logged_in_api_client):
assert response.status_code == 201
subscription = actor.emitted_follows.select_related(
"target__channel__artist__description"
"target__channel__artist__description",
"target__channel__artist__attachment_cover",
).latest("id")
setattr(subscription.target.channel.artist, "_tracks_count", 0)
setattr(subscription.target.channel.artist, "_prefetched_tagged_items", [])
assert subscription.fid == subscription.get_federation_id()
expected = serializers.SubscriptionSerializer(subscription).data
assert response.data == expected
......@@ -168,6 +180,8 @@ def test_subscriptions_list(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
channel = factories["audio.Channel"](artist__description=None)
subscription = factories["audio.Subscription"](target=channel.actor, actor=actor)
setattr(subscription.target.channel.artist, "_tracks_count", 0)
setattr(subscription.target.channel.artist, "_prefetched_tagged_items", [])
factories["audio.Subscription"](target=channel.actor)
url = reverse("api:v1:subscriptions-list")
expected = serializers.SubscriptionSerializer(subscription).data
......@@ -192,7 +206,10 @@ def test_subscriptions_all(factories, logged_in_api_client):
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == {"results": [subscription.uuid], "count": 1}
assert response.data == {
"results": [{"uuid": subscription.uuid, "channel": uuid.UUID(channel.uuid)}],
"count": 1,
}
def test_channel_rss_feed(factories, api_client):
......
......@@ -50,6 +50,8 @@ def test_mutation_filter_is_approved(value, expected, factories):
("noop", 0, []),
("noop", 1, []),
("noop", 2, []),
("actor:actor1@domain.test", 0, [0]),
("actor:actor2@domain.test", 0, [1]),
],
)
def test_actor_scope_filter(
......@@ -61,8 +63,13 @@ def test_actor_scope_filter(
mocker,
anonymous_user,
):
actor1 = factories["users.User"]().create_actor()
actor2 = factories["users.User"]().create_actor()
domain = factories["federation.Domain"](name="domain.test")
actor1 = factories["users.User"]().create_actor(
preferred_username="actor1", domain=domain
)
actor2 = factories["users.User"]().create_actor(
preferred_username="actor2", domain=domain
)
users = [actor1.user, actor2.user, anonymous_user]
tracks = [
factories["music.Upload"](library__actor=actor1, playable=True).track,
......
......@@ -7,6 +7,7 @@ from funkwhale_api.common import serializers
from funkwhale_api.common import signals
from funkwhale_api.common import tasks
from funkwhale_api.common import throttling
from funkwhale_api.common import utils
def test_can_detail_mutation(logged_in_api_client, factories):
......@@ -270,3 +271,13 @@ def test_attachment_destroy_not_owner(factories, logged_in_api_client):
assert response.status_code == 403
attachment.refresh_from_db()
def test_can_render_text_preview(api_client, db):
payload = {"text": "Hello world"}
url = reverse("api:v1:text-preview")
response = api_client.post(url, payload)
expected = {"rendered": utils.render_html(payload["text"], "text/markdown")}
assert response.status_code == 200
assert response.data == expected
......@@ -29,6 +29,9 @@ def test_user_can_get_his_favorites(
favorite, context={"request": request}
).data
]
expected[0]["track"]["artist"].pop("cover")
expected[0]["track"]["album"]["artist"].pop("cover")
assert response.status_code == 200
assert response.data["results"] == expected
......
import pytest
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.federation import api_serializers
from funkwhale_api.federation import serializers
from funkwhale_api.users import serializers as users_serializers
def test_library_serializer(factories, to_api_date):
......@@ -111,3 +113,31 @@ def test_serialize_generic_relation(factory_name, factory_kwargs, expected, fact
obj = factories[factory_name](**factory_kwargs)
expected["type"] = factory_name
assert api_serializers.serialize_generic_relation({}, obj) == expected
def test_api_full_actor_serializer(factories, to_api_date):
summary = factories["common.Content"]()
icon = factories["common.Attachment"]()
user = factories["users.User"]()
actor = user.create_actor(summary_obj=summary, attachment_icon=icon)
expected = {
"fid": actor.fid,
"url": actor.url,
"creation_date": to_api_date(actor.creation_date),
"last_fetch_date": to_api_date(actor.last_fetch_date),
"user": users_serializers.UserBasicSerializer(user).data,
"is_channel": False,
"domain": actor.domain_id,
"type": actor.type,
"manually_approves_followers": actor.manually_approves_followers,
"full_username": actor.full_username,
"name": actor.name,
"preferred_username": actor.preferred_username,
"is_local": actor.is_local,
"summary": common_serializers.ContentSerializer(summary).data,
"icon": common_serializers.AttachmentSerializer(icon).data,
}
serializer = api_serializers.FullActorSerializer(actor)
assert serializer.data == expected
......@@ -197,3 +197,15 @@ def test_user_can_list_domains(factories, api_client, preferences):
"results": [api_serializers.DomainSerializer(allowed).data],
}
assert response.data == expected
def test_can_retrieve_actor(factories, api_client, preferences):
preferences["common__api_authentication_required"] = False
actor = factories["federation.Actor"]()
url = reverse(
"api:v1:federation:actors-detail", kwargs={"full_username": actor.full_username}
)
response = api_client.get(url)
expected = api_serializers.FullActorSerializer(actor).data
assert response.data == expected
......@@ -215,6 +215,8 @@ def test_album_serializer(factories, to_api_date):
}
serializer = serializers.AlbumSerializer(album)
for t in expected["tracks"]:
t["artist"].pop("cover")
assert serializer.data == expected
......