kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Resolve "An avatar for users"
rodzic
b411df4f29
commit
af270f4abd
|
@ -94,6 +94,7 @@ THIRD_PARTY_APPS = (
|
||||||
"django_filters",
|
"django_filters",
|
||||||
"cacheops",
|
"cacheops",
|
||||||
"django_cleanup",
|
"django_cleanup",
|
||||||
|
"versatileimagefield",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -449,6 +450,7 @@ ACCOUNT_USERNAME_BLACKLIST = [
|
||||||
"superuser",
|
"superuser",
|
||||||
"staff",
|
"staff",
|
||||||
"service",
|
"service",
|
||||||
|
"me",
|
||||||
] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[])
|
] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[])
|
||||||
|
|
||||||
EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True)
|
EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True)
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
|
from django.utils.deconstruct import deconstructible
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import uuid
|
||||||
|
|
||||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
@ -41,3 +45,22 @@ def set_query_parameter(url, **kwargs):
|
||||||
new_query_string = urlencode(query_params, doseq=True)
|
new_query_string = urlencode(query_params, doseq=True)
|
||||||
|
|
||||||
return urlunsplit((scheme, netloc, path, new_query_string, fragment))
|
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)
|
||||||
|
|
|
@ -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)
|
|
@ -1,4 +1,3 @@
|
||||||
import os
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
@ -9,6 +8,7 @@ from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from funkwhale_api.common import session
|
from funkwhale_api.common import session
|
||||||
|
from funkwhale_api.common import utils as common_utils
|
||||||
from funkwhale_api.music import utils as music_utils
|
from funkwhale_api.music import utils as music_utils
|
||||||
|
|
||||||
TYPE_CHOICES = [
|
TYPE_CHOICES = [
|
||||||
|
@ -141,12 +141,7 @@ class Library(models.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_file_path(instance, filename):
|
get_file_path = common_utils.ChunkedPath("federation_cache")
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class LibraryTrack(models.Model):
|
class LibraryTrack(models.Model):
|
||||||
|
|
|
@ -56,7 +56,10 @@ class UserAdmin(AuthUserAdmin):
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("username", "password", "privacy_level")}),
|
(None, {"fields": ("username", "password", "privacy_level")}),
|
||||||
(_("Personal info"), {"fields": ("first_name", "last_name", "email")}),
|
(
|
||||||
|
_("Personal info"),
|
||||||
|
{"fields": ("first_name", "last_name", "email", "avatar")},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
_("Permissions"),
|
_("Permissions"),
|
||||||
{
|
{
|
||||||
|
|
|
@ -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)]),
|
||||||
|
),
|
||||||
|
]
|
|
@ -16,7 +16,11 @@ from django.utils import timezone
|
||||||
from django.utils.encoding import python_2_unicode_compatible
|
from django.utils.encoding import python_2_unicode_compatible
|
||||||
from django.utils.translation import ugettext_lazy as _
|
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 fields, preferences
|
||||||
|
from funkwhale_api.common import utils as common_utils
|
||||||
|
from funkwhale_api.common import validators as common_validators
|
||||||
|
|
||||||
|
|
||||||
def get_token():
|
def get_token():
|
||||||
|
@ -39,6 +43,9 @@ PERMISSIONS_CONFIGURATION = {
|
||||||
PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys())
|
PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys())
|
||||||
|
|
||||||
|
|
||||||
|
get_file_path = common_utils.ChunkedPath("users/avatars", preserve_file_name=False)
|
||||||
|
|
||||||
|
|
||||||
@python_2_unicode_compatible
|
@python_2_unicode_compatible
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
|
|
||||||
|
@ -88,6 +95,19 @@ class User(AbstractUser):
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.SET_NULL,
|
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):
|
def __str__(self):
|
||||||
return self.username
|
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_auth.registration.serializers import RegisterSerializer as RS
|
||||||
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 . import models
|
from . import models
|
||||||
|
@ -49,15 +51,29 @@ class UserBasicSerializer(serializers.ModelSerializer):
|
||||||
fields = ["id", "username", "name", "date_joined"]
|
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):
|
class UserWriteSerializer(serializers.ModelSerializer):
|
||||||
|
avatar = avatar_field
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["name", "privacy_level"]
|
fields = ["name", "privacy_level", "avatar"]
|
||||||
|
|
||||||
|
|
||||||
class UserReadSerializer(serializers.ModelSerializer):
|
class UserReadSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
permissions = serializers.SerializerMethodField()
|
permissions = serializers.SerializerMethodField()
|
||||||
|
avatar = avatar_field
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
|
@ -71,6 +87,7 @@ class UserReadSerializer(serializers.ModelSerializer):
|
||||||
"permissions",
|
"permissions",
|
||||||
"date_joined",
|
"date_joined",
|
||||||
"privacy_level",
|
"privacy_level",
|
||||||
|
"avatar",
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_permissions(self, o):
|
def get_permissions(self, o):
|
||||||
|
|
|
@ -37,7 +37,7 @@ oauth2client<4
|
||||||
google-api-python-client>=1.6,<1.7
|
google-api-python-client>=1.6,<1.7
|
||||||
arrow>=0.12,<0.13
|
arrow>=0.12,<0.13
|
||||||
persisting-theory>=0.2,<0.3
|
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-filter>=1.1,<1.2
|
||||||
django-rest-auth>=0.9,<0.10
|
django-rest-auth>=0.9,<0.10
|
||||||
beautifulsoup4>=4.6,<4.7
|
beautifulsoup4>=4.6,<4.7
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
import io
|
||||||
|
import PIL
|
||||||
|
import random
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
@ -258,3 +261,14 @@ def now(mocker):
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
mocker.patch("django.utils.timezone.now", return_value=now)
|
mocker.patch("django.utils.timezone.now", return_value=now)
|
||||||
return 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)
|
response = handler(url, payload)
|
||||||
|
|
||||||
assert response.status_code == 403
|
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
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Users can now upload an avatar in their settings page (#257)
|
|
@ -39,6 +39,7 @@
|
||||||
<translate :translate-params="{username: $store.state.auth.username}">
|
<translate :translate-params="{username: $store.state.auth.username}">
|
||||||
Logged in as %{ username }
|
Logged in as %{ username }
|
||||||
</translate>
|
</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>
|
||||||
<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-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>
|
<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>
|
</style>
|
||||||
|
|
|
@ -3,19 +3,20 @@
|
||||||
<div v-if="isLoading" class="ui vertical segment">
|
<div v-if="isLoading" class="ui vertical segment">
|
||||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="$store.state.auth.profile">
|
<template v-if="profile">
|
||||||
<div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']">
|
<div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']">
|
||||||
<h2 class="ui center aligned icon header">
|
<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">
|
<div class="content">
|
||||||
{{ $store.state.auth.profile.username }}
|
{{ profile.username }}
|
||||||
<div class="sub header" v-translate="{date: signupDate}">Registered since %{ date }</div>
|
<div class="sub header" v-translate="{date: signupDate}">Registered since %{ date }</div>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="ui basic green label">
|
<div class="ui basic green label">
|
||||||
<translate>This is you!</translate>
|
<translate>This is you!</translate>
|
||||||
</div>
|
</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>
|
<i class="star icon"></i>
|
||||||
<translate>Staff member</translate>
|
<translate>Staff member</translate>
|
||||||
</div>
|
</div>
|
||||||
|
@ -30,15 +31,20 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import {mapState} from 'vuex'
|
||||||
|
|
||||||
const dateFormat = require('dateformat')
|
const dateFormat = require('dateformat')
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'login',
|
|
||||||
props: ['username'],
|
props: ['username'],
|
||||||
created () {
|
created () {
|
||||||
this.$store.dispatch('auth/fetchProfile')
|
this.$store.dispatch('auth/fetchProfile')
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
||||||
|
...mapState({
|
||||||
|
profile: state => state.auth.profile
|
||||||
|
}),
|
||||||
labels () {
|
labels () {
|
||||||
let msg = this.$gettext('%{ username }\'s profile')
|
let msg = this.$gettext('%{ username }\'s profile')
|
||||||
let usernameProfile = this.$gettextInterpolate(msg, {username: this.username})
|
let usernameProfile = this.$gettextInterpolate(msg, {username: this.username})
|
||||||
|
@ -47,11 +53,11 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signupDate () {
|
signupDate () {
|
||||||
let d = new Date(this.$store.state.auth.profile.date_joined)
|
let d = new Date(this.profile.date_joined)
|
||||||
return dateFormat(d, 'longDate')
|
return dateFormat(d, 'longDate')
|
||||||
},
|
},
|
||||||
isLoading () {
|
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 -->
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.ui.header > img.image {
|
||||||
|
width: 8em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -30,6 +30,39 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui hidden divider"></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">
|
<div class="ui small text container">
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<translate>Change my password</translate>
|
<translate>Change my password</translate>
|
||||||
|
@ -97,8 +130,12 @@ export default {
|
||||||
// properties that will be used in it
|
// properties that will be used in it
|
||||||
old_password: '',
|
old_password: '',
|
||||||
new_password: '',
|
new_password: '',
|
||||||
|
currentAvatar: this.$store.state.auth.profile.avatar,
|
||||||
passwordError: '',
|
passwordError: '',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isLoadingAvatar: false,
|
||||||
|
avatarErrors: [],
|
||||||
|
avatar: null,
|
||||||
settings: {
|
settings: {
|
||||||
success: false,
|
success: false,
|
||||||
errors: [],
|
errors: [],
|
||||||
|
@ -147,6 +184,46 @@ export default {
|
||||||
self.settings.errors = error.backendErrors
|
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 () {
|
submitPassword () {
|
||||||
var self = this
|
var self = this
|
||||||
self.isLoading = true
|
self.isLoading = true
|
||||||
|
|
|
@ -47,6 +47,11 @@ export default {
|
||||||
username: (state, value) => {
|
username: (state, value) => {
|
||||||
state.username = value
|
state.username = value
|
||||||
},
|
},
|
||||||
|
avatar: (state, value) => {
|
||||||
|
if (state.profile) {
|
||||||
|
state.profile.avatar = value
|
||||||
|
}
|
||||||
|
},
|
||||||
token: (state, value) => {
|
token: (state, value) => {
|
||||||
state.token = value
|
state.token = value
|
||||||
if (value) {
|
if (value) {
|
||||||
|
|
Ładowanie…
Reference in New Issue