diff --git a/wagtail/admin/static_src/wagtailadmin/images/default-user-avatar.svg b/wagtail/admin/static_src/wagtailadmin/images/default-user-avatar.svg new file mode 100644 index 0000000000..9657f3ae04 --- /dev/null +++ b/wagtail/admin/static_src/wagtailadmin/images/default-user-avatar.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/wagtail/admin/templates/wagtailadmin/account/change_avatar.html b/wagtail/admin/templates/wagtailadmin/account/change_avatar.html new file mode 100644 index 0000000000..db160d559d --- /dev/null +++ b/wagtail/admin/templates/wagtailadmin/account/change_avatar.html @@ -0,0 +1,27 @@ +{% extends "wagtailadmin/base.html" %} +{% load avatar i18n %} + +{% block titletag %}{% trans "Change profile picture" %}{% endblock %} +{% block content %} + {% trans "Change profile picture" as change_str %} + {% include "wagtailadmin/shared/header.html" with title=change_str %} + +
+
+ {% csrf_token %} + + + +
  • +
    +

    {% trans "Your current profile picture:" %}

    +

    + +

    + +
    +{% endblock %} diff --git a/wagtail/admin/templates/wagtailadmin/home.html b/wagtail/admin/templates/wagtailadmin/home.html index 4088102b31..af08da3c7a 100644 --- a/wagtail/admin/templates/wagtailadmin/home.html +++ b/wagtail/admin/templates/wagtailadmin/home.html @@ -1,5 +1,5 @@ {% extends "wagtailadmin/base.html" %} -{% load gravatar staticfiles i18n %} +{% load avatar staticfiles i18n %} {% block titletag %}{% trans "Dashboard" %}{% endblock %} {% block bodyclass %}homepage{% endblock %} @@ -13,7 +13,7 @@
    -
    +

    {% block branding_welcome %}{% blocktrans %}Welcome to the {{ site_name }} Wagtail CMS{% endblocktrans %}{% endblock %}

    diff --git a/wagtail/admin/templates/wagtailadmin/pages/edit.html b/wagtail/admin/templates/wagtailadmin/pages/edit.html index ea3c713c4d..8d95a9a4dd 100644 --- a/wagtail/admin/templates/wagtailadmin/pages/edit.html +++ b/wagtail/admin/templates/wagtailadmin/pages/edit.html @@ -1,6 +1,6 @@ {% extends "wagtailadmin/base.html" %} {% load wagtailadmin_tags %} -{% load gravatar %} +{% load avatar %} {% load i18n %} {% load l10n %} {% block titletag %}{% blocktrans with title=page.get_admin_display_title page_type=content_type.model_class.get_verbose_name %}Editing {{ page_type }}: {{ title }}{% endblocktrans %}{% endblock %} @@ -89,7 +89,7 @@ {% blocktrans with last_mod=page.get_latest_revision.created_at %}Last modified: {{ last_mod }}{% endblocktrans %} {% if page.get_latest_revision.user %} {% blocktrans with modified_by=page.get_latest_revision.user.get_full_name|default:page.get_latest_revision.user.get_username %}by {{ modified_by }}{% endblocktrans %} - + {% endif %} {% trans 'Revisions' %} {% endif %} diff --git a/wagtail/admin/templates/wagtailadmin/pages/revisions/list.html b/wagtail/admin/templates/wagtailadmin/pages/revisions/list.html index a43c6afb27..a8bc0c590e 100644 --- a/wagtail/admin/templates/wagtailadmin/pages/revisions/list.html +++ b/wagtail/admin/templates/wagtailadmin/pages/revisions/list.html @@ -1,4 +1,4 @@ -{% load i18n wagtailadmin_tags gravatar %} +{% load i18n wagtailadmin_tags avatar %} {% load l10n %} @@ -17,7 +17,7 @@

    {{ revision.created_at }} - {% trans 'by' context 'points to a user who created a revision' %}{{ revision.user }} + {% trans 'by' context 'points to a user who created a revision' %}{{ revision.user }} {% if revision == page.get_latest_revision %}({% trans 'Current draft' %}){% endif %} {% if revision == page.live_revision %}({% trans 'Live version' %}){% endif %} diff --git a/wagtail/admin/templates/wagtailadmin/shared/main_nav.html b/wagtail/admin/templates/wagtailadmin/shared/main_nav.html index ccd6646881..93a1944798 100644 --- a/wagtail/admin/templates/wagtailadmin/shared/main_nav.html +++ b/wagtail/admin/templates/wagtailadmin/shared/main_nav.html @@ -1,4 +1,4 @@ -{% load gravatar wagtailadmin_tags %} +{% load avatar wagtailadmin_tags %} {% load i18n %} \ No newline at end of file + diff --git a/wagtail/admin/templates/wagtailadmin/shared/user_avatar.html b/wagtail/admin/templates/wagtailadmin/shared/user_avatar.html index 851a592e2b..feaf31b186 100644 --- a/wagtail/admin/templates/wagtailadmin/shared/user_avatar.html +++ b/wagtail/admin/templates/wagtailadmin/shared/user_avatar.html @@ -1,7 +1,7 @@ -{% load gravatar %} +{% load avatar %} - + {{ user }} diff --git a/wagtail/admin/templatetags/avatar.py b/wagtail/admin/templatetags/avatar.py new file mode 100644 index 0000000000..cb1566f811 --- /dev/null +++ b/wagtail/admin/templatetags/avatar.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import, unicode_literals + +from django import template +from django.contrib.staticfiles.templatetags.staticfiles import static +register = template.Library() + + +@register.simple_tag(takes_context=True) +def avatar_url(context, user, size=50): + """ + A template tag that receives a user and size and return + the appropiate avatar url for that user. + Example usage: {% avatar_url request.user 50 %} + """ + + if hasattr(user, 'wagtail_userprofile'): # A user could not have profile yet, so this is necessay + return user.wagtail_userprofile.get_avatar_url(size=size) + return static('wagtailadmin/images/default-user-avatar.svg') diff --git a/wagtail/admin/templatetags/gravatar.py b/wagtail/admin/templatetags/gravatar.py deleted file mode 100644 index a5c3a4f05d..0000000000 --- a/wagtail/admin/templatetags/gravatar.py +++ /dev/null @@ -1,44 +0,0 @@ -# place inside a 'templatetags' directory inside the top level of a Django app (not project, must be inside an app) -# at the top of your page template include this: -# {% load gravatar %} -# and to use the url do this: -# -# or -# -# just make sure to update the "default" image path below - -import hashlib -from urllib.parse import urlencode - -from django import template - -register = template.Library() - - -class GravatarUrlNode(template.Node): - def __init__(self, email, size=50): - self.email = template.Variable(email) - self.size = size - - def render(self, context): - try: - email = self.email.resolve(context) - except template.VariableDoesNotExist: - return '' - - default = "mm" - size = int(self.size) * 2 # requested at retina size by default and scaled down at point of use with css - - gravatar_url = "//www.gravatar.com/avatar/{hash}?{params}".format( - hash=hashlib.md5(email.lower().encode('utf-8')).hexdigest(), - params=urlencode({'s': size, 'd': default}) - ) - - return gravatar_url - - -@register.tag -def gravatar_url(parser, token): - bits = token.split_contents() - - return GravatarUrlNode(*bits[1:]) diff --git a/wagtail/admin/tests/test_account_management.py b/wagtail/admin/tests/test_account_management.py index b83672c806..1359c6d4b8 100644 --- a/wagtail/admin/tests/test_account_management.py +++ b/wagtail/admin/tests/test_account_management.py @@ -1,3 +1,6 @@ +import os +import tempfile + import pytz from django.contrib.auth import views as auth_views @@ -13,6 +16,8 @@ from wagtail.admin.utils import ( from wagtail.tests.utils import WagtailTestUtils from wagtail.users.models import UserProfile +TMP_MEDIA_ROOT = tempfile.mktemp() + class TestAuthentication(TestCase, WagtailTestUtils): """ @@ -468,6 +473,101 @@ class TestAccountSection(TestCase, WagtailTestUtils): self.assertNotContains(response, 'Set Time Zone') +class TestAvatarSection(TestCase, WagtailTestUtils): + def _create_image(self): + from PIL import Image + + with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f: + image = Image.new('RGB', (200, 200), 'white') + image.save(f, 'JPEG') + + return open(f.name, mode='rb') + + def setUp(self): + self.user = self.login() + self.avatar = self._create_image() + self.other_avatar = self._create_image() + + def tearDown(self): + self.avatar.close() + self.other_avatar.close() + + def test_avatar_preferences_view(self): + """ + This tests that the change user profile(avatar) view responds with an index page + """ + response = self.client.get(reverse('wagtailadmin_account_change_avatar')) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'wagtailadmin/account/change_avatar.html') + self.assertContains(response, "Change profile picture") + + def test_avatar_preferences_post(self): + """ + This tests that the change user profile(avatar) view change the user preferences + """ + post_data = { + 'avatar_choice': 'default', + } + response = self.client.post(reverse('wagtailadmin_account_change_avatar'), post_data, follow=True) + self.assertEqual(response.status_code, 200) + + profile = UserProfile.get_for_user(get_user_model().objects.get(pk=self.user.pk)) + self.assertEqual('default', profile.avatar_choice) + + def test_get_avatar_returns_default_if_not_changed(self): + profile = UserProfile.get_for_user(get_user_model().objects.get(pk=self.user.pk)) + + self.assertEqual(profile.avatar_choice, 'default') + self.assertIn('default-user-avatar', profile.get_avatar_url()) + + def test_set_gravatar_returns_gravatar(self): + post_data = { + 'avatar_choice': 'gravatar', + } + response = self.client.post(reverse('wagtailadmin_account_change_avatar'), post_data, follow=True) + self.assertEqual(response.status_code, 200) + + profile = UserProfile.get_for_user(get_user_model().objects.get(pk=self.user.pk)) + self.assertEqual(profile.avatar_choice, 'gravatar') + self.assertIn('www.gravatar.com', profile.get_avatar_url()) + + @override_settings(MEDIA_ROOT=TMP_MEDIA_ROOT) + def test_set_custom_avatar_stores_and_get_custom_avatar(self): + response = self.client.post(reverse('wagtailadmin_account_change_avatar'), + {'avatar_choice': 'custom', + 'avatar': self.avatar}, + follow=True) + + self.assertEqual(response.status_code, 200) + + profile = UserProfile.get_for_user(get_user_model().objects.get(pk=self.user.pk)) + self.assertEqual('custom', profile.avatar_choice) + self.assertIn(os.path.basename(self.avatar.name), profile.get_avatar_url()) + + @override_settings(MEDIA_ROOT=TMP_MEDIA_ROOT) + def test_user_upload_another_image_removes_previous_one(self): + response = self.client.post(reverse('wagtailadmin_account_change_avatar'), + {'avatar_choice': 'custom', + 'avatar': self.avatar}, + follow=True) + self.assertEqual(response.status_code, 200) + + profile = UserProfile.get_for_user(get_user_model().objects.get(pk=self.user.pk)) + old_avatar_path = profile.avatar.path + + # Upload a new avatar + new_response = self.client.post(reverse('wagtailadmin_account_change_avatar'), + {'avatar_choice': 'custom', + 'avatar': self.other_avatar}, + follow=True) + self.assertEqual(new_response.status_code, 200) + + # Check old avatar doesn't exist anymore in filesystem + with self.assertRaises(FileNotFoundError): + open(old_avatar_path) + + class TestAccountManagementForNonModerator(TestCase, WagtailTestUtils): """ Tests of reduced-functionality for editors diff --git a/wagtail/admin/urls/__init__.py b/wagtail/admin/urls/__init__.py index e0325fce3c..7ce810ec64 100644 --- a/wagtail/admin/urls/__init__.py +++ b/wagtail/admin/urls/__init__.py @@ -50,6 +50,7 @@ urlpatterns = [ account.notification_preferences, name='wagtailadmin_account_notification_preferences' ), + url(r'account/change_avatar/$', account.change_avatar, name='wagtailadmin_account_change_avatar'), url( r'^account/language_preferences/$', account.language_preferences, diff --git a/wagtail/admin/views/account.py b/wagtail/admin/views/account.py index 0a271d11b4..34fa86ace9 100644 --- a/wagtail/admin/views/account.py +++ b/wagtail/admin/views/account.py @@ -12,7 +12,7 @@ from django.utils.translation import activate from wagtail.admin import forms from wagtail.core import hooks from wagtail.users.forms import ( - CurrentTimeZoneForm, EmailForm, NotificationPreferencesForm, PreferredLanguageForm) + AvatarPreferencesForm, CurrentTimeZoneForm, EmailForm, NotificationPreferencesForm, PreferredLanguageForm) from wagtail.users.models import UserProfile from wagtail.utils.loading import get_custom_form @@ -185,6 +185,20 @@ def current_time_zone(request): }) +def change_avatar(request): + if request.method == 'POST': + user_profile = UserProfile.get_for_user(request.user) + form = AvatarPreferencesForm(request.POST, request.FILES, instance=user_profile) + if form.is_valid(): + form.save() + messages.success(request, _("Your preferences have been updated successfully!")) + return redirect('wagtailadmin_account_change_avatar') + else: + form = AvatarPreferencesForm(instance=UserProfile.get_for_user(request.user)) + + return render(request, 'wagtailadmin/account/change_avatar.html', {'form': form}) + + class LoginView(auth_views.LoginView): template_name = 'wagtailadmin/login.html' diff --git a/wagtail/admin/wagtail_hooks.py b/wagtail/admin/wagtail_hooks.py index c709902b92..e2fc1ccc3e 100644 --- a/wagtail/admin/wagtail_hooks.py +++ b/wagtail/admin/wagtail_hooks.py @@ -194,15 +194,11 @@ def register_viewsets_urls(): @hooks.register('register_account_menu_item') -def register_account_set_gravatar(request): +def register_account_set_profile_picture(request): return { - 'url': 'https://gravatar.com/emails/', - 'label': _('Set gravatar'), - 'help_text': _( - "Your avatar image is provided by Gravatar and is connected to " - "your email address. With a Gravatar account you can set an " - "avatar for any number of other email addresses you use." - ) + 'url': reverse('wagtailadmin_account_change_avatar'), + 'label': _('Set profile picture'), + 'help_text': _("Change your profile picture") } diff --git a/wagtail/contrib/styleguide/templates/wagtailstyleguide/base.html b/wagtail/contrib/styleguide/templates/wagtailstyleguide/base.html index 01b331e433..0a111479d1 100644 --- a/wagtail/contrib/styleguide/templates/wagtailstyleguide/base.html +++ b/wagtail/contrib/styleguide/templates/wagtailstyleguide/base.html @@ -1,5 +1,5 @@ {% extends "wagtailadmin/base.html" %} -{% load wagtailadmin_tags i18n staticfiles gravatar %} +{% load wagtailadmin_tags i18n staticfiles avatar %} {% block extra_css %} {{ block.super }} @@ -582,12 +582,9 @@

    Misc formatters

    Avatar icons

    -

    Gravatar set (normal)

    -

    Gravatar not set (normal)

    -

    Gravatar set (square)

    -

    Gravatar not set (square)

    -

    Gravatar set (small)

    -

    Gravatar not set (small)

    +

    Avatar normal

    +

    Avatar square

    +

    Avatar small

    Status tags

    Primary tag
    diff --git a/wagtail/contrib/styleguide/views.py b/wagtail/contrib/styleguide/views.py index 7cb24c7d29..9b2d7e7a92 100644 --- a/wagtail/contrib/styleguide/views.py +++ b/wagtail/contrib/styleguide/views.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.auth.models import User from django.core.paginator import Paginator from django.shortcuts import render from django.utils.translation import ugettext as _ @@ -71,8 +72,11 @@ def index(request): paginator = Paginator(list(range(100)), 10) page = paginator.page(2) + user = User(email='david@torchbox.com') + return render(request, 'wagtailstyleguide/base.html', { 'search_form': form, 'example_form': example_form, 'example_page': page, + 'user': user, }) diff --git a/wagtail/users/forms.py b/wagtail/users/forms.py index e3ecce8012..33a193a427 100644 --- a/wagtail/users/forms.py +++ b/wagtail/users/forms.py @@ -418,3 +418,12 @@ class CurrentTimeZoneForm(forms.ModelForm): class Meta: model = UserProfile fields = ("current_time_zone",) + + +class AvatarPreferencesForm(forms.ModelForm): + class Meta: + model = UserProfile + fields = ("avatar_choice", "avatar") + widgets = { + 'avatar_choice': forms.RadioSelect(), + } diff --git a/wagtail/users/migrations/0008_userprofile_avatar_choice.py b/wagtail/users/migrations/0008_userprofile_avatar_choice.py new file mode 100644 index 0000000000..7c90d412ff --- /dev/null +++ b/wagtail/users/migrations/0008_userprofile_avatar_choice.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.5 on 2017-11-24 14:18 +from __future__ import unicode_literals + +from django.db import migrations, models +import wagtail.users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailusers', '0007_userprofile_current_time_zone'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='avatar', + field=models.ImageField(blank=True, upload_to=wagtail.users.models.upload_avatar_to, verbose_name='Upload your custom avatar'), + ), + migrations.AddField( + model_name='userprofile', + name='avatar_choice', + field=models.CharField(choices=[('default', 'Default'), ('custom', 'Custom'), ('gravatar', 'Gravatar')], default='default', max_length=10, verbose_name='Select profile picture type'), + ), + ] diff --git a/wagtail/users/models.py b/wagtail/users/models.py index 00dc767e41..698c6b496f 100644 --- a/wagtail/users/models.py +++ b/wagtail/users/models.py @@ -1,9 +1,34 @@ +import os +import uuid + from django.conf import settings +from django.contrib.staticfiles.templatetags.staticfiles import static from django.db import models +from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ +from wagtail.users.utils import get_gravatar_url + + +def upload_avatar_to(instance, filename): + filename, ext = os.path.splitext(filename) + return os.path.join( + 'avatar_images', + 'avatar_{uuid}_{filename}{ext}'.format( + uuid=uuid.uuid4(), filename=filename, ext=ext) + ) + class UserProfile(models.Model): + DEFAULT = 'default' + CUSTOM = 'custom' + GRAVATAR = 'gravatar' + AVATAR_CHOICES = ( + (DEFAULT, _('Default')), + (CUSTOM, _('Custom')), + (GRAVATAR, 'Gravatar') + ) + user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='wagtail_userprofile' ) @@ -40,6 +65,19 @@ class UserProfile(models.Model): default='' ) + avatar_choice = models.CharField( + verbose_name=_('Select profile picture type'), + default=DEFAULT, + choices=AVATAR_CHOICES, + max_length=10 + ) + + avatar = models.ImageField( + verbose_name=_('Upload your custom avatar'), + upload_to=upload_avatar_to, + blank=True, + ) + @classmethod def get_for_user(cls, user): return cls.objects.get_or_create(user=user)[0] @@ -53,5 +91,30 @@ class UserProfile(models.Model): def __str__(self): return self.user.get_username() + @cached_property + def default_avatar(self): + return static('wagtailadmin/images/default-user-avatar.svg') + + def get_avatar_url(self, size=50): + if self.avatar_choice == self.DEFAULT: + return self.default_avatar + + if self.avatar_choice == self.CUSTOM: + try: + return self.avatar.url + except ValueError: + return self.default_avatar + + if self.avatar_choice == self.GRAVATAR and self.user.email: + return get_gravatar_url(self.user.email, default=None, size=50) + + return self.default_avatar + + def save(self, *args, **kwargs): + if self.avatar: + this = UserProfile.objects.get(pk=self.pk) + this.avatar.delete(save=False) + return super(UserProfile, self).save(*args, **kwargs) + class Meta: verbose_name = _('user profile') diff --git a/wagtail/users/templates/wagtailusers/groups/index.html b/wagtail/users/templates/wagtailusers/groups/index.html index 5ca4531d70..0f6ebaae60 100644 --- a/wagtail/users/templates/wagtailusers/groups/index.html +++ b/wagtail/users/templates/wagtailusers/groups/index.html @@ -1,6 +1,6 @@ {% extends "wagtailadmin/base.html" %} {% load i18n %} -{% load gravatar %} +{% load avatar %} {% block titletag %}{% trans "groups" %}{% endblock %} {% block extra_js %} {{ block.super }} diff --git a/wagtail/users/templates/wagtailusers/users/index.html b/wagtail/users/templates/wagtailusers/users/index.html index d2be62efbe..4ee5a8caa0 100644 --- a/wagtail/users/templates/wagtailusers/users/index.html +++ b/wagtail/users/templates/wagtailusers/users/index.html @@ -1,6 +1,6 @@ {% extends "wagtailadmin/base.html" %} {% load i18n %} -{% load gravatar %} +{% load avatar %} {% block titletag %}{% trans "Users" %}{% endblock %} {% block extra_js %} {{ block.super }} diff --git a/wagtail/users/templates/wagtailusers/users/list.html b/wagtail/users/templates/wagtailusers/users/list.html index 5692de4843..03ecb695be 100644 --- a/wagtail/users/templates/wagtailusers/users/list.html +++ b/wagtail/users/templates/wagtailusers/users/list.html @@ -1,5 +1,5 @@ {% load i18n wagtailusers_tags %} -{% load gravatar %} +{% load avatar %}
    @@ -28,7 +28,7 @@

    - + {{ user.get_full_name|default:user.get_username }}

      diff --git a/wagtail/users/tests.py b/wagtail/users/tests.py index 6ba60ccd25..9b51278656 100644 --- a/wagtail/users/tests.py +++ b/wagtail/users/tests.py @@ -19,6 +19,10 @@ delete_user_perm_codename = "delete_{0}".format(AUTH_USER_MODEL_NAME.lower()) change_user_perm_codename = "change_{0}".format(AUTH_USER_MODEL_NAME.lower()) +def test_avatar_provider(user, default, size=50): + return '/nonexistent/path/to/avatar.png' + + class CustomUserCreationForm(UserCreationForm): country = forms.CharField(required=True, label="Country") attachment = forms.FileField(required=True, label="Attachment") @@ -929,7 +933,7 @@ class TestUserProfileCreation(TestCase, WagtailTestUtils): self.test_user = get_user_model().objects.create_user( username='testuser', email='testuser@email.com', - password='password' + password='password', ) def test_user_created_without_profile(self): @@ -942,6 +946,16 @@ class TestUserProfileCreation(TestCase, WagtailTestUtils): # and get it from the db too self.assertEqual(UserProfile.objects.filter(user=self.test_user).count(), 1) + def test_get_avatar_url_default(self): + user_profile = UserProfile.get_for_user(self.test_user) + self.assertEqual(user_profile.avatar_choice, 'default') + self.assertIn('default-user-avatar', user_profile.get_avatar_url()) + + def test_get_avatar_url_fallback_default(self): + user_profile = UserProfile.get_for_user(self.test_user) + user_profile.avatar_choice = 'Non-existent' + self.assertIn('default-user-avatar', user_profile.get_avatar_url()) + class TestUserEditViewForNonSuperuser(TestCase, WagtailTestUtils): def setUp(self): diff --git a/wagtail/users/utils.py b/wagtail/users/utils.py index 7a21d7382c..5487efc144 100644 --- a/wagtail/users/utils.py +++ b/wagtail/users/utils.py @@ -1,3 +1,6 @@ +import hashlib +from django.utils.http import urlencode + from wagtail.core.compat import AUTH_USER_APP_LABEL, AUTH_USER_MODEL_NAME delete_user_perm = "{0}.delete_{1}".format(AUTH_USER_APP_LABEL, AUTH_USER_MODEL_NAME.lower()) @@ -16,3 +19,12 @@ def user_can_delete_user(current_user, user_to_delete): return False return True + + +def get_gravatar_url(email, default=None, size=50): + params = {'s': str(size)} + if default is not None: + params['default'] = default + gravatar_url = "https://www.gravatar.com/avatar/" + hashlib.md5(email.lower().encode('utf-8')).hexdigest() + "?" + gravatar_url += urlencode(params) + return gravatar_url