Merge branch '170-rss-feed-federation' into 'develop'
See #170: expose/store actor URL over federation See merge request funkwhale/funkwhale!1026
This commit is contained in:
commit
0504dc0819
|
@ -25,6 +25,7 @@ class ChannelFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||||
music_factories.ArtistFactory,
|
music_factories.ArtistFactory,
|
||||||
attributed_to=factory.SelfAttribute("..attributed_to"),
|
attributed_to=factory.SelfAttribute("..attributed_to"),
|
||||||
)
|
)
|
||||||
|
rss_url = factory.Faker("url")
|
||||||
metadata = factory.LazyAttribute(lambda o: {})
|
metadata = factory.LazyAttribute(lambda o: {})
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 2.2.10 on 2020-02-06 15:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('audio', '0002_channel_metadata'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='channel',
|
||||||
|
name='rss_url',
|
||||||
|
field=models.URLField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -36,6 +36,7 @@ class Channel(models.Model):
|
||||||
"music.Library", on_delete=models.CASCADE, related_name="channel"
|
"music.Library", on_delete=models.CASCADE, related_name="channel"
|
||||||
)
|
)
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
|
rss_url = models.URLField(max_length=500, null=True, blank=True)
|
||||||
|
|
||||||
# metadata to enhance rss feed
|
# metadata to enhance rss feed
|
||||||
metadata = JSONField(
|
metadata = JSONField(
|
||||||
|
@ -46,6 +47,9 @@ class Channel(models.Model):
|
||||||
return federation_utils.full_url("/channels/{}".format(self.uuid))
|
return federation_utils.full_url("/channels/{}".format(self.uuid))
|
||||||
|
|
||||||
def get_rss_url(self):
|
def get_rss_url(self):
|
||||||
|
if not self.artist.is_local:
|
||||||
|
return self.rss_url
|
||||||
|
|
||||||
return federation_utils.full_url(
|
return federation_utils.full_url(
|
||||||
reverse("api:v1:channels-rss", kwargs={"uuid": self.uuid})
|
reverse("api:v1:channels-rss", kwargs={"uuid": self.uuid})
|
||||||
)
|
)
|
||||||
|
|
|
@ -191,6 +191,7 @@ class ChannelSerializer(serializers.ModelSerializer):
|
||||||
artist = serializers.SerializerMethodField()
|
artist = serializers.SerializerMethodField()
|
||||||
actor = federation_serializers.APIActorSerializer()
|
actor = federation_serializers.APIActorSerializer()
|
||||||
attributed_to = federation_serializers.APIActorSerializer()
|
attributed_to = federation_serializers.APIActorSerializer()
|
||||||
|
rss_url = serializers.CharField(source="get_rss_url")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Channel
|
model = models.Channel
|
||||||
|
@ -201,6 +202,7 @@ class ChannelSerializer(serializers.ModelSerializer):
|
||||||
"actor",
|
"actor",
|
||||||
"creation_date",
|
"creation_date",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"rss_url",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_artist(self, obj):
|
def get_artist(self, obj):
|
||||||
|
|
|
@ -167,7 +167,7 @@ def prepare_for_serializer(payload, config, fallbacks={}):
|
||||||
attr=field_config.get("attr"),
|
attr=field_config.get("attr"),
|
||||||
)
|
)
|
||||||
except (IndexError, KeyError):
|
except (IndexError, KeyError):
|
||||||
aliases = field_config.get("aliases", [])
|
aliases = field_config.get("aliases", {})
|
||||||
noop = object()
|
noop = object()
|
||||||
value = noop
|
value = noop
|
||||||
if not aliases:
|
if not aliases:
|
||||||
|
@ -176,9 +176,7 @@ def prepare_for_serializer(payload, config, fallbacks={}):
|
||||||
for a in aliases:
|
for a in aliases:
|
||||||
try:
|
try:
|
||||||
value = get_value(
|
value = get_value(
|
||||||
payload[a],
|
payload[a["property"]], keep=a.get("keep"), attr=a.get("attr"),
|
||||||
keep=field_config.get("keep"),
|
|
||||||
attr=field_config.get("attr"),
|
|
||||||
)
|
)
|
||||||
except (IndexError, KeyError):
|
except (IndexError, KeyError):
|
||||||
continue
|
continue
|
||||||
|
@ -231,8 +229,14 @@ def get_default_context_fw():
|
||||||
|
|
||||||
|
|
||||||
class JsonLdSerializer(serializers.Serializer):
|
class JsonLdSerializer(serializers.Serializer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.jsonld_expand = kwargs.pop("jsonld_expand", True)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def run_validation(self, data=empty):
|
def run_validation(self, data=empty):
|
||||||
if data and data is not empty and self.context.get("expand", True):
|
if data and data is not empty:
|
||||||
|
|
||||||
|
if self.context.get("expand", self.jsonld_expand):
|
||||||
try:
|
try:
|
||||||
data = expand(data)
|
data = expand(data)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -247,6 +251,7 @@ class JsonLdSerializer(serializers.Serializer):
|
||||||
fallbacks = self.Meta.jsonld_fallbacks
|
fallbacks = self.Meta.jsonld_fallbacks
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
fallbacks = {}
|
fallbacks = {}
|
||||||
|
|
||||||
data = prepare_for_serializer(data, config, fallbacks=fallbacks)
|
data = prepare_for_serializer(data, config, fallbacks=fallbacks)
|
||||||
dereferenced_fields = [
|
dereferenced_fields = [
|
||||||
k
|
k
|
||||||
|
|
|
@ -254,6 +254,17 @@ class Actor(models.Model):
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_channel(self):
|
||||||
|
try:
|
||||||
|
return self.channel
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
if self.is_local:
|
||||||
|
return federation_utils.full_url("/@{}".format(self.preferred_username))
|
||||||
|
return self.url or self.fid
|
||||||
|
|
||||||
def get_current_usage(self):
|
def get_current_usage(self):
|
||||||
actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get()
|
actor = self.__class__.objects.filter(pk=self.pk).with_current_usage().get()
|
||||||
data = {}
|
data = {}
|
||||||
|
|
|
@ -91,6 +91,17 @@ class ImageSerializer(MediaSerializer):
|
||||||
return validated_data
|
return validated_data
|
||||||
|
|
||||||
|
|
||||||
|
class URLSerializer(jsonld.JsonLdSerializer):
|
||||||
|
href = serializers.URLField(max_length=500)
|
||||||
|
mediaType = serializers.CharField(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
jsonld_mapping = {
|
||||||
|
"href": jsonld.first_id(contexts.AS.href, aliases=[jsonld.raw("@id")]),
|
||||||
|
"mediaType": jsonld.first_val(contexts.AS.mediaType),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class EndpointsSerializer(jsonld.JsonLdSerializer):
|
class EndpointsSerializer(jsonld.JsonLdSerializer):
|
||||||
sharedInbox = serializers.URLField(max_length=500, required=False)
|
sharedInbox = serializers.URLField(max_length=500, required=False)
|
||||||
|
|
||||||
|
@ -105,10 +116,19 @@ class PublicKeySerializer(jsonld.JsonLdSerializer):
|
||||||
jsonld_mapping = {"publicKeyPem": jsonld.first_val(contexts.SEC.publicKeyPem)}
|
jsonld_mapping = {"publicKeyPem": jsonld.first_val(contexts.SEC.publicKeyPem)}
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_media_type(urls, media_type):
|
||||||
|
for url in urls:
|
||||||
|
if url.get("mediaType", "text/html") == media_type:
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
class ActorSerializer(jsonld.JsonLdSerializer):
|
class ActorSerializer(jsonld.JsonLdSerializer):
|
||||||
id = serializers.URLField(max_length=500)
|
id = serializers.URLField(max_length=500)
|
||||||
outbox = serializers.URLField(max_length=500, required=False)
|
outbox = serializers.URLField(max_length=500, required=False)
|
||||||
inbox = serializers.URLField(max_length=500, required=False)
|
inbox = serializers.URLField(max_length=500, required=False)
|
||||||
|
url = serializers.ListField(
|
||||||
|
child=URLSerializer(jsonld_expand=False), required=False, min_length=0
|
||||||
|
)
|
||||||
type = serializers.ChoiceField(
|
type = serializers.ChoiceField(
|
||||||
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
|
choices=[getattr(contexts.AS, c[0]) for c in models.TYPE_CHOICES]
|
||||||
)
|
)
|
||||||
|
@ -144,6 +164,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
||||||
"mediaType": jsonld.first_val(contexts.AS.mediaType),
|
"mediaType": jsonld.first_val(contexts.AS.mediaType),
|
||||||
"endpoints": jsonld.first_obj(contexts.AS.endpoints),
|
"endpoints": jsonld.first_obj(contexts.AS.endpoints),
|
||||||
"icon": jsonld.first_obj(contexts.AS.icon),
|
"icon": jsonld.first_obj(contexts.AS.icon),
|
||||||
|
"url": jsonld.raw(contexts.AS.url),
|
||||||
}
|
}
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
|
@ -165,6 +186,36 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
||||||
|
|
||||||
if instance.summary_obj_id:
|
if instance.summary_obj_id:
|
||||||
ret["summary"] = instance.summary_obj.rendered
|
ret["summary"] = instance.summary_obj.rendered
|
||||||
|
urls = []
|
||||||
|
if instance.url:
|
||||||
|
urls.append(
|
||||||
|
{"type": "Link", "href": instance.url, "mediaType": "text/html"}
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = instance.get_channel()
|
||||||
|
if channel:
|
||||||
|
ret["url"] = [
|
||||||
|
{
|
||||||
|
"type": "Link",
|
||||||
|
"href": instance.channel.get_absolute_url()
|
||||||
|
if instance.channel.artist.is_local
|
||||||
|
else instance.get_absolute_url(),
|
||||||
|
"mediaType": "text/html",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Link",
|
||||||
|
"href": instance.channel.get_rss_url(),
|
||||||
|
"mediaType": "application/rss+xml",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
ret["url"] = [
|
||||||
|
{
|
||||||
|
"type": "Link",
|
||||||
|
"href": instance.get_absolute_url(),
|
||||||
|
"mediaType": "text/html",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
ret["@context"] = jsonld.get_default_context()
|
ret["@context"] = jsonld.get_default_context()
|
||||||
if instance.public_key:
|
if instance.public_key:
|
||||||
|
@ -192,6 +243,10 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
||||||
"name": self.validated_data.get("name"),
|
"name": self.validated_data.get("name"),
|
||||||
"preferred_username": self.validated_data["preferredUsername"],
|
"preferred_username": self.validated_data["preferredUsername"],
|
||||||
}
|
}
|
||||||
|
url = get_by_media_type(self.validated_data.get("url", []), "text/html")
|
||||||
|
if url:
|
||||||
|
kwargs["url"] = url["href"]
|
||||||
|
|
||||||
maf = self.validated_data.get("manuallyApprovesFollowers")
|
maf = self.validated_data.get("manuallyApprovesFollowers")
|
||||||
if maf is not None:
|
if maf is not None:
|
||||||
kwargs["manually_approves_followers"] = maf
|
kwargs["manually_approves_followers"] = maf
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from funkwhale_api.federation import utils as federation_utils
|
||||||
|
|
||||||
|
|
||||||
def test_channel(factories, now):
|
def test_channel(factories, now):
|
||||||
channel = factories["audio.Channel"]()
|
channel = factories["audio.Channel"]()
|
||||||
assert channel.artist is not None
|
assert channel.artist is not None
|
||||||
|
@ -5,3 +10,16 @@ def test_channel(factories, now):
|
||||||
assert channel.attributed_to is not None
|
assert channel.attributed_to is not None
|
||||||
assert channel.library is not None
|
assert channel.library is not None
|
||||||
assert channel.creation_date >= now
|
assert channel.creation_date >= now
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_get_rss_url_local(factories):
|
||||||
|
channel = factories["audio.Channel"](artist__local=True)
|
||||||
|
expected = federation_utils.full_url(
|
||||||
|
reverse("api:v1:channels-rss", kwargs={"uuid": channel.uuid})
|
||||||
|
)
|
||||||
|
assert channel.get_rss_url() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_get_rss_url_remote(factories):
|
||||||
|
channel = factories["audio.Channel"]()
|
||||||
|
assert channel.get_rss_url() == channel.rss_url
|
||||||
|
|
|
@ -134,6 +134,7 @@ def test_channel_serializer_representation(factories, to_api_date):
|
||||||
channel.attributed_to
|
channel.attributed_to
|
||||||
).data,
|
).data,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
|
"rss_url": channel.get_rss_url(),
|
||||||
}
|
}
|
||||||
expected["artist"]["description"] = common_serializers.ContentSerializer(
|
expected["artist"]["description"] = common_serializers.ContentSerializer(
|
||||||
content
|
content
|
||||||
|
|
|
@ -30,6 +30,7 @@ def test_actor_serializer_from_ap(db):
|
||||||
"name": "Test",
|
"name": "Test",
|
||||||
"summary": "Hello world",
|
"summary": "Hello world",
|
||||||
"manuallyApprovesFollowers": True,
|
"manuallyApprovesFollowers": True,
|
||||||
|
"url": "http://hello.world/path",
|
||||||
"publicKey": {
|
"publicKey": {
|
||||||
"publicKeyPem": public.decode("utf-8"),
|
"publicKeyPem": public.decode("utf-8"),
|
||||||
"owner": actor_url,
|
"owner": actor_url,
|
||||||
|
@ -48,7 +49,7 @@ def test_actor_serializer_from_ap(db):
|
||||||
actor = serializer.save()
|
actor = serializer.save()
|
||||||
|
|
||||||
assert actor.fid == actor_url
|
assert actor.fid == actor_url
|
||||||
assert actor.url is None
|
assert actor.url == payload["url"]
|
||||||
assert actor.inbox_url == payload["inbox"]
|
assert actor.inbox_url == payload["inbox"]
|
||||||
assert actor.shared_inbox_url == payload["endpoints"]["sharedInbox"]
|
assert actor.shared_inbox_url == payload["endpoints"]["sharedInbox"]
|
||||||
assert actor.outbox_url == payload["outbox"]
|
assert actor.outbox_url == payload["outbox"]
|
||||||
|
@ -108,6 +109,7 @@ def test_actor_serializer_to_ap(factories):
|
||||||
"outbox": "https://test.federation/user/outbox",
|
"outbox": "https://test.federation/user/outbox",
|
||||||
"preferredUsername": "user",
|
"preferredUsername": "user",
|
||||||
"name": "Real User",
|
"name": "Real User",
|
||||||
|
"url": [{"type": "Link", "href": "https://test.url", "mediaType": "text/html"}],
|
||||||
"manuallyApprovesFollowers": False,
|
"manuallyApprovesFollowers": False,
|
||||||
"publicKey": {
|
"publicKey": {
|
||||||
"id": "https://test.federation/user#main-key",
|
"id": "https://test.federation/user#main-key",
|
||||||
|
@ -120,13 +122,14 @@ def test_actor_serializer_to_ap(factories):
|
||||||
fid=expected["id"],
|
fid=expected["id"],
|
||||||
inbox_url=expected["inbox"],
|
inbox_url=expected["inbox"],
|
||||||
outbox_url=expected["outbox"],
|
outbox_url=expected["outbox"],
|
||||||
|
url=expected["url"][0]["href"],
|
||||||
shared_inbox_url=expected["endpoints"]["sharedInbox"],
|
shared_inbox_url=expected["endpoints"]["sharedInbox"],
|
||||||
followers_url=expected["followers"],
|
followers_url=expected["followers"],
|
||||||
following_url=expected["following"],
|
following_url=expected["following"],
|
||||||
public_key=expected["publicKey"]["publicKeyPem"],
|
public_key=expected["publicKey"]["publicKeyPem"],
|
||||||
preferred_username=expected["preferredUsername"],
|
preferred_username=expected["preferredUsername"],
|
||||||
name=expected["name"],
|
name=expected["name"],
|
||||||
domain=models.Domain.objects.create(pk="test.federation"),
|
domain=models.Domain.objects.create(pk="test.domain"),
|
||||||
type="Person",
|
type="Person",
|
||||||
manually_approves_followers=False,
|
manually_approves_followers=False,
|
||||||
attachment_icon=factories["common.Attachment"](),
|
attachment_icon=factories["common.Attachment"](),
|
||||||
|
@ -1112,7 +1115,7 @@ def test_activity_pub_audio_serializer_to_ap(factories):
|
||||||
assert serializer.data == expected
|
assert serializer.data == expected
|
||||||
|
|
||||||
|
|
||||||
def test_local_actor_serializer_to_ap(factories):
|
def test_local_actor_serializer_to_ap(factories, settings):
|
||||||
expected = {
|
expected = {
|
||||||
"@context": jsonld.get_default_context(),
|
"@context": jsonld.get_default_context(),
|
||||||
"id": "https://test.federation/user",
|
"id": "https://test.federation/user",
|
||||||
|
@ -1155,6 +1158,15 @@ def test_local_actor_serializer_to_ap(factories):
|
||||||
user.save()
|
user.save()
|
||||||
ac.refresh_from_db()
|
ac.refresh_from_db()
|
||||||
expected["summary"] = content.rendered
|
expected["summary"] = content.rendered
|
||||||
|
expected["url"] = [
|
||||||
|
{
|
||||||
|
"type": "Link",
|
||||||
|
"href": "https://{}/@{}".format(
|
||||||
|
settings.FUNKWHALE_HOSTNAME, ac.preferred_username
|
||||||
|
),
|
||||||
|
"mediaType": "text/html",
|
||||||
|
}
|
||||||
|
]
|
||||||
expected["icon"] = {
|
expected["icon"] = {
|
||||||
"type": "Image",
|
"type": "Image",
|
||||||
"mediaType": "image/jpeg",
|
"mediaType": "image/jpeg",
|
||||||
|
@ -1197,6 +1209,25 @@ def test_track_serializer_update_license(factories):
|
||||||
assert obj.license_id == "cc-by-2.0"
|
assert obj.license_id == "cc-by-2.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_channel_actor_serializer(factories):
|
||||||
|
channel = factories["audio.Channel"]()
|
||||||
|
serializer = serializers.ActorSerializer(channel.actor)
|
||||||
|
expected_url = [
|
||||||
|
{
|
||||||
|
"type": "Link",
|
||||||
|
"href": channel.actor.get_absolute_url(),
|
||||||
|
"mediaType": "text/html",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Link",
|
||||||
|
"href": channel.get_rss_url(),
|
||||||
|
"mediaType": "application/rss+xml",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert serializer.data["url"] == expected_url
|
||||||
|
|
||||||
|
|
||||||
def test_channel_actor_outbox_serializer(factories):
|
def test_channel_actor_outbox_serializer(factories):
|
||||||
channel = factories["audio.Channel"]()
|
channel = factories["audio.Channel"]()
|
||||||
uploads = factories["music.Upload"].create_batch(
|
uploads = factories["music.Upload"].create_batch(
|
||||||
|
|
Loading…
Reference in New Issue