Commit af270f4a authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Resolve "An avatar for users"

parent b411df4f
......@@ -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)
......
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)
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)
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):
......
......@@ -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"),
{
......
# 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)]),
),
]
......@@ -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
......
......@@ -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):
......
......@@ -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
......
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()
......@@ -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
Users can now upload an avatar in their settings page (#257)
......@@ -39,6 +39,7 @@
<translate :translate-params="{username: $store.state.auth.username}">
Logged in as %{ username }
</translate>
<img class="ui avatar right floated circular mini image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
</router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate>Logout</translate></router-link>
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i><translate>Login</translate></router-link>
......@@ -432,4 +433,8 @@ $sidebar-color: #3d3e3f;
}
}
}
.avatar {
position: relative;
top: -0.5em;
}
</style>
......@@ -3,19 +3,20 @@
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="$store.state.auth.profile">
<template v-if="profile">
<div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']">
<h2 class="ui center aligned icon header">
<i class="circular inverted user green icon"></i>
<i v-if="!profile.avatar.square_crop" class="circular inverted user green icon"></i>
<img class="ui big circular image" v-else :src="$store.getters['instance/absoluteUrl'](profile.avatar.square_crop)" />
<div class="content">
{{ $store.state.auth.profile.username }}
{{ profile.username }}
<div class="sub header" v-translate="{date: signupDate}">Registered since %{ date }</div>
</div>
</h2>
<div class="ui basic green label">
<translate>This is you!</translate>
</div>
<div v-if="$store.state.auth.profile.is_staff" class="ui yellow label">
<div v-if="profile.is_staff" class="ui yellow label">
<i class="star icon"></i>
<translate>Staff member</translate>
</div>
......@@ -30,15 +31,20 @@
</template>
<script>
import {mapState} from 'vuex'
const dateFormat = require('dateformat')
export default {
name: 'login',
props: ['username'],
created () {
this.$store.dispatch('auth/fetchProfile')
},
computed: {
...mapState({
profile: state => state.auth.profile
}),
labels () {
let msg = this.$gettext('%{ username }\'s profile')
let usernameProfile = this.$gettextInterpolate(msg, {username: this.username})
......@@ -47,11 +53,11 @@ export default {
}
},
signupDate () {
let d = new Date(this.$store.state.auth.profile.date_joined)
let d = new Date(this.profile.date_joined)
return dateFormat(d, 'longDate')
},
isLoading () {
return !this.$store.state.auth.profile
return !this.profile
}
}
}
......@@ -59,4 +65,7 @@ export default {
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.ui.header > img.image {
width: 8em;
}
</style>
......@@ -30,6 +30,39 @@
</form>
</div>
<div class="ui hidden divider"></div>
<div class="ui small text container">
<h2 class="ui header">
<translate>Avatar</translate>
</h2>
<div class="ui form">
<div v-if="avatarErrors.length > 0" class="ui negative message">
<div class="header"><translate>We cannot save your avatar</translate></div>
<ul class="list">
<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>Upload a new avatar</translate></h3>
<p><translate>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>Update avatar</translate>
</button>
</div>
<div class="ui six wide column">
<h3 class="ui header"><translate>Current avatar</translate></h3>
<img class="ui circular image" v-if="currentAvatar && currentAvatar.square_crop" :src="$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>Remove avatar</translate>
</button>
</div>
</div>
</div>
</div>
<div class="ui hidden divider"></div>
<div class="ui small text container">
<h2 class="ui header">
<translate>Change my password</translate>
......@@ -97,8 +130,12 @@ export default {
// properties that will be used in it
old_password: '',
new_password: '',
currentAvatar: this.$store.state.auth.profile.avatar,
passwordError: '',
isLoading: false,
isLoadingAvatar: false,
avatarErrors: [],
avatar: null,
settings: {
success: false,
errors: [],
......@@ -147,6 +184,46 @@ export default {
self.settings.errors = error.backendErrors
})
},
submitAvatar () {
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'
}
}
).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)
}, error => {
self.isLoadingAvatar = false
self.avatarErrors = error.backendErrors
})
},
submitPassword () {
var self = this
self.isLoading = true
......
......@@ -47,6 +47,11 @@ export default {
username: (state, value) => {
state.username = value
},
avatar: (state, value) => {
if (state.profile) {
state.profile.avatar = value
}
},
token: (state, value) => {
state.token = value
if (value) {
......