Federation of avatars
This commit is contained in:
parent
b86971c305
commit
f107656586
|
@ -180,6 +180,7 @@ class AttachmentQuerySet(models.QuerySet):
|
||||||
"mutation_attachment",
|
"mutation_attachment",
|
||||||
"covered_track",
|
"covered_track",
|
||||||
"covered_artist",
|
"covered_artist",
|
||||||
|
"iconed_actor",
|
||||||
]
|
]
|
||||||
query = None
|
query = None
|
||||||
for field in related_fields:
|
for field in related_fields:
|
||||||
|
|
|
@ -5,13 +5,9 @@ Compute different sizes of image used for Album covers and User avatars
|
||||||
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
|
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
|
||||||
|
|
||||||
from funkwhale_api.common.models import Attachment
|
from funkwhale_api.common.models import Attachment
|
||||||
from funkwhale_api.music.models import Album
|
|
||||||
from funkwhale_api.users.models import User
|
|
||||||
|
|
||||||
|
|
||||||
MODELS = [
|
MODELS = [
|
||||||
(Album, "cover", "square"),
|
|
||||||
(User, "avatar", "square"),
|
|
||||||
(Attachment, "file", "attachment_square"),
|
(Attachment, "file", "attachment_square"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ class RelatedField(serializers.RelatedField):
|
||||||
self.related_field_name = related_field_name
|
self.related_field_name = related_field_name
|
||||||
self.serializer = serializer
|
self.serializer = serializer
|
||||||
self.filters = kwargs.pop("filters", None)
|
self.filters = kwargs.pop("filters", None)
|
||||||
|
self.queryset_filter = kwargs.pop("queryset_filter", None)
|
||||||
try:
|
try:
|
||||||
kwargs["queryset"] = kwargs.pop("queryset")
|
kwargs["queryset"] = kwargs.pop("queryset")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -36,10 +37,16 @@ class RelatedField(serializers.RelatedField):
|
||||||
filters.update(self.filters(self.context))
|
filters.update(self.filters(self.context))
|
||||||
return filters
|
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):
|
def to_internal_value(self, data):
|
||||||
try:
|
try:
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
filters = self.get_filters(data)
|
filters = self.get_filters(data)
|
||||||
|
queryset = self.filter_queryset(queryset)
|
||||||
return queryset.get(**filters)
|
return queryset.get(**filters)
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
self.fail(
|
self.fail(
|
||||||
|
@ -318,3 +325,16 @@ class ContentSerializer(serializers.Serializer):
|
||||||
|
|
||||||
def get_html(self, o):
|
def get_html(self, o):
|
||||||
return utils.render_html(o.text, o.content_type)
|
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)
|
||||||
|
|
|
@ -327,8 +327,11 @@ def attach_file(obj, field, file_data, fetch=False):
|
||||||
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
|
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
|
||||||
extension = extensions.get(file_data["mimetype"], "jpg")
|
extension = extensions.get(file_data["mimetype"], "jpg")
|
||||||
attachment = models.Attachment(mimetype=file_data["mimetype"])
|
attachment = models.Attachment(mimetype=file_data["mimetype"])
|
||||||
|
name_fields = ["uuid", "full_username", "pk"]
|
||||||
filename = "cover-{}.{}".format(obj.uuid, extension)
|
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:
|
if "url" in file_data:
|
||||||
attachment.url = file_data["url"]
|
attachment.url = file_data["url"]
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -22,7 +22,9 @@ class TrackFavoriteViewSet(
|
||||||
|
|
||||||
filterset_class = filters.TrackFavoriteFilter
|
filterset_class = filters.TrackFavoriteFilter
|
||||||
serializer_class = serializers.UserTrackFavoriteSerializer
|
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 = [
|
permission_classes = [
|
||||||
oauth_permissions.ScopePermission,
|
oauth_permissions.ScopePermission,
|
||||||
permissions.OwnerPermission,
|
permissions.OwnerPermission,
|
||||||
|
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -205,6 +205,13 @@ class Actor(models.Model):
|
||||||
through_fields=("target", "actor"),
|
through_fields=("target", "actor"),
|
||||||
related_name="following",
|
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()
|
objects = ActorQuerySet.as_manager()
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
|
@ -97,6 +95,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
||||||
following = serializers.URLField(max_length=500, required=False, allow_null=True)
|
following = serializers.URLField(max_length=500, required=False, allow_null=True)
|
||||||
publicKey = PublicKeySerializer(required=False)
|
publicKey = PublicKeySerializer(required=False)
|
||||||
endpoints = EndpointsSerializer(required=False)
|
endpoints = EndpointsSerializer(required=False)
|
||||||
|
icon = LinkSerializer(
|
||||||
|
allowed_mimetypes=["image/*"], allow_null=True, required=False
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
jsonld_mapping = {
|
jsonld_mapping = {
|
||||||
|
@ -113,6 +114,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),
|
||||||
}
|
}
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
|
@ -143,17 +145,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
||||||
"id": "{}#main-key".format(instance.fid),
|
"id": "{}#main-key".format(instance.fid),
|
||||||
}
|
}
|
||||||
ret["endpoints"] = {}
|
ret["endpoints"] = {}
|
||||||
|
|
||||||
|
include_image(ret, instance.attachment_icon, "icon")
|
||||||
|
|
||||||
if instance.shared_inbox_url:
|
if instance.shared_inbox_url:
|
||||||
ret["endpoints"]["sharedInbox"] = 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
|
return ret
|
||||||
|
|
||||||
def prepare_missing_fields(self):
|
def prepare_missing_fields(self):
|
||||||
|
@ -201,6 +197,15 @@ class ActorSerializer(jsonld.JsonLdSerializer):
|
||||||
common_utils.attach_content(
|
common_utils.attach_content(
|
||||||
actor, "summary_obj", self.validated_data["summary"]
|
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
|
return actor
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
@ -844,15 +849,15 @@ def include_content(repr, content_obj):
|
||||||
repr["mediaType"] = "text/html"
|
repr["mediaType"] = "text/html"
|
||||||
|
|
||||||
|
|
||||||
def include_image(repr, attachment):
|
def include_image(repr, attachment, field="image"):
|
||||||
if attachment:
|
if attachment:
|
||||||
repr["image"] = {
|
repr[field] = {
|
||||||
"type": "Image",
|
"type": "Image",
|
||||||
"href": attachment.download_url_original,
|
"href": attachment.download_url_original,
|
||||||
"mediaType": attachment.mimetype or "image/jpeg",
|
"mediaType": attachment.mimetype or "image/jpeg",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
repr["image"] = None
|
repr[field] = None
|
||||||
|
|
||||||
|
|
||||||
class MusicEntitySerializer(jsonld.JsonLdSerializer):
|
class MusicEntitySerializer(jsonld.JsonLdSerializer):
|
||||||
|
|
|
@ -19,7 +19,9 @@ class ListeningViewSet(
|
||||||
):
|
):
|
||||||
|
|
||||||
serializer_class = serializers.ListeningSerializer
|
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 = [
|
permission_classes = [
|
||||||
oauth_permissions.ScopePermission,
|
oauth_permissions.ScopePermission,
|
||||||
|
|
|
@ -61,7 +61,9 @@ class DescriptionMutation(mutations.UpdateMutationSerializer):
|
||||||
|
|
||||||
class CoverMutation(mutations.UpdateMutationSerializer):
|
class CoverMutation(mutations.UpdateMutationSerializer):
|
||||||
cover = common_serializers.RelatedField(
|
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):
|
def get_serialized_relations(self):
|
||||||
|
|
|
@ -18,20 +18,9 @@ from funkwhale_api.tags import serializers as tags_serializers
|
||||||
from . import filters, models, tasks
|
from . import filters, models, tasks
|
||||||
|
|
||||||
|
|
||||||
class NullToEmptDict(object):
|
class CoverField(
|
||||||
def get_attribute(self, o):
|
common_serializers.NullToEmptDict, common_serializers.AttachmentSerializer
|
||||||
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):
|
|
||||||
# XXX: BACKWARD COMPATIBILITY
|
# XXX: BACKWARD COMPATIBILITY
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ class PlaylistViewSet(
|
||||||
serializer_class = serializers.PlaylistSerializer
|
serializer_class = serializers.PlaylistSerializer
|
||||||
queryset = (
|
queryset = (
|
||||||
models.Playlist.objects.all()
|
models.Playlist.objects.all()
|
||||||
.select_related("user__actor")
|
.select_related("user__actor__attachment_icon")
|
||||||
.annotate(tracks_count=Count("playlist_tracks", distinct=True))
|
.annotate(tracks_count=Count("playlist_tracks", distinct=True))
|
||||||
.with_covers()
|
.with_covers()
|
||||||
.with_duration()
|
.with_duration()
|
||||||
|
|
|
@ -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)]
|
|
@ -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 models as oauth2_models
|
||||||
from oauth2_provider import validators as oauth2_validators
|
from oauth2_provider import validators as oauth2_validators
|
||||||
from versatileimagefield.fields import VersatileImageField
|
from versatileimagefield.fields import VersatileImageField
|
||||||
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
|
|
||||||
|
|
||||||
from funkwhale_api.common import fields, preferences
|
from funkwhale_api.common import fields, preferences
|
||||||
from funkwhale_api.common import utils as common_utils
|
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):
|
def init_ldap_user(sender, user, ldap_user, **kwargs):
|
||||||
if not user.actor:
|
if not user.actor:
|
||||||
user.actor = create_actor(user)
|
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()
|
|
||||||
|
|
|
@ -7,9 +7,9 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from rest_auth.serializers import PasswordResetSerializer as PRS
|
from rest_auth.serializers import PasswordResetSerializer as PRS
|
||||||
from rest_auth.registration.serializers import RegisterSerializer as RS, get_adapter
|
from rest_auth.registration.serializers import RegisterSerializer as RS, get_adapter
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from versatileimagefield.serializers import VersatileImageFieldSerializer
|
|
||||||
|
|
||||||
from funkwhale_api.activity import serializers as activity_serializers
|
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 serializers as common_serializers
|
||||||
from funkwhale_api.common import utils as common_utils
|
from funkwhale_api.common import utils as common_utils
|
||||||
from funkwhale_api.federation import models as federation_models
|
from funkwhale_api.federation import models as federation_models
|
||||||
|
@ -89,26 +89,30 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
|
||||||
return "Person"
|
return "Person"
|
||||||
|
|
||||||
|
|
||||||
class AvatarField(
|
|
||||||
common_serializers.StripExifImageField, VersatileImageFieldSerializer
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
avatar_field = AvatarField(allow_null=True, sizes="square")
|
|
||||||
|
|
||||||
|
|
||||||
class UserBasicSerializer(serializers.ModelSerializer):
|
class UserBasicSerializer(serializers.ModelSerializer):
|
||||||
avatar = avatar_field
|
avatar = serializers.SerializerMethodField()
|
||||||
|
|
||||||
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):
|
||||||
avatar = avatar_field
|
|
||||||
summary = common_serializers.ContentSerializer(required=False, allow_null=True)
|
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:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
|
@ -125,19 +129,30 @@ class UserWriteSerializer(serializers.ModelSerializer):
|
||||||
if not obj.actor:
|
if not obj.actor:
|
||||||
obj.create_actor()
|
obj.create_actor()
|
||||||
summary = validated_data.pop("summary", NOOP)
|
summary = validated_data.pop("summary", NOOP)
|
||||||
|
avatar = validated_data.pop("avatar", NOOP)
|
||||||
|
|
||||||
obj = super().update(obj, validated_data)
|
obj = super().update(obj, validated_data)
|
||||||
|
|
||||||
if summary != NOOP:
|
if summary != NOOP:
|
||||||
common_utils.attach_content(obj.actor, "summary_obj", summary)
|
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
|
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):
|
class UserReadSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
permissions = serializers.SerializerMethodField()
|
permissions = serializers.SerializerMethodField()
|
||||||
full_username = serializers.SerializerMethodField()
|
full_username = serializers.SerializerMethodField()
|
||||||
avatar = avatar_field
|
avatar = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
|
@ -155,6 +170,9 @@ class UserReadSerializer(serializers.ModelSerializer):
|
||||||
"avatar",
|
"avatar",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_avatar(self, o):
|
||||||
|
return common_serializers.AttachmentSerializer(o.actor.attachment_icon).data
|
||||||
|
|
||||||
def get_permissions(self, o):
|
def get_permissions(self, o):
|
||||||
return o.get_permissions()
|
return o.get_permissions()
|
||||||
|
|
||||||
|
|
|
@ -44,7 +44,7 @@ class PasswordResetConfirmView(rest_auth_views.PasswordResetConfirmView):
|
||||||
|
|
||||||
|
|
||||||
class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
|
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
|
serializer_class = serializers.UserWriteSerializer
|
||||||
lookup_field = "username"
|
lookup_field = "username"
|
||||||
lookup_value_regex = r"[a-zA-Z0-9-_.]+"
|
lookup_value_regex = r"[a-zA-Z0-9-_.]+"
|
||||||
|
|
|
@ -36,6 +36,11 @@ def test_actor_serializer_from_ap(db):
|
||||||
"id": actor_url + "#main-key",
|
"id": actor_url + "#main-key",
|
||||||
},
|
},
|
||||||
"endpoints": {"sharedInbox": "https://noop.url/federation/shared/inbox"},
|
"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)
|
serializer = serializers.ActorSerializer(data=payload)
|
||||||
|
@ -60,6 +65,8 @@ def test_actor_serializer_from_ap(db):
|
||||||
assert actor.private_key is None
|
assert actor.private_key is None
|
||||||
assert actor.public_key == payload["publicKey"]["publicKeyPem"]
|
assert actor.public_key == payload["publicKey"]["publicKeyPem"]
|
||||||
assert actor.domain_id == "test.federation"
|
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):
|
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
|
assert actor.manually_approves_followers is None
|
||||||
|
|
||||||
|
|
||||||
def test_actor_serializer_to_ap(db):
|
def test_actor_serializer_to_ap(factories):
|
||||||
expected = {
|
expected = {
|
||||||
"@context": jsonld.get_default_context(),
|
"@context": jsonld.get_default_context(),
|
||||||
"id": "https://test.federation/user",
|
"id": "https://test.federation/user",
|
||||||
|
@ -122,12 +129,18 @@ def test_actor_serializer_to_ap(db):
|
||||||
domain=models.Domain.objects.create(pk="test.federation"),
|
domain=models.Domain.objects.create(pk="test.federation"),
|
||||||
type="Person",
|
type="Person",
|
||||||
manually_approves_followers=False,
|
manually_approves_followers=False,
|
||||||
|
attachment_icon=factories["common.Attachment"](),
|
||||||
)
|
)
|
||||||
|
|
||||||
content = common_utils.attach_content(
|
content = common_utils.attach_content(
|
||||||
ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"}
|
ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"}
|
||||||
)
|
)
|
||||||
expected["summary"] = content.rendered
|
expected["summary"] = content.rendered
|
||||||
|
expected["icon"] = {
|
||||||
|
"type": "Image",
|
||||||
|
"mediaType": "image/jpeg",
|
||||||
|
"href": utils.full_url(ac.attachment_icon.file.url),
|
||||||
|
}
|
||||||
serializer = serializers.ActorSerializer(ac)
|
serializer = serializers.ActorSerializer(ac)
|
||||||
|
|
||||||
assert serializer.data == expected
|
assert serializer.data == expected
|
||||||
|
@ -1133,6 +1146,7 @@ def test_local_actor_serializer_to_ap(factories):
|
||||||
domain=models.Domain.objects.create(pk="test.federation"),
|
domain=models.Domain.objects.create(pk="test.federation"),
|
||||||
type="Person",
|
type="Person",
|
||||||
manually_approves_followers=False,
|
manually_approves_followers=False,
|
||||||
|
attachment_icon=factories["common.Attachment"](),
|
||||||
)
|
)
|
||||||
content = common_utils.attach_content(
|
content = common_utils.attach_content(
|
||||||
ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"}
|
ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"}
|
||||||
|
@ -1145,7 +1159,7 @@ def test_local_actor_serializer_to_ap(factories):
|
||||||
expected["icon"] = {
|
expected["icon"] = {
|
||||||
"type": "Image",
|
"type": "Image",
|
||||||
"mediaType": "image/jpeg",
|
"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)
|
serializer = serializers.ActorSerializer(ac)
|
||||||
|
|
||||||
|
|
|
@ -110,7 +110,9 @@ def test_can_fetch_data_from_api(api_client, factories):
|
||||||
user = factories["users.User"](permission_library=True, with_actor=True)
|
user = factories["users.User"](permission_library=True, with_actor=True)
|
||||||
summary = {"content_type": "text/plain", "text": "Hello"}
|
summary = {"content_type": "text/plain", "text": "Hello"}
|
||||||
summary_obj = common_utils.attach_content(user.actor, "summary_obj", summary)
|
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")
|
api_client.login(username=user.username, password="test")
|
||||||
response = api_client.get(url)
|
response = api_client.get(url)
|
||||||
assert response.status_code == 200
|
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["email"] == user.email
|
||||||
assert response.data["name"] == user.name
|
assert response.data["name"] == user.name
|
||||||
assert response.data["permissions"] == user.get_permissions()
|
assert response.data["permissions"] == user.get_permissions()
|
||||||
|
assert (
|
||||||
|
response.data["avatar"] == common_serializers.AttachmentSerializer(avatar).data
|
||||||
|
)
|
||||||
assert (
|
assert (
|
||||||
response.data["summary"]
|
response.data["summary"]
|
||||||
== common_serializers.ContentSerializer(summary_obj).data
|
== 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
|
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
|
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})
|
url = reverse("api:v1:users:users-detail", kwargs={"username": user.username})
|
||||||
content = avatar.read()
|
payload = {"avatar": attachment.uuid}
|
||||||
avatar.seek(0)
|
|
||||||
payload = {"avatar": avatar}
|
|
||||||
response = logged_in_api_client.patch(url, payload)
|
response = logged_in_api_client.patch(url, payload)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user.refresh_from_db()
|
user.refresh_from_db()
|
||||||
|
|
||||||
assert user.avatar.read() == content
|
assert user.actor.attachment_icon == attachment
|
||||||
|
|
||||||
|
|
||||||
def test_creating_user_creates_actor_as_well(
|
def test_creating_user_creates_actor_as_well(
|
||||||
|
|
|
@ -74,7 +74,7 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="ui user-dropdown dropdown" >
|
<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}" />
|
<actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" />
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<router-link class="item" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>
|
<router-link class="item" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>
|
||||||
|
|
|
@ -41,25 +41,13 @@
|
||||||
<li v-for="error in avatarErrors">{{ error }}</li>
|
<li v-for="error in avatarErrors">{{ error }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui stackable grid">
|
{{ }}
|
||||||
<div class="ui ten wide column">
|
<attachment-input
|
||||||
<h3 class="ui header"><translate translate-context="Content/Settings/Title/Verb">Upload a new avatar</translate></h3>
|
:value="avatar.uuid"
|
||||||
<p><translate translate-context="Content/Settings/Paragraph">PNG, GIF or JPG. At most 2MB. Will be downscaled to 400x400px.</translate></p>
|
@input="submitAvatar($event)"
|
||||||
<input class="ui input" ref="avatar" type="file" />
|
:initial-value="initialAvatar"
|
||||||
<div class="ui hidden divider"></div>
|
:required="false"
|
||||||
<button @click="submitAvatar" :class="['ui', {'loading': isLoadingAvatar}, 'button']">
|
@delete="avatar = {uuid: null}"></attachment-input>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -315,12 +303,14 @@ import logger from "@/logging"
|
||||||
import PasswordInput from "@/components/forms/PasswordInput"
|
import PasswordInput from "@/components/forms/PasswordInput"
|
||||||
import SubsonicTokenForm from "@/components/auth/SubsonicTokenForm"
|
import SubsonicTokenForm from "@/components/auth/SubsonicTokenForm"
|
||||||
import TranslationsMixin from "@/components/mixins/Translations"
|
import TranslationsMixin from "@/components/mixins/Translations"
|
||||||
|
import AttachmentInput from '@/components/common/AttachmentInput'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [TranslationsMixin],
|
mixins: [TranslationsMixin],
|
||||||
components: {
|
components: {
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
SubsonicTokenForm
|
SubsonicTokenForm,
|
||||||
|
AttachmentInput
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
let d = {
|
let d = {
|
||||||
|
@ -328,7 +318,7 @@ export default {
|
||||||
// properties that will be used in it
|
// properties that will be used in it
|
||||||
old_password: "",
|
old_password: "",
|
||||||
new_password: "",
|
new_password: "",
|
||||||
currentAvatar: this.$store.state.auth.profile.avatar,
|
avatar: {...(this.$store.state.auth.profile.avatar || {uuid: null})},
|
||||||
passwordError: "",
|
passwordError: "",
|
||||||
password: "",
|
password: "",
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
@ -336,7 +326,6 @@ export default {
|
||||||
isDeletingAccount: false,
|
isDeletingAccount: false,
|
||||||
accountDeleteErrors: [],
|
accountDeleteErrors: [],
|
||||||
avatarErrors: [],
|
avatarErrors: [],
|
||||||
avatar: null,
|
|
||||||
apps: [],
|
apps: [],
|
||||||
ownedApps: [],
|
ownedApps: [],
|
||||||
settings: {
|
settings: {
|
||||||
|
@ -352,6 +341,7 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
d.initialAvatar = d.avatar.uuid
|
||||||
d.settings.order.forEach(id => {
|
d.settings.order.forEach(id => {
|
||||||
d.settings.fields[id].value = d.settings.fields[id].initial
|
d.settings.fields[id].value = d.settings.fields[id].initial
|
||||||
d.settings.fields[id].id = id
|
d.settings.fields[id].id = id
|
||||||
|
@ -437,44 +427,17 @@ export default {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
submitAvatar() {
|
submitAvatar(uuid) {
|
||||||
this.isLoadingAvatar = true
|
this.isLoadingAvatar = true
|
||||||
this.avatarErrors = []
|
this.avatarErrors = []
|
||||||
let self = this
|
let self = this
|
||||||
this.avatar = this.$refs.avatar.files[0]
|
|
||||||
let formData = new FormData()
|
|
||||||
formData.append("avatar", this.avatar)
|
|
||||||
axios
|
axios
|
||||||
.patch(`users/users/${this.$store.state.auth.username}/`, formData, {
|
.patch(`users/users/${this.$store.state.auth.username}/`, {avatar: uuid})
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(
|
.then(
|
||||||
response => {
|
response => {
|
||||||
this.isLoadingAvatar = false
|
this.isLoadingAvatar = false
|
||||||
self.currentAvatar = response.data.avatar
|
self.avatar = response.data.avatar
|
||||||
self.$store.commit("auth/avatar", self.currentAvatar)
|
self.$store.commit("auth/avatar", response.data.avatar)
|
||||||
},
|
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
error => {
|
error => {
|
||||||
self.isLoadingAvatar = false
|
self.isLoadingAvatar = false
|
||||||
|
|
Loading…
Reference in New Issue