Merge branch 'actor-avatar' into 'develop'

Federation of avatars

See merge request funkwhale/funkwhale!1003
This commit is contained in:
Eliot Berriot 2020-01-23 17:28:44 +01:00
commit 9f3ca7b4c5
20 changed files with 214 additions and 126 deletions

View File

@ -180,6 +180,7 @@ class AttachmentQuerySet(models.QuerySet):
"mutation_attachment",
"covered_track",
"covered_artist",
"iconed_actor",
]
query = None
for field in related_fields:

View File

@ -5,13 +5,9 @@ Compute different sizes of image used for Album covers and User avatars
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from funkwhale_api.common.models import Attachment
from funkwhale_api.music.models import Album
from funkwhale_api.users.models import User
MODELS = [
(Album, "cover", "square"),
(User, "avatar", "square"),
(Attachment, "file", "attachment_square"),
]

View File

@ -24,6 +24,7 @@ class RelatedField(serializers.RelatedField):
self.related_field_name = related_field_name
self.serializer = serializer
self.filters = kwargs.pop("filters", None)
self.queryset_filter = kwargs.pop("queryset_filter", None)
try:
kwargs["queryset"] = kwargs.pop("queryset")
except KeyError:
@ -36,10 +37,16 @@ class RelatedField(serializers.RelatedField):
filters.update(self.filters(self.context))
return filters
def filter_queryset(self, queryset):
if self.queryset_filter:
queryset = self.queryset_filter(queryset, self.context)
return queryset
def to_internal_value(self, data):
try:
queryset = self.get_queryset()
filters = self.get_filters(data)
queryset = self.filter_queryset(queryset)
return queryset.get(**filters)
except ObjectDoesNotExist:
self.fail(
@ -318,3 +325,16 @@ class ContentSerializer(serializers.Serializer):
def get_html(self, o):
return utils.render_html(o.text, o.content_type)
class NullToEmptDict(object):
def get_attribute(self, o):
attr = super().get_attribute(o)
if attr is None:
return {}
return attr
def to_representation(self, v):
if not v:
return v
return super().to_representation(v)

View File

@ -327,8 +327,11 @@ def attach_file(obj, field, file_data, fetch=False):
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
extension = extensions.get(file_data["mimetype"], "jpg")
attachment = models.Attachment(mimetype=file_data["mimetype"])
filename = "cover-{}.{}".format(obj.uuid, extension)
name_fields = ["uuid", "full_username", "pk"]
name = [getattr(obj, field) for field in name_fields if getattr(obj, field, None)][
0
]
filename = "{}-{}.{}".format(field, name, extension)
if "url" in file_data:
attachment.url = file_data["url"]
else:

View File

@ -22,7 +22,9 @@ class TrackFavoriteViewSet(
filterset_class = filters.TrackFavoriteFilter
serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all().select_related("user__actor")
queryset = models.TrackFavorite.objects.all().select_related(
"user__actor__attachment_icon"
)
permission_classes = [
oauth_permissions.ScopePermission,
permissions.OwnerPermission,

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2.9 on 2020-01-23 13:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('common', '0007_auto_20200116_1610'),
('federation', '0023_actor_summary_obj'),
]
operations = [
migrations.AddField(
model_name='actor',
name='attachment_icon',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='iconed_actor', to='common.Attachment'),
),
]

View File

@ -205,6 +205,13 @@ class Actor(models.Model):
through_fields=("target", "actor"),
related_name="following",
)
attachment_icon = models.ForeignKey(
"common.Attachment",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="iconed_actor",
)
objects = ActorQuerySet.as_manager()

View File

@ -1,9 +1,7 @@
import logging
import mimetypes
import urllib.parse
import uuid
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
from django.db import transaction
@ -97,6 +95,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
following = serializers.URLField(max_length=500, required=False, allow_null=True)
publicKey = PublicKeySerializer(required=False)
endpoints = EndpointsSerializer(required=False)
icon = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
)
class Meta:
jsonld_mapping = {
@ -113,6 +114,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
),
"mediaType": jsonld.first_val(contexts.AS.mediaType),
"endpoints": jsonld.first_obj(contexts.AS.endpoints),
"icon": jsonld.first_obj(contexts.AS.icon),
}
def to_representation(self, instance):
@ -143,17 +145,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
"id": "{}#main-key".format(instance.fid),
}
ret["endpoints"] = {}
include_image(ret, instance.attachment_icon, "icon")
if instance.shared_inbox_url:
ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url
try:
if instance.user.avatar:
ret["icon"] = {
"type": "Image",
"mediaType": mimetypes.guess_type(instance.user.avatar_path)[0],
"url": utils.full_url(instance.user.avatar.crop["400x400"].url),
}
except ObjectDoesNotExist:
pass
return ret
def prepare_missing_fields(self):
@ -201,6 +197,15 @@ class ActorSerializer(jsonld.JsonLdSerializer):
common_utils.attach_content(
actor, "summary_obj", self.validated_data["summary"]
)
if "icon" in self.validated_data:
new_value = self.validated_data["icon"]
common_utils.attach_file(
actor,
"attachment_icon",
{"url": new_value["href"], "mimetype": new_value["mediaType"]}
if new_value
else None,
)
return actor
def validate(self, data):
@ -844,15 +849,15 @@ def include_content(repr, content_obj):
repr["mediaType"] = "text/html"
def include_image(repr, attachment):
def include_image(repr, attachment, field="image"):
if attachment:
repr["image"] = {
repr[field] = {
"type": "Image",
"href": attachment.download_url_original,
"mediaType": attachment.mimetype or "image/jpeg",
}
else:
repr["image"] = None
repr[field] = None
class MusicEntitySerializer(jsonld.JsonLdSerializer):

View File

@ -19,7 +19,9 @@ class ListeningViewSet(
):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related("user__actor")
queryset = models.Listening.objects.all().select_related(
"user__actor__attachment_icon"
)
permission_classes = [
oauth_permissions.ScopePermission,

View File

@ -61,7 +61,9 @@ class DescriptionMutation(mutations.UpdateMutationSerializer):
class CoverMutation(mutations.UpdateMutationSerializer):
cover = common_serializers.RelatedField(
"uuid", queryset=common_models.Attachment.objects.all().local(), serializer=None
"uuid",
queryset=common_models.Attachment.objects.all().local(),
serializer=None,
)
def get_serialized_relations(self):

View File

@ -18,20 +18,9 @@ from funkwhale_api.tags import serializers as tags_serializers
from . import filters, models, tasks
class NullToEmptDict(object):
def get_attribute(self, o):
attr = super().get_attribute(o)
if attr is None:
return {}
return attr
def to_representation(self, v):
if not v:
return v
return super().to_representation(v)
class CoverField(NullToEmptDict, common_serializers.AttachmentSerializer):
class CoverField(
common_serializers.NullToEmptDict, common_serializers.AttachmentSerializer
):
# XXX: BACKWARD COMPATIBILITY
pass

View File

@ -23,7 +23,7 @@ class PlaylistViewSet(
serializer_class = serializers.PlaylistSerializer
queryset = (
models.Playlist.objects.all()
.select_related("user__actor")
.select_related("user__actor__attachment_icon")
.annotate(tracks_count=Count("playlist_tracks", distinct=True))
.with_covers()
.with_duration()

View File

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
def create_attachments(apps, schema_editor):
Actor = apps.get_model("federation", "Actor")
User = apps.get_model("users", "User")
Attachment = apps.get_model("common", "Attachment")
obj_attachment_mapping = {}
def get_mimetype(path):
if path.lower().endswith('.png'):
return "image/png"
return "image/jpeg"
qs = User.objects.filter(actor__attachment_icon=None).exclude(avatar="").exclude(avatar=None).exclude(actor=None).select_related('actor')
total = qs.count()
print('Creating attachments for {} user avatars, this may take a while…'.format(total))
from django.core.files.storage import FileSystemStorage
for i, user in enumerate(qs):
if isinstance(user.avatar.storage._wrapped, FileSystemStorage):
try:
size = user.avatar.size
except FileNotFoundError:
# can occur when file isn't found on disk or S3
print(" Warning: avatar file wasn't found in storage: {}".format(e.__class__))
size = None
obj_attachment_mapping[user.actor] = Attachment(
file=user.avatar,
size=size,
mimetype=get_mimetype(user.avatar.name),
)
print('Commiting changes…')
Attachment.objects.bulk_create(obj_attachment_mapping.values(), batch_size=2000)
# map each attachment to the corresponding obj
# and bulk save
for obj, attachment in obj_attachment_mapping.items():
obj.attachment_icon = attachment
Actor.objects.bulk_update(obj_attachment_mapping.keys(), fields=['attachment_icon'], batch_size=2000)
def rewind(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [("users", "0016_auto_20190920_0857"), ("federation", "0024_actor_attachment_icon")]
operations = [migrations.RunPython(create_attachments, rewind)]

View File

@ -21,7 +21,6 @@ from django_auth_ldap.backend import populate_user as ldap_populate_user
from oauth2_provider import models as oauth2_models
from oauth2_provider import validators as oauth2_validators
from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from funkwhale_api.common import fields, preferences
from funkwhale_api.common import utils as common_utils
@ -413,13 +412,3 @@ def create_actor(user):
def init_ldap_user(sender, user, ldap_user, **kwargs):
if not user.actor:
user.actor = create_actor(user)
@receiver(models.signals.post_save, sender=User)
def warm_user_avatar(sender, instance, **kwargs):
if not instance.avatar or not settings.CREATE_IMAGE_THUMBNAILS:
return
user_avatar_warmer = VersatileImageFieldWarmer(
instance_or_queryset=instance, rendition_key_set="square", image_attr="avatar"
)
num_created, failed_to_create = user_avatar_warmer.warm()

View File

@ -7,9 +7,9 @@ from django.utils.translation import gettext_lazy as _
from rest_auth.serializers import PasswordResetSerializer as PRS
from rest_auth.registration.serializers import RegisterSerializer as RS, get_adapter
from rest_framework import serializers
from versatileimagefield.serializers import VersatileImageFieldSerializer
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.common import models as common_models
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
@ -89,26 +89,30 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
return "Person"
class AvatarField(
common_serializers.StripExifImageField, VersatileImageFieldSerializer
):
pass
avatar_field = AvatarField(allow_null=True, sizes="square")
class UserBasicSerializer(serializers.ModelSerializer):
avatar = avatar_field
avatar = serializers.SerializerMethodField()
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):
avatar = avatar_field
summary = common_serializers.ContentSerializer(required=False, allow_null=True)
avatar = common_serializers.RelatedField(
"uuid",
queryset=common_models.Attachment.objects.all().local().attached(False),
serializer=None,
queryset_filter=lambda qs, context: qs.filter(
actor=context["request"].user.actor
),
write_only=True,
)
class Meta:
model = models.User
@ -125,19 +129,30 @@ class UserWriteSerializer(serializers.ModelSerializer):
if not obj.actor:
obj.create_actor()
summary = validated_data.pop("summary", NOOP)
avatar = validated_data.pop("avatar", NOOP)
obj = super().update(obj, validated_data)
if summary != NOOP:
common_utils.attach_content(obj.actor, "summary_obj", summary)
if avatar != NOOP:
obj.actor.attachment_icon = avatar
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 = avatar_field
avatar = serializers.SerializerMethodField()
class Meta:
model = models.User
@ -155,6 +170,9 @@ 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()

View File

@ -44,7 +44,7 @@ class PasswordResetConfirmView(rest_auth_views.PasswordResetConfirmView):
class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
queryset = models.User.objects.all()
queryset = models.User.objects.all().select_related("actor__attachment_icon")
serializer_class = serializers.UserWriteSerializer
lookup_field = "username"
lookup_value_regex = r"[a-zA-Z0-9-_.]+"

View File

@ -36,6 +36,11 @@ def test_actor_serializer_from_ap(db):
"id": actor_url + "#main-key",
},
"endpoints": {"sharedInbox": "https://noop.url/federation/shared/inbox"},
"icon": {
"type": "Image",
"mediaType": "image/jpeg",
"href": "https://image.example/image.png",
},
}
serializer = serializers.ActorSerializer(data=payload)
@ -60,6 +65,8 @@ def test_actor_serializer_from_ap(db):
assert actor.private_key is None
assert actor.public_key == payload["publicKey"]["publicKeyPem"]
assert actor.domain_id == "test.federation"
assert actor.attachment_icon.url == payload["icon"]["href"]
assert actor.attachment_icon.mimetype == payload["icon"]["mediaType"]
def test_actor_serializer_only_mandatory_field_from_ap(db):
@ -90,7 +97,7 @@ def test_actor_serializer_only_mandatory_field_from_ap(db):
assert actor.manually_approves_followers is None
def test_actor_serializer_to_ap(db):
def test_actor_serializer_to_ap(factories):
expected = {
"@context": jsonld.get_default_context(),
"id": "https://test.federation/user",
@ -122,12 +129,18 @@ def test_actor_serializer_to_ap(db):
domain=models.Domain.objects.create(pk="test.federation"),
type="Person",
manually_approves_followers=False,
attachment_icon=factories["common.Attachment"](),
)
content = common_utils.attach_content(
ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"}
)
expected["summary"] = content.rendered
expected["icon"] = {
"type": "Image",
"mediaType": "image/jpeg",
"href": utils.full_url(ac.attachment_icon.file.url),
}
serializer = serializers.ActorSerializer(ac)
assert serializer.data == expected
@ -1133,6 +1146,7 @@ def test_local_actor_serializer_to_ap(factories):
domain=models.Domain.objects.create(pk="test.federation"),
type="Person",
manually_approves_followers=False,
attachment_icon=factories["common.Attachment"](),
)
content = common_utils.attach_content(
ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"}
@ -1145,7 +1159,7 @@ def test_local_actor_serializer_to_ap(factories):
expected["icon"] = {
"type": "Image",
"mediaType": "image/jpeg",
"url": utils.full_url(user.avatar.crop["400x400"].url),
"href": utils.full_url(ac.attachment_icon.file.url),
}
serializer = serializers.ActorSerializer(ac)

View File

@ -110,7 +110,9 @@ def test_can_fetch_data_from_api(api_client, factories):
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)
avatar = factories["common.Attachment"]()
user.actor.attachment_icon = avatar
user.actor.save()
api_client.login(username=user.username, password="test")
response = api_client.get(url)
assert response.status_code == 200
@ -120,6 +122,9 @@ 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["avatar"] == common_serializers.AttachmentSerializer(avatar).data
)
assert (
response.data["summary"]
== common_serializers.ContentSerializer(summary_obj).data
@ -301,18 +306,18 @@ def test_user_cannot_patch_another_user(method, logged_in_api_client, factories)
assert response.status_code == 403
def test_user_can_patch_their_own_avatar(logged_in_api_client, avatar):
def test_user_can_patch_their_own_avatar(logged_in_api_client, factories):
user = logged_in_api_client.user
actor = user.create_actor()
attachment = factories["common.Attachment"](actor=actor)
url = reverse("api:v1:users:users-detail", kwargs={"username": user.username})
content = avatar.read()
avatar.seek(0)
payload = {"avatar": avatar}
payload = {"avatar": attachment.uuid}
response = logged_in_api_client.patch(url, payload)
assert response.status_code == 200
user.refresh_from_db()
assert user.avatar.read() == content
assert user.actor.attachment_icon == attachment
def test_creating_user_creates_actor_as_well(

View File

@ -74,7 +74,7 @@
</router-link>
<div class="item">
<div class="ui user-dropdown dropdown" >
<img class="ui avatar image" v-if="$store.state.auth.profile.avatar.square_crop" v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
<img class="ui avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
<actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" />
<div class="menu">
<router-link class="item" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>

View File

@ -41,25 +41,13 @@
<li v-for="error in avatarErrors">{{ error }}</li>
</ul>
</div>
<div class="ui stackable grid">
<div class="ui ten wide column">
<h3 class="ui header"><translate translate-context="Content/Settings/Title/Verb">Upload a new avatar</translate></h3>
<p><translate translate-context="Content/Settings/Paragraph">PNG, GIF or JPG. At most 2MB. Will be downscaled to 400x400px.</translate></p>
<input class="ui input" ref="avatar" type="file" />
<div class="ui hidden divider"></div>
<button @click="submitAvatar" :class="['ui', {'loading': isLoadingAvatar}, 'button']">
<translate translate-context="Content/Settings/Button.Label/Verb">Update avatar</translate>
</button>
</div>
<div class="ui six wide column">
<h3 class="ui header"><translate translate-context="Content/Settings/Title/Noun">Current avatar</translate></h3>
<img class="ui circular image" v-if="currentAvatar && currentAvatar.square_crop" v-lazy="$store.getters['instance/absoluteUrl'](currentAvatar.medium_square_crop)" />
<div class="ui hidden divider"></div>
<button @click="removeAvatar" v-if="currentAvatar && currentAvatar.square_crop" :class="['ui', {'loading': isLoadingAvatar}, ,'yellow', 'button']">
<translate translate-context="Content/Settings/Button.Label/Verb">Remove avatar</translate>
</button>
</div>
</div>
{{ }}
<attachment-input
:value="avatar.uuid"
@input="submitAvatar($event)"
:initial-value="initialAvatar"
:required="false"
@delete="avatar = {uuid: null}"></attachment-input>
</div>
</section>
@ -315,12 +303,14 @@ import logger from "@/logging"
import PasswordInput from "@/components/forms/PasswordInput"
import SubsonicTokenForm from "@/components/auth/SubsonicTokenForm"
import TranslationsMixin from "@/components/mixins/Translations"
import AttachmentInput from '@/components/common/AttachmentInput'
export default {
mixins: [TranslationsMixin],
components: {
PasswordInput,
SubsonicTokenForm
SubsonicTokenForm,
AttachmentInput
},
data() {
let d = {
@ -328,7 +318,7 @@ export default {
// properties that will be used in it
old_password: "",
new_password: "",
currentAvatar: this.$store.state.auth.profile.avatar,
avatar: {...(this.$store.state.auth.profile.avatar || {uuid: null})},
passwordError: "",
password: "",
isLoading: false,
@ -336,7 +326,6 @@ export default {
isDeletingAccount: false,
accountDeleteErrors: [],
avatarErrors: [],
avatar: null,
apps: [],
ownedApps: [],
settings: {
@ -352,6 +341,7 @@ export default {
}
}
}
d.initialAvatar = d.avatar.uuid
d.settings.order.forEach(id => {
d.settings.fields[id].value = d.settings.fields[id].initial
d.settings.fields[id].id = id
@ -437,44 +427,17 @@ export default {
}
)
},
submitAvatar() {
submitAvatar(uuid) {
this.isLoadingAvatar = true
this.avatarErrors = []
let self = this
this.avatar = this.$refs.avatar.files[0]
let formData = new FormData()
formData.append("avatar", this.avatar)
axios
.patch(`users/users/${this.$store.state.auth.username}/`, formData, {
headers: {
"Content-Type": "multipart/form-data"
}
})
.patch(`users/users/${this.$store.state.auth.username}/`, {avatar: uuid})
.then(
response => {
this.isLoadingAvatar = false
self.currentAvatar = response.data.avatar
self.$store.commit("auth/avatar", self.currentAvatar)
},
error => {
self.isLoadingAvatar = false
self.avatarErrors = error.backendErrors
}
)
},
removeAvatar() {
this.isLoadingAvatar = true
let self = this
this.avatar = null
axios
.patch(`users/users/${this.$store.state.auth.username}/`, {
avatar: null
})
.then(
response => {
this.isLoadingAvatar = false
self.currentAvatar = {}
self.$store.commit("auth/avatar", self.currentAvatar)
self.avatar = response.data.avatar
self.$store.commit("auth/avatar", response.data.avatar)
},
error => {
self.isLoadingAvatar = false