From ea2aeb03adc10cb7b082c69b763fe21b8dc63200 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 | 21 ++++- .../migrations/0023_auto_20181022_1914.py | 19 +++++ src/authentic2/models.py | 2 +- src/authentic2/settings.py | 3 + .../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/urls.py | 5 ++ src/authentic2/utils.py | 46 +++++++++++ src/authentic2/views.py | 2 + tests/conftest.py | 5 ++ tests/test_attribute_kinds.py | 73 ++++++++++++++++++ "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 + 21 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 src/authentic2/migrations/0023_auto_20181022_1914.py create mode 100644 src/authentic2/templates/authentic2/accounts_image.html create mode 100644 src/authentic2/templatetags/__init__.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 91613441..cec1415b 100644 --- a/debian/control +++ b/debian/control @@ -27,7 +27,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 ec933942..2be01bbc 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 d142eba3..2c560aab 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_html_value capfirst = allow_lazy(capfirst, unicode) @@ -160,6 +161,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, + 'rest_framework_field_kwargs': { + 'read_only': True, + }, + 'html_value': profile_image_html_value, + }, ] diff --git a/src/authentic2/forms/widgets.py b/src/authentic2/forms/widgets.py index c3d1dda2..18f0da08 100644 --- a/src/authentic2/forms/widgets.py +++ b/src/authentic2/forms/widgets.py @@ -7,11 +7,13 @@ # License: BSD # Initial Author: Alfredo Saglimbeni +import django 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 +248,20 @@ class CheckPasswordInput(PasswordInput): json.dumps(_id), ) return output + + +class ProfileImageInput(ClearableFileInput): + if django.VERSION < (1, 9): + template_with_initial = ( + '%(initial_text)s:

' + '%(clear_template)s %(input_text)s: %(input)s' + ) + + def get_template_substitution_values(self, value): + return {'initial': value} + + else: + template_name = "authentic2/accounts_image.html" + + def is_initial(self, value): + return bool(value) diff --git a/src/authentic2/migrations/0023_auto_20181022_1914.py b/src/authentic2/migrations/0023_auto_20181022_1914.py new file mode 100644 index 00000000..df301709 --- /dev/null +++ b/src/authentic2/migrations/0023_auto_20181022_1914.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentic2', '0022_attribute_scopes'), + ] + + operations = [ + migrations.AlterField( + model_name='attributevalue', + name='content', + field=models.TextField(null=True, verbose_name='content', db_index=True), + ), + ] diff --git a/src/authentic2/models.py b/src/authentic2/models.py index b14f3085..550f8ae5 100644 --- a/src/authentic2/models.py +++ b/src/authentic2/models.py @@ -270,7 +270,7 @@ class AttributeValue(models.Model): verbose_name=_('attribute')) multiple = models.BooleanField(default=False) - content = models.TextField(verbose_name=_('content'), db_index=True) + content = models.TextField(verbose_name=_('content'), db_index=True, null=True) verified = models.BooleanField(default=False) objects = managers.AttributeValueManager() 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_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..5c0d352b --- /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/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..127022e8 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,47 @@ 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.lower() + 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 get_image_thumbnail(img): + from sorl.thumbnail import get_thumbnail + logger = logging.getLogger(__name__) + + dimensions = app_settings.A2_ATTRIBUTE_KIND_IMAGE_DIMENSIONS + crop = app_settings.A2_ATTRIBUTE_KIND_IMAGE_CROPPING + quality = app_settings.A2_ATTRIBUTE_KIND_IMAGE_QUALITY + + try: + local_img = img.split(default_storage.base_url)[-1] + thumb = get_thumbnail(local_img, dimensions, crop=crop, quality=quality) + except: + logger.error("Couldn't generate thumbnail for image %s" % img) + else: + return thumb + + +def profile_image_html_value(attribute, value): + fragment = u'' % ( + settings.MEDIA_URL, value, attribute.name, value) + return html.mark_safe(fragment) + + +def profile_image_serialize(image): + from urllib import unquote + + if isinstance(image, basestring): + return unquote(image).decode('utf8') + elif image: + img_tmp_path = _store_image(image) + return get_image_thumbnail(img_tmp_path).url diff --git a/src/authentic2/views.py b/src/authentic2/views.py index c9d8d9a7..13c96d0f 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -444,11 +444,13 @@ class ProfileView(cbv.TemplateNamesMixin, TemplateView): if attribute: if not attribute.user_visible: continue + html_value = attribute.get_kind().get('html_value', lambda a, b: b) qs = models.AttributeValue.objects.with_owner(request.user) qs = qs.filter(attribute=attribute) qs = qs.select_related() value = [at_value.to_python() for at_value in qs] value = filter(None, value) + value = [html_value(attribute, at_value) for at_value in value] if not title: title = unicode(attribute) else: diff --git a/tests/conftest.py b/tests/conftest.py index efa66a49..b5b5e78c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -339,3 +339,8 @@ def french_translation(): activate('fr') yield deactivate() + + +@pytest.fixture +def media(settings, tmpdir): + settings.MEDIA_ROOT = str(tmpdir.mkdir('media')) diff --git a/tests/test_attribute_kinds.py b/tests/test_attribute_kinds.py index b821bc6d..ef4d49b0 100644 --- a/tests/test_attribute_kinds.py +++ b/tests/test_attribute_kinds.py @@ -5,6 +5,7 @@ from authentic2.custom_user.models import User from authentic2.models import Attribute from utils import get_link_from_mail +from webtest import Upload def test_string(db, app, admin, mailoutbox): @@ -369,3 +370,75 @@ def test_birthdate_api(db, app, admin, mailoutbox, freezer): app.post_json('/api/users/', params=payload) assert qs.get().attributes.birthdate == datetime.date(1900, 1, 1) qs.delete() + + +def test_profile_image(db, app, admin, mailoutbox, media): + Attribute.objects.create(name='cityscape_image', label='cityscape', kind='profile_image', + asked_on_registration=True, required=False, + user_visible=True, user_editable=True) + + 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() + + 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-clear', True) + response = form.submit() + + qs = User.objects.filter(first_name='John') + assert qs.get().attributes.cityscape_image == '' + qs.delete() + + +def test_profile_images_account_registration(db, app, admin, mailoutbox, media): + 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) + + 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() + + qs = User.objects.filter(first_name='John') + john = qs.get() + assert john.attributes.cityscape_image + assert john.attributes.garden_image + john.delete() 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%