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 @@