Skip to content
Snippets Groups Projects
Commit af270f4a authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Resolve "An avatar for users"

parent b411df4f
No related branches found
No related tags found
No related merge requests found
Showing
with 374 additions and 17 deletions
......@@ -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) {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment