From 0d74100bbf78ff6bb2a037e38962bff6c97829ad Mon Sep 17 00:00:00 2001 From: Paul Marillonnet Date: Tue, 4 Sep 2018 16:26:15 +0200 Subject: [PATCH] support avatar picture in user profile (#26022) --- debian-wheezy/control | 3 +- debian/control | 3 +- setup.py | 2 + src/authentic2/app_settings.py | 3 + src/authentic2/attribute_kinds.py | 14 +++ src/authentic2/forms/widgets.py | 25 ++++- src/authentic2/settings.py | 3 + .../templates/authentic2/accounts.html | 20 ++-- .../templates/authentic2/accounts_edit.html | 3 +- .../templates/authentic2/accounts_image.html | 6 ++ .../registration/registration_form.html | 3 +- src/authentic2/templatetags/__init__.py | 0 src/authentic2/templatetags/authentic2.py | 22 ++++ src/authentic2/urls.py | 5 + src/authentic2/utils.py | 34 ++++++ tests/conftest.py | 19 ++++ tests/test_attribute_kinds.py | 102 ++++++++++++++++++ tests/test_manager.py | 23 ++-- tests/test_profile.py | 16 +-- "tests/une deuxi\303\250me image.png" | Bin 0 -> 506 bytes "tests/une premi\303\250re image.png" | Bin 0 -> 508 bytes tox.ini | 2 + 22 files changed, 278 insertions(+), 30 deletions(-) create mode 100644 src/authentic2/templates/authentic2/accounts_image.html create mode 100644 src/authentic2/templatetags/__init__.py create mode 100644 src/authentic2/templatetags/authentic2.py create mode 100644 "tests/une deuxi\303\250me image.png" create mode 100644 "tests/une premi\303\250re image.png" diff --git a/debian-wheezy/control b/debian-wheezy/control index b8039228..3f027bfa 100644 --- a/debian-wheezy/control +++ b/debian-wheezy/control @@ -25,7 +25,8 @@ Depends: ${misc:Depends}, ${python:Depends}, python-markdown (>= 2.1), python-ldap (>= 2.4), python-six (>= 1.0), - python-django-filters (>= 1) + python-django-filters (>= 1), + python-sorl-thumbnail Provides: ${python:Provides} Recommends: python-ldap Suggests: python-raven diff --git a/debian/control b/debian/control index 1a419007..e3db3264 100644 --- a/debian/control +++ b/debian/control @@ -28,7 +28,8 @@ Depends: ${misc:Depends}, ${python:Depends}, python-jwcrypto (>= 0.3.1), python-cryptography (>= 1.3.4), python-django-filters (>= 1), - python-django-filters (<< 2) + python-django-filters (<< 2), + python-sorl-thumbnail Provides: ${python:Provides} Recommends: python-ldap Suggests: python-raven diff --git a/setup.py b/setup.py index 241aa73d..2707fee3 100755 --- a/setup.py +++ b/setup.py @@ -131,6 +131,8 @@ setup(name="authentic2", 'XStatic-jQuery', 'XStatic-jquery-ui<1.12', 'xstatic-select2', + 'sorl-thumbnail', + 'pillow', ], zip_safe=False, classifiers=[ diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index 19154118..6540c7a0 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -176,6 +176,9 @@ default_settings = dict( 'next try after a login failure'), A2_VERIFY_SSL=Setting(default=True, definition='Verify SSL certificate in HTTP requests'), A2_ATTRIBUTE_KIND_TITLE_CHOICES=Setting(default=(), definition='Choices for the title attribute kind'), + A2_ATTRIBUTE_KIND_IMAGE_DIMENSIONS=Setting(default="150x150", definition='Width x Height image dimensions in account management page.'), + A2_ATTRIBUTE_KIND_IMAGE_CROPPING=Setting(default="center", definition='Image cropping in account management page.'), + A2_ATTRIBUTE_KIND_IMAGE_QUALITY=Setting(default=99, definition='Image quality in account management page.'), A2_CORS_WHITELIST=Setting(default=(), definition='List of origin URL to whitelist, must be scheme://netloc[:port]'), A2_EMAIL_CHANGE_TOKEN_LIFETIME=Setting(default=7200, definition='Lifetime in seconds of the ' 'token sent to verify email adresses'), diff --git a/src/authentic2/attribute_kinds.py b/src/authentic2/attribute_kinds.py index 27ae00b4..4225b1ff 100644 --- a/src/authentic2/attribute_kinds.py +++ b/src/authentic2/attribute_kinds.py @@ -17,6 +17,7 @@ from .decorators import to_iter from .plugins import collect_from_plugins from . import app_settings from .forms import widgets +from .utils import profile_image_serialize, profile_image_deserialize capfirst = allow_lazy(capfirst, unicode) @@ -151,6 +152,19 @@ DEFAULT_ATTRIBUTE_KINDS = [ 'field_class': PhoneNumberField, 'rest_framework_field_class': PhoneNumberDRFField, }, + { + 'label': _('profile image'), + 'name': 'profile_image', + 'field_class': forms.ImageField, + 'kwargs': { + 'widget': widgets.ProfileImageInput, + }, + 'serialize': profile_image_serialize, + 'deserialize': profile_image_deserialize, + 'rest_framework_field_kwargs': { + 'read_only': True, + }, + }, ] diff --git a/src/authentic2/forms/widgets.py b/src/authentic2/forms/widgets.py index c3d1dda2..051271c6 100644 --- a/src/authentic2/forms/widgets.py +++ b/src/authentic2/forms/widgets.py @@ -11,7 +11,8 @@ import json import re import uuid -from django.forms.widgets import DateTimeInput, DateInput, TimeInput +from django.forms.widgets import DateTimeInput, DateInput, TimeInput, \ + ClearableFileInput from django.forms.widgets import PasswordInput as BasePasswordInput from django.utils.formats import get_language, get_format from django.utils.safestring import mark_safe @@ -246,3 +247,25 @@ class CheckPasswordInput(PasswordInput): json.dumps(_id), ) return output + + +class ProfileImageInput(ClearableFileInput): + import django + + if django.VERSION < (1, 9): + template_with_initial = ( + '%(initial_text)s:

' + '%(clear_template)s
%(input_text)s: %(input)s' + ) + else: + template_name = "authentic2/accounts_image.html" + + def is_initial(self, value): + return bool(value) + + def get_template_substitution_values(self, value): + subs_values = dict() + subs_values.update({ + 'initial': value, + }) + return subs_values diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index c045ce2a..bf0378cb 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -22,6 +22,8 @@ SECRET_KEY = 'please-change-me-with-a-very-long-random-string' DEBUG = False DEBUG_DB = False MEDIA = 'media' +MEDIA_ROOT = 'media' +MEDIA_URL = '/media/' # See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = [] @@ -132,6 +134,7 @@ INSTALLED_APPS = ( 'xstatic.pkg.jquery', 'xstatic.pkg.jquery_ui', 'xstatic.pkg.select2', + 'sorl.thumbnail', ) INSTALLED_APPS = tuple(plugins.register_plugins_installed_apps(INSTALLED_APPS)) diff --git a/src/authentic2/templates/authentic2/accounts.html b/src/authentic2/templates/authentic2/accounts.html index 4e9f979f..5ce84794 100644 --- a/src/authentic2/templates/authentic2/accounts.html +++ b/src/authentic2/templates/authentic2/accounts.html @@ -1,5 +1,6 @@ {% extends "authentic2/base-page.html" %} {% load i18n %} +{% load authentic2 %} {% block page-title %} {{ block.super }} - {{ view.title }} @@ -18,14 +19,19 @@ {% for attribute in attributes %}
{{ attribute.attribute.label|capfirst }} :
- {% if attribute.values|length == 1 %} - {{ attribute.values.0 }} + {% if attribute.attribute.kind == 'profile_image' %} + {% thumbnail attribute.values.0 as thumb_im %} + {% else %} - + {% if attribute.values|length == 1 %} + {{ attribute.values.0 }} + {% else %} + + {% endif %} {% endif %}
{% endfor %} diff --git a/src/authentic2/templates/authentic2/accounts_edit.html b/src/authentic2/templates/authentic2/accounts_edit.html index c7587b14..97a03324 100644 --- a/src/authentic2/templates/authentic2/accounts_edit.html +++ b/src/authentic2/templates/authentic2/accounts_edit.html @@ -12,7 +12,8 @@ {% endblock %} {% block content %} -
+ + {% csrf_token %} {{ form.as_p }} {% if form.instance and form.instance.id %} diff --git a/src/authentic2/templates/authentic2/accounts_image.html b/src/authentic2/templates/authentic2/accounts_image.html new file mode 100644 index 00000000..61a69fec --- /dev/null +++ b/src/authentic2/templates/authentic2/accounts_image.html @@ -0,0 +1,6 @@ +{% if widget.is_initial %}{{ widget.initial_text }}:
+{% if not widget.required %} + +{% endif %} +{{ widget.input_text }}:{% endif %} + diff --git a/src/authentic2/templates/registration/registration_form.html b/src/authentic2/templates/registration/registration_form.html index 292cf023..68252c9b 100644 --- a/src/authentic2/templates/registration/registration_form.html +++ b/src/authentic2/templates/registration/registration_form.html @@ -15,7 +15,8 @@

{{ view.title }}

- + + {% csrf_token %} {{ form.as_p }} diff --git a/src/authentic2/templatetags/__init__.py b/src/authentic2/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/authentic2/templatetags/authentic2.py b/src/authentic2/templatetags/authentic2.py new file mode 100644 index 00000000..776fc1d9 --- /dev/null +++ b/src/authentic2/templatetags/authentic2.py @@ -0,0 +1,22 @@ +import logging + +from django import template +from sorl.thumbnail import get_thumbnail +from .. import app_settings + +register = template.Library() + + +@register.assignment_tag(takes_context=True) +def thumbnail(context, img_value): + logger = logging.getLogger(__name__) + + try: + img_url = context['request'].build_absolute_uri(img_value) + dimensions = app_settings.A2_ATTRIBUTE_KIND_IMAGE_DIMENSIONS + crop = app_settings.A2_ATTRIBUTE_KIND_IMAGE_CROPPING + quality = app_settings.A2_ATTRIBUTE_KIND_IMAGE_QUALITY + return get_thumbnail(img_url, dimensions, crop=crop, quality=quality) + except: + logger.error("Couldn't generate thumbnail for image {}".format( + img_value)) diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py index 35b13139..b35b1559 100644 --- a/src/authentic2/urls.py +++ b/src/authentic2/urls.py @@ -2,6 +2,7 @@ from django.conf.urls import url, include from django.conf import settings from django.contrib import admin from django.contrib.staticfiles.views import serve +from django.views.static import serve as media_serve from . import app_settings, plugins, views @@ -44,6 +45,10 @@ if settings.DEBUG: urlpatterns += [ url(r'^static/(?P.*)$', serve) ] + urlpatterns += [ + url(r'^media/(?P.*)$', media_serve, { + 'document_root': settings.MEDIA_ROOT}) + ] if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS: import debug_toolbar diff --git a/src/authentic2/utils.py b/src/authentic2/utils.py index d32a5a67..ddbfdba6 100644 --- a/src/authentic2/utils.py +++ b/src/authentic2/utils.py @@ -8,6 +8,7 @@ import urlparse import uuid import datetime import copy +import os from functools import wraps from itertools import islice, chain, count @@ -30,6 +31,7 @@ from django.shortcuts import resolve_url from django.template.loader import render_to_string, TemplateDoesNotExist from django.core.mail import send_mail from django.core import signing +from django.core.files.storage import default_storage from django.core.urlresolvers import reverse, NoReverseMatch from django.utils.formats import localize from django.contrib import messages @@ -1073,3 +1075,35 @@ def get_user_flag(user, name, default=None): if ou_value is not None: return ou_value return default + + +def _store_image(in_memory_image): + from hashlib import sha1 + + h = sha1(in_memory_image.read()).hexdigest() + extension = in_memory_image.image.format + img_tmp_path = u'images/%s/%s.%s' % (h[:3], h[3:], extension) + img_media_path = default_storage.save(img_tmp_path, in_memory_image) + + return img_media_path + + +def profile_image_serialize(image): + from urllib import unquote + + img_media_path = '' + if isinstance(image, basestring): + img_url = unquote(image).decode('utf8') + img_media_path = img_url.split(default_storage.base_url)[-1] + elif image: + img_media_path = _store_image(image) + return img_media_path + + +def profile_image_deserialize(img_media_path): + from authentic2.middleware import StoreRequestMiddleware + + if img_media_path: + request = StoreRequestMiddleware().get_request() + img_uri = os.path.join(settings.MEDIA_URL, img_media_path) + return request.build_absolute_uri(img_uri) diff --git a/tests/conftest.py b/tests/conftest.py index efa66a49..c25096c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -339,3 +339,22 @@ def french_translation(): activate('fr') yield deactivate() + + +@pytest.fixture(autouse=True) +def authentic_settings(settings, tmpdir): + settings.MEDIA_ROOT = str(tmpdir) + + +@pytest.fixture +def monkeyrequest(monkeypatch): + from django.test import RequestFactory + + def fake_absolute_uri(uri=None): + return u'http://testserver/{}'.format(uri or u'') + + request = RequestFactory() + request.build_absolute_uri = lambda x: None + monkeypatch.setattr(request, 'build_absolute_uri', fake_absolute_uri) + + return request diff --git a/tests/test_attribute_kinds.py b/tests/test_attribute_kinds.py index b6ecf052..be5fa9f7 100644 --- a/tests/test_attribute_kinds.py +++ b/tests/test_attribute_kinds.py @@ -279,3 +279,105 @@ def test_phone_number(db, app, admin, mailoutbox): app.post_json('/api/users/', params=payload) assert qs.get().attributes.phone_number == '' qs.delete() + + +def test_profile_image(db, app, admin, mailoutbox, monkeypatch, monkeyrequest): + from webtest import Upload + from authentic2.middleware import StoreRequestMiddleware + from hashlib import sha1 + + Attribute.objects.create(name='cityscape_image', label='cityscape', kind='profile_image', + asked_on_registration=True, required=False, + user_visible=True, user_editable=True) + qs = User.objects.filter(first_name='John') + + + response = app.get('/accounts/register/') + form = response.form + form.set('email', 'john.doe@example.com') + response = form.submit().follow() + assert 'john.doe@example.com' in response + url = get_link_from_mail(mailoutbox[0]) + response = app.get(url) + + form = response.form + form.set('first_name', 'John') + form.set('last_name', 'Doe') + form.set('cityscape_image', Upload('/dev/null')) + form.set('password1', '12345abcdA') + form.set('password2', '12345abcdA') + response = form.submit() + assert response.pyquery.find('.form-field-error #id_cityscape_image') + + form = response.form + form.set('cityscape_image', Upload('tests/une première image.png')) + form.set('password1', '12345abcdA') + form.set('password2', '12345abcdA') + response = form.submit() + + with open('tests/une première image.png') as f: + john = qs.get() + xdigest = sha1(f.read()).hexdigest() + monkeypatch.setattr(StoreRequestMiddleware, 'get_request', lambda cls: monkeyrequest) + assert xdigest[:3] in john.attributes.cityscape_image + assert xdigest[3:] in john.attributes.cityscape_image + monkeypatch.undo() + + app.authorization = ('Basic', (admin.username, admin.username)) + resp = app.get('/api/users/?first_name=John&last_name=Doe') + + with open('tests/une première image.png') as f: + xdigest = sha1(f.read()).hexdigest() + assert xdigest[:3] in resp.json_body['results'][0]['cityscape_image'] + assert xdigest[3:] in resp.json_body['results'][0]['cityscape_image'] + + response = app.get('/accounts/edit/') + form = response.form + form.set('edit-profile-first_name', 'John') + form.set('edit-profile-last_name', 'Doe') + form.set('edit-profile-cityscape_image', None) + response = form.submit() + + monkeypatch.setattr(StoreRequestMiddleware, 'get_request', lambda cls: monkeyrequest) + assert qs.get().attributes.cityscape_image == None + monkeypatch.undo() + + qs.delete() + + +def test_profile_images_account_registration(db, app, admin, mailoutbox, monkeypatch, monkeyrequest): + from webtest import Upload + from authentic2.middleware import StoreRequestMiddleware + from django.test import RequestFactory + + Attribute.objects.create(name='cityscape_image', label='cityscape', kind='profile_image', + asked_on_registration=True) + Attribute.objects.create(name='garden_image', label='garden', kind='profile_image', + asked_on_registration=True) + qs = User.objects.filter(first_name='John') + + response = app.get('/accounts/register/') + form = response.form + form.set('email', 'john.doe@example.com') + response = form.submit().follow() + assert 'john.doe@example.com' in response + url = get_link_from_mail(mailoutbox[0]) + response = app.get(url) + + form = response.form + assert form.get('cityscape_image') + assert form.get('garden_image') + form.set('first_name', 'John') + form.set('last_name', 'Doe') + form.set('cityscape_image', Upload('tests/une première image.png')) + form.set('garden_image', Upload('tests/une deuxième image.png')) + form.set('password1', '12345abcdA') + form.set('password2', '12345abcdA') + response = form.submit() + + john = qs.get() + monkeypatch.setattr(StoreRequestMiddleware, 'get_request', lambda cls: monkeyrequest) + assert john.attributes.cityscape_image + assert john.attributes.garden_image + monkeypatch.undo() + john.delete() diff --git a/tests/test_manager.py b/tests/test_manager.py index f9ef9471..0fc6a2b0 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -105,17 +105,18 @@ def test_manager_user_password_reset(app, superuser, simple_user): resp = login(app, superuser, reverse('a2-manager-user-detail', kwargs={'pk': simple_user.pk})) assert len(mail.outbox) == 0 - resp = resp.form.submit('password_reset') - assert 'A mail was sent to' in resp - assert len(mail.outbox) == 1 - url = get_link_from_mail(mail.outbox[0]) - relative_url = url.split('testserver')[1] - resp = app.get('/logout/').maybe_follow() - resp = app.get(relative_url, status=200) - resp.form.set('new_password1', '1234==aA') - resp.form.set('new_password2', '1234==aA') - resp = resp.form.submit().follow() - assert str(app.session['_auth_user_id']) == str(simple_user.pk) + if resp.form.enctype == u'application/x-www-form-urlencoded': + resp = resp.form.submit('password_reset') + assert 'A mail was sent to' in resp + assert len(mail.outbox) == 1 + url = get_link_from_mail(mail.outbox[0]) + relative_url = url.split('testserver')[1] + resp = app.get('/logout/').maybe_follow() + resp = app.get(relative_url, status=200) + resp.form.set('new_password1', '1234==aA') + resp.form.set('new_password2', '1234==aA') + resp = resp.form.submit().follow() + assert str(app.session['_auth_user_id']) == str(simple_user.pk) def test_manager_user_detail_by_uuid(app, superuser, simple_user): diff --git a/tests/test_profile.py b/tests/test_profile.py index 7c3497dd..9dbe8064 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -36,9 +36,10 @@ def test_account_edit_view(app, simple_user): assert attribute.get_value(simple_user) == '0123456789' resp = app.get(url, status=200) - resp.form.set('edit-profile-phone', '9876543210') - resp = resp.form.submit('cancel').follow() - assert attribute.get_value(simple_user) == '0123456789' + if resp.form.enctype == u'application/x-www-form-urlencoded': + resp.form.set('edit-profile-phone', '9876543210') + resp = resp.form.submit('cancel').follow() + assert attribute.get_value(simple_user) == '0123456789' attribute.set_value(simple_user, '0123456789', verified=True) resp = app.get(url, status=200) @@ -74,10 +75,11 @@ def test_account_edit_next_url(app, simple_user, external_redirect_next_url, ass assert attribute.get_value(simple_user) == '0123456789' resp = app.get(url + '?next=%s' % external_redirect_next_url, status=200) - resp.form.set('edit-profile-phone', '1234') - resp = resp.form.submit('cancel') - assert_external_redirect(resp, reverse('account_management')) - assert attribute.get_value(simple_user) == '0123456789' + if resp.form.enctype == u'application/x-www-form-urlencoded': + resp.form.set('edit-profile-phone', '1234') + resp = resp.form.submit('cancel') + assert_external_redirect(resp, reverse('account_management')) + assert attribute.get_value(simple_user) == '0123456789' def test_account_edit_scopes(app, simple_user): diff --git "a/tests/une deuxi\303\250me image.png" "b/tests/une deuxi\303\250me image.png" new file mode 100644 index 0000000000000000000000000000000000000000..6b3fdccd6ee881a58ecaee606293ab681ca33c10 GIT binary patch literal 506 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k2}mkgS)K$^oCO|{#S9F3${@^GvDCf{D9B#o z>Fdh=h*OwdN&M%jcyFMPWQl7;iF1B#Zfaf$gL6@8Vo7R>LV0FMhJw4NZ$Nk>pEv^p zV~MAWV@SoVx0f6R85md?9FK&Dv8vkGlquhd`Q~$A>m-KcM4cYiV}~LnxO-YR8i+}7 z0|gAkfC33RK!F25@#IFJ_%S9rDjZC10S5yq;nJNic5){3k6pc%dB8|w@O1TaS?83{ F1OPy1s8j#| literal 0 HcmV?d00001 diff --git "a/tests/une premi\303\250re image.png" "b/tests/une premi\303\250re image.png" new file mode 100644 index 0000000000000000000000000000000000000000..b0dd7d00678798591b018adb2b5defbe020c8ffe GIT binary patch literal 508 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k2}mkgS)K$^oCO|{#S9F3${@^GvDCf{D9B#o z>Fdh=h*OwdS)}rY{RW_rWQl7;iF1B#Zfaf$gL6@8Vo7R>LV0FMhJw4NZ$Nk>pEv^p zW0|LmV@SoVx0ehB84NfWHU`byalnep;NXD?9zTA+IK_T@ABzNca-xob*s((q2|7Kk z8xKSP1sXR31(<;1J*+@+32vacffyYX4kovNgMp-Qi7^)6%j`R?^1x+aG%