Verified Commit f1076565 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Federation of avatars

parent b86971c3
...@@ -180,6 +180,7 @@ class AttachmentQuerySet(models.QuerySet): ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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( ...@@ -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,
......
# 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): ...@@ -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()
......
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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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( ...@@ -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): ...@@ -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 ...@@ -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( ...@@ -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()
......
# -*- 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 ...@@ -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): ...@@ -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 _ ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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"