From af270f4abdf15412165b00c39e7272ba45f92c3f Mon Sep 17 00:00:00 2001 From: Eliot Berriot Date: Fri, 13 Jul 2018 12:10:39 +0000 Subject: [PATCH] Resolve "An avatar for users" --- api/config/settings/common.py | 2 + api/funkwhale_api/common/utils.py | 23 +++ api/funkwhale_api/common/validators.py | 152 ++++++++++++++++++ api/funkwhale_api/federation/models.py | 9 +- api/funkwhale_api/users/admin.py | 5 +- .../users/migrations/0010_user_avatar.py | 20 +++ api/funkwhale_api/users/models.py | 20 +++ api/funkwhale_api/users/serializers.py | 19 ++- api/requirements/base.txt | 2 +- api/tests/conftest.py | 14 ++ api/tests/users/test_views.py | 14 ++ changes/changelog.d/257.feature | 1 + front/src/components/Sidebar.vue | 5 + front/src/components/auth/Profile.vue | 23 ++- front/src/components/auth/Settings.vue | 77 +++++++++ front/src/store/auth.js | 5 + 16 files changed, 374 insertions(+), 17 deletions(-) create mode 100644 api/funkwhale_api/common/validators.py create mode 100644 api/funkwhale_api/users/migrations/0010_user_avatar.py create mode 100644 changes/changelog.d/257.feature diff --git a/api/config/settings/common.py b/api/config/settings/common.py index b74c2bdfe..c789c36af 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -94,6 +94,7 @@ THIRD_PARTY_APPS = ( "django_filters", "cacheops", "django_cleanup", + "versatileimagefield", ) @@ -449,6 +450,7 @@ ACCOUNT_USERNAME_BLACKLIST = [ "superuser", "staff", "service", + "me", ] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[]) EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True) diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 221d2336b..bba4702b0 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -1,5 +1,9 @@ +from django.utils.deconstruct import deconstructible + import os import shutil +import uuid + from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from django.db import transaction @@ -41,3 +45,22 @@ def set_query_parameter(url, **kwargs): new_query_string = urlencode(query_params, doseq=True) return urlunsplit((scheme, netloc, path, new_query_string, fragment)) + + +@deconstructible +class ChunkedPath(object): + def __init__(self, root, preserve_file_name=True): + self.root = root + self.preserve_file_name = preserve_file_name + + def __call__(self, instance, filename): + uid = str(uuid.uuid4()) + chunk_size = 2 + chunks = [uid[i : i + chunk_size] for i in range(0, len(uid), chunk_size)] + if self.preserve_file_name: + parts = chunks[:3] + [filename] + else: + ext = os.path.splitext(filename)[1][1:].lower() + new_filename = "".join(chunks[3:]) + ".{}".format(ext) + parts = chunks[:3] + [new_filename] + return os.path.join(self.root, *parts) diff --git a/api/funkwhale_api/common/validators.py b/api/funkwhale_api/common/validators.py new file mode 100644 index 000000000..b5f26cac5 --- /dev/null +++ b/api/funkwhale_api/common/validators.py @@ -0,0 +1,152 @@ +import mimetypes +from os.path import splitext + +from django.core.exceptions import ValidationError +from django.core.files.images import get_image_dimensions +from django.template.defaultfilters import filesizeformat +from django.utils.deconstruct import deconstructible +from django.utils.translation import ugettext_lazy as _ + + +@deconstructible +class ImageDimensionsValidator: + """ + ImageField dimensions validator. + + from https://gist.github.com/emilio-rst/4f81ea2718736a6aaf9bdb64d5f2ea6c + """ + + def __init__( + self, + width=None, + height=None, + min_width=None, + max_width=None, + min_height=None, + max_height=None, + ): + """ + Constructor + + Args: + width (int): exact width + height (int): exact height + min_width (int): minimum width + min_height (int): minimum height + max_width (int): maximum width + max_height (int): maximum height + """ + + self.width = width + self.height = height + self.min_width = min_width + self.max_width = max_width + self.min_height = min_height + self.max_height = max_height + + def __call__(self, image): + w, h = get_image_dimensions(image) + + if self.width is not None and w != self.width: + raise ValidationError(_("Width must be %dpx.") % (self.width,)) + + if self.height is not None and h != self.height: + raise ValidationError(_("Height must be %dpx.") % (self.height,)) + + if self.min_width is not None and w < self.min_width: + raise ValidationError(_("Minimum width must be %dpx.") % (self.min_width,)) + + if self.min_height is not None and h < self.min_height: + raise ValidationError( + _("Minimum height must be %dpx.") % (self.min_height,) + ) + + if self.max_width is not None and w > self.max_width: + raise ValidationError(_("Maximum width must be %dpx.") % (self.max_width,)) + + if self.max_height is not None and h > self.max_height: + raise ValidationError( + _("Maximum height must be %dpx.") % (self.max_height,) + ) + + +@deconstructible +class FileValidator(object): + """ + Taken from https://gist.github.com/jrosebr1/2140738 + Validator for files, checking the size, extension and mimetype. + Initialization parameters: + allowed_extensions: iterable with allowed file extensions + ie. ('txt', 'doc') + allowd_mimetypes: iterable with allowed mimetypes + ie. ('image/png', ) + min_size: minimum number of bytes allowed + ie. 100 + max_size: maximum number of bytes allowed + ie. 24*1024*1024 for 24 MB + Usage example:: + MyModel(models.Model): + myfile = FileField(validators=FileValidator(max_size=24*1024*1024), ...) + """ + + extension_message = _( + "Extension '%(extension)s' not allowed. Allowed extensions are: '%(allowed_extensions)s.'" + ) + mime_message = _( + "MIME type '%(mimetype)s' is not valid. Allowed types are: %(allowed_mimetypes)s." + ) + min_size_message = _( + "The current file %(size)s, which is too small. The minumum file size is %(allowed_size)s." + ) + max_size_message = _( + "The current file %(size)s, which is too large. The maximum file size is %(allowed_size)s." + ) + + def __init__(self, *args, **kwargs): + self.allowed_extensions = kwargs.pop("allowed_extensions", None) + self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", None) + self.min_size = kwargs.pop("min_size", 0) + self.max_size = kwargs.pop("max_size", None) + + def __call__(self, value): + """ + Check the extension, content type and file size. + """ + + # Check the extension + ext = splitext(value.name)[1][1:].lower() + if self.allowed_extensions and ext not in self.allowed_extensions: + message = self.extension_message % { + "extension": ext, + "allowed_extensions": ", ".join(self.allowed_extensions), + } + + raise ValidationError(message) + + # Check the content type + mimetype = mimetypes.guess_type(value.name)[0] + if self.allowed_mimetypes and mimetype not in self.allowed_mimetypes: + message = self.mime_message % { + "mimetype": mimetype, + "allowed_mimetypes": ", ".join(self.allowed_mimetypes), + } + + raise ValidationError(message) + + # Check the file size + filesize = len(value) + if self.max_size and filesize > self.max_size: + message = self.max_size_message % { + "size": filesizeformat(filesize), + "allowed_size": filesizeformat(self.max_size), + } + + raise ValidationError(message) + + elif filesize < self.min_size: + message = self.min_size_message % { + "size": filesizeformat(filesize), + "allowed_size": filesizeformat(self.min_size), + } + + raise ValidationError(message) diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 979b0674a..1d80395fe 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -1,4 +1,3 @@ -import os import tempfile import uuid @@ -9,6 +8,7 @@ from django.db import models from django.utils import timezone from funkwhale_api.common import session +from funkwhale_api.common import utils as common_utils from funkwhale_api.music import utils as music_utils TYPE_CHOICES = [ @@ -141,12 +141,7 @@ class Library(models.Model): ) -def get_file_path(instance, filename): - uid = str(uuid.uuid4()) - chunk_size = 2 - chunks = [uid[i : i + chunk_size] for i in range(0, len(uid), chunk_size)] - parts = chunks[:3] + [filename] - return os.path.join("federation_cache", *parts) +get_file_path = common_utils.ChunkedPath("federation_cache") class LibraryTrack(models.Model): diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py index 205c7c367..365db615e 100644 --- a/api/funkwhale_api/users/admin.py +++ b/api/funkwhale_api/users/admin.py @@ -56,7 +56,10 @@ class UserAdmin(AuthUserAdmin): fieldsets = ( (None, {"fields": ("username", "password", "privacy_level")}), - (_("Personal info"), {"fields": ("first_name", "last_name", "email")}), + ( + _("Personal info"), + {"fields": ("first_name", "last_name", "email", "avatar")}, + ), ( _("Permissions"), { diff --git a/api/funkwhale_api/users/migrations/0010_user_avatar.py b/api/funkwhale_api/users/migrations/0010_user_avatar.py new file mode 100644 index 000000000..da60439be --- /dev/null +++ b/api/funkwhale_api/users/migrations/0010_user_avatar.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.6 on 2018-07-10 20:09 + +from django.db import migrations, models +import funkwhale_api.common.utils +import funkwhale_api.common.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0009_auto_20180619_2024'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='avatar', + field=models.ImageField(blank=True, max_length=150, null=True, upload_to=funkwhale_api.common.utils.ChunkedPath('users/avatars'), validators=[funkwhale_api.common.validators.ImageDimensionsValidator(max_height=400, max_width=400, min_height=50, min_width=50)]), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index ec9c39fd6..a56406d8b 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -16,7 +16,11 @@ from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ +from versatileimagefield.fields import VersatileImageField + from funkwhale_api.common import fields, preferences +from funkwhale_api.common import utils as common_utils +from funkwhale_api.common import validators as common_validators def get_token(): @@ -39,6 +43,9 @@ PERMISSIONS_CONFIGURATION = { PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys()) +get_file_path = common_utils.ChunkedPath("users/avatars", preserve_file_name=False) + + @python_2_unicode_compatible class User(AbstractUser): @@ -88,6 +95,19 @@ class User(AbstractUser): blank=True, on_delete=models.SET_NULL, ) + avatar = VersatileImageField( + upload_to=get_file_path, + null=True, + blank=True, + max_length=150, + validators=[ + common_validators.ImageDimensionsValidator(min_width=50, min_height=50), + common_validators.FileValidator( + allowed_extensions=["png", "jpg", "jpeg", "gif"], + max_size=1024 * 1024 * 2, + ), + ], + ) def __str__(self): return self.username diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 438951265..fd007e234 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -3,6 +3,8 @@ from rest_auth.serializers import PasswordResetSerializer as PRS from rest_auth.registration.serializers import RegisterSerializer as RS from rest_framework import serializers +from versatileimagefield.serializers import VersatileImageFieldSerializer + from funkwhale_api.activity import serializers as activity_serializers from . import models @@ -49,15 +51,29 @@ class UserBasicSerializer(serializers.ModelSerializer): fields = ["id", "username", "name", "date_joined"] +avatar_field = VersatileImageFieldSerializer( + allow_null=True, + sizes=[ + ("original", "url"), + ("square_crop", "crop__400x400"), + ("medium_square_crop", "crop__200x200"), + ("small_square_crop", "crop__50x50"), + ], +) + + class UserWriteSerializer(serializers.ModelSerializer): + avatar = avatar_field + class Meta: model = models.User - fields = ["name", "privacy_level"] + fields = ["name", "privacy_level", "avatar"] class UserReadSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField() + avatar = avatar_field class Meta: model = models.User @@ -71,6 +87,7 @@ class UserReadSerializer(serializers.ModelSerializer): "permissions", "date_joined", "privacy_level", + "avatar", ] def get_permissions(self, o): diff --git a/api/requirements/base.txt b/api/requirements/base.txt index 13c0efdbc..ed179a897 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -37,7 +37,7 @@ oauth2client<4 google-api-python-client>=1.6,<1.7 arrow>=0.12,<0.13 persisting-theory>=0.2,<0.3 -django-versatileimagefield>=1.8,<1.9 +django-versatileimagefield>=1.9,<1.10 django-filter>=1.1,<1.2 django-rest-auth>=0.9,<0.10 beautifulsoup4>=4.6,<4.7 diff --git a/api/tests/conftest.py b/api/tests/conftest.py index aa36e1f76..fc7e11947 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,4 +1,7 @@ import datetime +import io +import PIL +import random import shutil import tempfile @@ -258,3 +261,14 @@ def now(mocker): now = timezone.now() mocker.patch("django.utils.timezone.now", return_value=now) return now + + +@pytest.fixture() +def avatar(): + i = PIL.Image.new("RGBA", (400, 400), random.choice(["red", "blue", "yellow"])) + f = io.BytesIO() + i.save(f, "png") + f.name = "avatar.png" + f.seek(0) + yield f + f.close() diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index fca66d302..9bea4ced3 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -235,3 +235,17 @@ def test_user_cannot_patch_another_user(method, logged_in_api_client, factories) response = handler(url, payload) assert response.status_code == 403 + + +def test_user_can_patch_their_own_avatar(logged_in_api_client, avatar): + user = logged_in_api_client.user + url = reverse("api:v1:users:users-detail", kwargs={"username": user.username}) + content = avatar.read() + avatar.seek(0) + payload = {"avatar": avatar} + response = logged_in_api_client.patch(url, payload) + + assert response.status_code == 200 + user.refresh_from_db() + + assert user.avatar.read() == content diff --git a/changes/changelog.d/257.feature b/changes/changelog.d/257.feature new file mode 100644 index 000000000..c2e25ff32 --- /dev/null +++ b/changes/changelog.d/257.feature @@ -0,0 +1 @@ +Users can now upload an avatar in their settings page (#257) diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 4765823f7..938a48070 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -39,6 +39,7 @@ Logged in as %{ username } + Logout Login @@ -432,4 +433,8 @@ $sidebar-color: #3d3e3f; } } } +.avatar { + position: relative; + top: -0.5em; +} diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue index 556a9a67e..23af78c61 100644 --- a/front/src/components/auth/Profile.vue +++ b/front/src/components/auth/Profile.vue @@ -3,19 +3,20 @@
-