From d9649160c84a515fe67e2447770038b1b178de3d 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 | 1 + src/authentic2/app_settings.py | 1 + src/authentic2/attribute_kinds.py | 59 +++++++++++++++- src/authentic2/forms/fields.py | 56 ++++++++++++++- src/authentic2/forms/widgets.py | 14 +++- src/authentic2/settings.py | 2 + .../templates/authentic2/accounts_edit.html | 3 +- .../authentic2/profile_image_input.html | 5 ++ .../registration/registration_form.html | 3 +- src/authentic2/urls.py | 5 ++ src/authentic2/views.py | 2 + tests/200x200.jpg | Bin 0 -> 317 bytes tests/201x201.jpg | Bin 0 -> 330 bytes tests/conftest.py | 5 ++ tests/test_attribute_kinds.py | 64 ++++++++++++++++++ 17 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 src/authentic2/templates/authentic2/profile_image_input.html create mode 100644 tests/200x200.jpg create mode 100644 tests/201x201.jpg diff --git a/debian-wheezy/control b/debian-wheezy/control index b8039228..1c469f3a 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-pil Provides: ${python:Provides} Recommends: python-ldap Suggests: python-raven diff --git a/debian/control b/debian/control index 91613441..1564608b 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-pil Provides: ${python:Provides} Recommends: python-ldap Suggests: python-raven diff --git a/setup.py b/setup.py index ec933942..27bb3c64 100755 --- a/setup.py +++ b/setup.py @@ -131,6 +131,7 @@ setup(name="authentic2", 'XStatic-jQuery', 'XStatic-jquery-ui<1.12', 'xstatic-select2', + 'pillow', ], zip_safe=False, classifiers=[ diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py index 19154118..fdb64d7e 100644 --- a/src/authentic2/app_settings.py +++ b/src/authentic2/app_settings.py @@ -145,6 +145,7 @@ default_settings = dict( A2_OPENED_SESSION_COOKIE_NAME=Setting(default='A2_OPENED_SESSION', definition='Authentic session open'), A2_OPENED_SESSION_COOKIE_DOMAIN=Setting(default=None), A2_ATTRIBUTE_KINDS=Setting(default=(), definition='List of other attribute kinds'), + A2_ATTRIBUTE_KIND_PROFILE_IMAGE_MAX_SIZE=Setting(default=200, definition='Max width and height for a profile image'), A2_VALIDATE_EMAIL=Setting(default=False, definition='Validate user email server by doing an RCPT command'), A2_VALIDATE_EMAIL_DOMAIN=Setting(default=True, definition='Validate user email domain'), A2_PASSWORD_POLICY_MIN_CLASSES=Setting(default=3, definition='Minimum number of characters classes to be present in passwords'), diff --git a/src/authentic2/attribute_kinds.py b/src/authentic2/attribute_kinds.py index d142eba3..47b297df 100644 --- a/src/authentic2/attribute_kinds.py +++ b/src/authentic2/attribute_kinds.py @@ -1,6 +1,9 @@ import re import string import datetime +import io +import hashlib +import os from itertools import chain @@ -9,14 +12,17 @@ from django.core.exceptions import ValidationError from django.core.validators import RegexValidator from django.utils.translation import ugettext_lazy as _, pgettext_lazy from django.utils.functional import allow_lazy +from django.utils import html from django.template.defaultfilters import capfirst +from django.core.files import File +from django.core.files.storage import default_storage from rest_framework import serializers from .decorators import to_iter from .plugins import collect_from_plugins from . import app_settings -from .forms import widgets +from .forms import widgets, fields capfirst = allow_lazy(capfirst, unicode) @@ -100,6 +106,44 @@ class FrPostcodeDRFField(serializers.CharField): default_validators = [validate_fr_postcode] +class ProfileImageFile(object): + def __init__(self, name): + self.name = name + + @property + def url(self): + return default_storage.url(self.name) + + +def profile_image_serialize(uploadedfile): + if not uploadedfile: + return '' + if hasattr(uploadedfile, 'url'): + return uploadedfile.name + h_computation = hashlib.md5() + for chunk in uploadedfile.chunks(): + h_computation.update(chunk) + hexdigest = h_computation.hexdigest() + stored_file = default_storage.save( + os.path.join('profile-image', hexdigest), + uploadedfile) + return stored_file + + +def profile_image_deserialize(name): + if name: + return ProfileImageFile(name) + return None + + +def profile_image_html_value(attribute, value): + if value: + fragment = u'' % ( + value.url, attribute.name, value.url) + return html.mark_safe(fragment) + return '' + + DEFAULT_ALLOW_BLANK = True DEFAULT_MAX_LENGTH = 256 @@ -160,6 +204,19 @@ DEFAULT_ATTRIBUTE_KINDS = [ 'field_class': PhoneNumberField, 'rest_framework_field_class': PhoneNumberDRFField, }, + { + 'label': _('profile image'), + 'name': 'profile_image', + 'field_class': fields.ProfileImageField, + 'serialize': profile_image_serialize, + 'deserialize': profile_image_deserialize, + 'rest_framework_field_class': serializers.FileField, + 'rest_framework_field_kwargs': { + 'read_only': True, + 'use_url': True, + }, + 'html_value': profile_image_html_value, + }, ] diff --git a/src/authentic2/forms/fields.py b/src/authentic2/forms/fields.py index 0ca312cc..3be157a6 100644 --- a/src/authentic2/forms/fields.py +++ b/src/authentic2/forms/fields.py @@ -1,8 +1,17 @@ -from django.forms import CharField +import warnings +import io + +from django.forms import CharField, FileField, ValidationError +from django.forms.fields import FILE_INPUT_CONTRADICTION from django.utils.translation import ugettext_lazy as _ +from django.core.files import File +from authentic2 import app_settings from authentic2.passwords import password_help_text, validate_password -from authentic2.forms.widgets import PasswordInput, NewPasswordInput, CheckPasswordInput +from authentic2.forms.widgets import (PasswordInput, NewPasswordInput, + CheckPasswordInput, ProfileImageInput) + +import PIL.Image class PasswordField(CharField): @@ -33,3 +42,46 @@ class CheckPasswordField(CharField): } super(CheckPasswordField, self).__init__(*args, **kwargs) + +class ProfileImageField(FileField): + widget = ProfileImageInput + + def __init__(self, *args, **kwargs): + kwargs.setdefault( + 'help_text', + _('Image must be JPG or PNG of size less ' + 'than {max_size}x{max_size} pixels').format(max_size=self.max_size)) + super(ProfileImageField, self).__init__(*args, **kwargs) + + @property + def max_size(self): + return app_settings.A2_ATTRIBUTE_KIND_PROFILE_IMAGE_MAX_SIZE + + def clean(self, data, initial=None): + if data is FILE_INPUT_CONTRADICTION or data is False or data is None: + return super(ProfileImageField, self).clean(data, initial=initial) + # we have a file + try: + with warnings.catch_warnings(): + image = PIL.Image.open(io.BytesIO(data.read())) + except (IOError, PIL.Image.DecompressionBombWarning): + raise ValidationError(_('The image is not valid')) + width, height = image.size + max_size = app_settings.A2_ATTRIBUTE_KIND_PROFILE_IMAGE_MAX_SIZE + if width > max_size or height > max_size: + raise ValidationError(_('The image is bigger than {max_size}x{max_size} pixels') + .format(max_size=self.max_size)) + new_data = self.file_from_image(image, data.name) + return super(ProfileImageField, self).clean(new_data, initial=initial) + + def file_from_image(self, image, name=None): + output = io.BytesIO() + if image.mode != 'RGB': + image = image.convert('RGB') + image.save( + output, + format='JPEG', + quality=99, + optimize=1) + output.seek(0) + return File(output, name=name) diff --git a/src/authentic2/forms/widgets.py b/src/authentic2/forms/widgets.py index c3d1dda2..36fcf7fa 100644 --- a/src/authentic2/forms/widgets.py +++ b/src/authentic2/forms/widgets.py @@ -11,7 +11,9 @@ import json import re import uuid -from django.forms.widgets import DateTimeInput, DateInput, TimeInput +import django +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,13 @@ 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' + ) + else: + template_name = "authentic2/profile_image_input.html" diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index c045ce2a..8c9289a6 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 = [] 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/profile_image_input.html b/src/authentic2/templates/authentic2/profile_image_input.html new file mode 100644 index 00000000..186e7167 --- /dev/null +++ b/src/authentic2/templates/authentic2/profile_image_input.html @@ -0,0 +1,5 @@ +{% 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/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/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/200x200.jpg b/tests/200x200.jpg new file mode 100644 index 0000000000000000000000000000000000000000..946c079344b3fc869f0b624c73cc8ff2c7610b5b GIT binary patch literal 317 zcmd6h!41MN3`M_biR>h8oEGNcfK&;lU_Pecz@Z})xUh*yIMEGwU$XwUCEK>x8+x8j z6NmsRg1vA=v#HeD6k69-*tx9}_Fh=T#}s|&gGiUVKB;E)aTo_3i&wiLFlNj|8Ft71 byC1(uY45ZgTw_mg{pEn`Wj*<;l?@88swq)C$wm>h_ zc>)nYS+EaIsCI=?t4!_cf_HB3*o&}dme@rJZD4Lv({`#!CH6zFL-uNS1dkarQGz{i c{O-puoZAP@2e;T8zWg%ccHK_?H;wjM0of@P+yDRo literal 0 HcmV?d00001 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..bc7e6307 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,66 @@ 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) + + def john(): + return User.objects.get(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) + + # verify empty file is refused + 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') + + # verify 201x201 image is refused + form = response.form + form.set('cityscape_image', Upload('tests/201x201.jpg')) + form.set('password1', '12345abcdA') + form.set('password2', '12345abcdA') + response = form.submit() + assert response.pyquery.find('.form-field-error #id_cityscape_image') + + # verify 200x200 image is accepted + form = response.form + form.set('cityscape_image', Upload('tests/200x200.jpg')) + form.set('password1', '12345abcdA') + form.set('password2', '12345abcdA') + response = form.submit() + assert john().attributes.cityscape_image + + # verify API serves absolute URL for profile images + app.authorization = ('Basic', (admin.username, admin.username)) + response = app.get('/api/users/%s/' % john().uuid) + assert response.json['cityscape_image'] == 'http://testserver/media/%s' % john().attributes.cityscape_image.name + app.authorization = None + + # verify we can clear the 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-clear', True) + response = form.submit() + assert john().attributes.cityscape_image == None + + # verify API serves absolute URL for profile images + app.authorization = ('Basic', (admin.username, admin.username)) + response = app.get('/api/users/%s/' % john().uuid) + assert response.json['cityscape_image'] is None -- 2.18.0