From 87bc011e32313d3a97e3742e449690c879f518e3 Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Thu, 23 Jan 2020 11:09:52 +0100 Subject: [PATCH] Actor description/summary --- .../migrations/0023_actor_summary_obj.py | 20 +++++++ api/funkwhale_api/federation/models.py | 3 + api/funkwhale_api/federation/serializers.py | 55 ++++++++++++------- api/funkwhale_api/users/serializers.py | 22 ++++++++ api/tests/federation/test_serializers.py | 21 ++++--- api/tests/users/test_views.py | 25 ++++++++- 6 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 api/funkwhale_api/federation/migrations/0023_actor_summary_obj.py diff --git a/api/funkwhale_api/federation/migrations/0023_actor_summary_obj.py b/api/funkwhale_api/federation/migrations/0023_actor_summary_obj.py new file mode 100644 index 000000000..48b29edfb --- /dev/null +++ b/api/funkwhale_api/federation/migrations/0023_actor_summary_obj.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.9 on 2020-01-22 11:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0007_auto_20200116_1610'), + ('federation', '0022_auto_20191204_1539'), + ] + + operations = [ + migrations.AddField( + model_name='actor', + name='summary_obj', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.Content'), + ), + ] diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 60cf26054..211ac9447 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -189,6 +189,9 @@ class Actor(models.Model): name = models.CharField(max_length=200, null=True, blank=True) domain = models.ForeignKey(Domain, on_delete=models.CASCADE, related_name="actors") summary = models.CharField(max_length=500, null=True, blank=True) + summary_obj = models.ForeignKey( + "common.Content", null=True, blank=True, on_delete=models.SET_NULL + ) preferred_username = models.CharField(max_length=200, null=True, blank=True) public_key = models.TextField(max_length=5000, null=True, blank=True) private_key = models.TextField(max_length=5000, null=True, blank=True) diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index c55c99ed0..4883062b3 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -21,6 +21,18 @@ from . import activity, actors, contexts, jsonld, models, tasks, utils logger = logging.getLogger(__name__) +class TruncatedCharField(serializers.CharField): + def __init__(self, *args, **kwargs): + self.truncate_length = kwargs.pop("truncate_length") + super().__init__(*args, **kwargs) + + def to_internal_value(self, v): + v = super().to_internal_value(v) + if v: + v = v[: self.truncate_length] + return v + + class LinkSerializer(jsonld.JsonLdSerializer): type = serializers.ChoiceField(choices=[contexts.AS.Link, contexts.AS.Image]) href = serializers.URLField(max_length=500) @@ -76,7 +88,11 @@ class ActorSerializer(jsonld.JsonLdSerializer): preferredUsername = serializers.CharField() manuallyApprovesFollowers = serializers.NullBooleanField(required=False) name = serializers.CharField(required=False, max_length=200) - summary = serializers.CharField(max_length=None, required=False) + summary = TruncatedCharField( + truncate_length=common_models.CONTENT_TEXT_MAX_LENGTH, + required=False, + allow_null=True, + ) followers = serializers.URLField(max_length=500, required=False) following = serializers.URLField(max_length=500, required=False, allow_null=True) publicKey = PublicKeySerializer(required=False) @@ -113,11 +129,12 @@ class ActorSerializer(jsonld.JsonLdSerializer): ret["followers"] = instance.followers_url if instance.following_url: ret["following"] = instance.following_url - if instance.summary: - ret["summary"] = instance.summary if instance.manually_approves_followers is not None: ret["manuallyApprovesFollowers"] = instance.manually_approves_followers + if instance.summary_obj_id: + ret["summary"] = instance.summary_obj.rendered + ret["@context"] = jsonld.get_default_context() if instance.public_key: ret["publicKey"] = { @@ -146,7 +163,6 @@ class ActorSerializer(jsonld.JsonLdSerializer): "inbox_url": self.validated_data.get("inbox"), "following_url": self.validated_data.get("following"), "followers_url": self.validated_data.get("followers"), - "summary": self.validated_data.get("summary"), "type": self.validated_data["type"], "name": self.validated_data.get("name"), "preferred_username": self.validated_data["preferredUsername"], @@ -181,11 +197,22 @@ class ActorSerializer(jsonld.JsonLdSerializer): def save(self, **kwargs): d = self.prepare_missing_fields() d.update(kwargs) - return models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0] + actor = models.Actor.objects.update_or_create(fid=d["fid"], defaults=d)[0] + common_utils.attach_content( + actor, "summary_obj", self.validated_data["summary"] + ) + return actor - def validate_summary(self, value): - if value: - return value[:500] + def validate(self, data): + validated_data = super().validate(data) + if "summary" in data: + validated_data["summary"] = { + "content_type": "text/html", + "text": data["summary"], + } + else: + validated_data["summary"] = None + return validated_data class APIActorSerializer(serializers.ModelSerializer): @@ -828,18 +855,6 @@ def include_image(repr, attachment): repr["image"] = None -class TruncatedCharField(serializers.CharField): - def __init__(self, *args, **kwargs): - self.truncate_length = kwargs.pop("truncate_length") - super().__init__(*args, **kwargs) - - def to_internal_value(self, v): - v = super().to_internal_value(v) - if v: - v = v[: self.truncate_length] - return v - - class MusicEntitySerializer(jsonld.JsonLdSerializer): id = serializers.URLField(max_length=500) published = serializers.DateTimeField() diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index cddc2e82a..0d167b1a1 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -11,6 +11,7 @@ from versatileimagefield.serializers import VersatileImageFieldSerializer from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.common import utils as common_utils from funkwhale_api.federation import models as federation_models from . import adapters from . import models @@ -27,6 +28,7 @@ class ASCIIUsernameValidator(validators.RegexValidator): username_validators = [ASCIIUsernameValidator()] +NOOP = object() class RegisterSerializer(RS): @@ -106,6 +108,7 @@ class UserBasicSerializer(serializers.ModelSerializer): class UserWriteSerializer(serializers.ModelSerializer): avatar = avatar_field + summary = common_serializers.ContentSerializer(required=False, allow_null=True) class Meta: model = models.User @@ -115,8 +118,20 @@ class UserWriteSerializer(serializers.ModelSerializer): "avatar", "instance_support_message_display_date", "funkwhale_support_message_display_date", + "summary", ] + def update(self, obj, validated_data): + if not obj.actor: + obj.create_actor() + summary = validated_data.pop("summary", NOOP) + obj = super().update(obj, validated_data) + + if summary != NOOP: + common_utils.attach_content(obj.actor, "summary_obj", summary) + + return obj + class UserReadSerializer(serializers.ModelSerializer): @@ -150,17 +165,24 @@ class UserReadSerializer(serializers.ModelSerializer): class MeSerializer(UserReadSerializer): quota_status = serializers.SerializerMethodField() + summary = serializers.SerializerMethodField() class Meta(UserReadSerializer.Meta): fields = UserReadSerializer.Meta.fields + [ "quota_status", "instance_support_message_display_date", "funkwhale_support_message_display_date", + "summary", ] def get_quota_status(self, o): return o.get_quota_status() if o.actor else 0 + def get_summary(self, o): + if not o.actor or not o.actor.summary_obj: + return + return common_serializers.ContentSerializer(o.actor.summary_obj).data + class PasswordResetSerializer(PRS): def get_email_options(self): diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index 4f31dcbdf..93fd14ba1 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -53,7 +53,8 @@ def test_actor_serializer_from_ap(db): assert actor.type == "Person" assert actor.preferred_username == payload["preferredUsername"] assert actor.name == payload["name"] - assert actor.summary == payload["summary"] + assert actor.summary_obj.text == payload["summary"] + assert actor.summary_obj.content_type == "text/html" assert actor.fid == actor_url assert actor.manually_approves_followers is True assert actor.private_key is None @@ -89,7 +90,7 @@ def test_actor_serializer_only_mandatory_field_from_ap(db): assert actor.manually_approves_followers is None -def test_actor_serializer_to_ap(): +def test_actor_serializer_to_ap(db): expected = { "@context": jsonld.get_default_context(), "id": "https://test.federation/user", @@ -100,7 +101,6 @@ def test_actor_serializer_to_ap(): "outbox": "https://test.federation/user/outbox", "preferredUsername": "user", "name": "Real User", - "summary": "Hello world", "manuallyApprovesFollowers": False, "publicKey": { "id": "https://test.federation/user#main-key", @@ -109,7 +109,7 @@ def test_actor_serializer_to_ap(): }, "endpoints": {"sharedInbox": "https://test.federation/inbox"}, } - ac = models.Actor( + ac = models.Actor.objects.create( fid=expected["id"], inbox_url=expected["inbox"], outbox_url=expected["outbox"], @@ -119,11 +119,15 @@ def test_actor_serializer_to_ap(): public_key=expected["publicKey"]["publicKeyPem"], preferred_username=expected["preferredUsername"], name=expected["name"], - domain=models.Domain(pk="test.federation"), - summary=expected["summary"], + domain=models.Domain.objects.create(pk="test.federation"), type="Person", manually_approves_followers=False, ) + + content = common_utils.attach_content( + ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"} + ) + expected["summary"] = content.rendered serializer = serializers.ActorSerializer(ac) assert serializer.data == expected @@ -1127,14 +1131,17 @@ def test_local_actor_serializer_to_ap(factories): preferred_username=expected["preferredUsername"], name=expected["name"], domain=models.Domain.objects.create(pk="test.federation"), - summary=expected["summary"], type="Person", manually_approves_followers=False, ) + content = common_utils.attach_content( + ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"} + ) user = factories["users.User"]() user.actor = ac user.save() ac.refresh_from_db() + expected["summary"] = content.rendered expected["icon"] = { "type": "Image", "mediaType": "image/jpeg", diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 8156c84b5..d01252621 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -1,6 +1,8 @@ import pytest from django.urls import reverse +from funkwhale_api.common import serializers as common_serializers +from funkwhale_api.common import utils as common_utils from funkwhale_api.users.models import User @@ -105,7 +107,10 @@ def test_can_fetch_data_from_api(api_client, factories): # login required assert response.status_code == 401 - user = factories["users.User"](permission_library=True) + user = factories["users.User"](permission_library=True, with_actor=True) + summary = {"content_type": "text/plain", "text": "Hello"} + summary_obj = common_utils.attach_content(user.actor, "summary_obj", summary) + api_client.login(username=user.username, password="test") response = api_client.get(url) assert response.status_code == 200 @@ -115,6 +120,10 @@ def test_can_fetch_data_from_api(api_client, factories): assert response.data["email"] == user.email assert response.data["name"] == user.name assert response.data["permissions"] == user.get_permissions() + assert ( + response.data["summary"] + == common_serializers.ContentSerializer(summary_obj).data + ) def test_can_get_token_via_api(api_client, factories): @@ -202,6 +211,20 @@ def test_user_can_patch_his_own_settings(logged_in_api_client): assert user.privacy_level == "me" +def test_user_can_patch_description(logged_in_api_client): + user = logged_in_api_client.user + payload = {"summary": {"content_type": "text/markdown", "text": "hello"}} + url = reverse("api:v1:users:users-detail", kwargs={"username": user.username}) + + response = logged_in_api_client.patch(url, payload, format="json") + + assert response.status_code == 200 + user.refresh_from_db() + + assert user.actor.summary_obj.content_type == payload["summary"]["content_type"] + assert user.actor.summary_obj.text == payload["summary"]["text"] + + def test_user_can_request_new_subsonic_token(logged_in_api_client): user = logged_in_api_client.user user.subsonic_api_token = "test"