Projet

Général

Profil

0001-support-avatar-picture-in-user-profile-26022.patch

Benjamin Dauvergne, 23 octobre 2018 18:13

Télécharger (18,1 ko)

Voir les différences:

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
debian-wheezy/control
25 25
    python-markdown (>= 2.1),
26 26
    python-ldap (>= 2.4),
27 27
    python-six (>= 1.0),
28
    python-django-filters (>= 1)
28
    python-django-filters (>= 1),
29
    python-pil
29 30
Provides: ${python:Provides}
30 31
Recommends: python-ldap
31 32
Suggests: python-raven
debian/control
27 27
    python-jwcrypto (>= 0.3.1),
28 28
    python-cryptography (>= 1.3.4),
29 29
    python-django-filters (>= 1),
30
    python-django-filters (<< 2)
30
    python-django-filters (<< 2),
31
    python-pil
31 32
Provides: ${python:Provides}
32 33
Recommends: python-ldap
33 34
Suggests: python-raven
setup.py
131 131
          'XStatic-jQuery',
132 132
          'XStatic-jquery-ui<1.12',
133 133
          'xstatic-select2',
134
          'pillow',
134 135
      ],
135 136
      zip_safe=False,
136 137
      classifiers=[
src/authentic2/app_settings.py
145 145
    A2_OPENED_SESSION_COOKIE_NAME=Setting(default='A2_OPENED_SESSION', definition='Authentic session open'),
146 146
    A2_OPENED_SESSION_COOKIE_DOMAIN=Setting(default=None),
147 147
    A2_ATTRIBUTE_KINDS=Setting(default=(), definition='List of other attribute kinds'),
148
    A2_ATTRIBUTE_KIND_PROFILE_IMAGE_MAX_SIZE=Setting(default=200, definition='Max width and height for a profile image'),
148 149
    A2_VALIDATE_EMAIL=Setting(default=False, definition='Validate user email server by doing an RCPT command'),
149 150
    A2_VALIDATE_EMAIL_DOMAIN=Setting(default=True, definition='Validate user email domain'),
150 151
    A2_PASSWORD_POLICY_MIN_CLASSES=Setting(default=3, definition='Minimum number of characters classes to be present in passwords'),
src/authentic2/attribute_kinds.py
1 1
import re
2 2
import string
3 3
import datetime
4
import io
5
import hashlib
6
import os
4 7

  
5 8
from itertools import chain
6 9

  
......
9 12
from django.core.validators import RegexValidator
10 13
from django.utils.translation import ugettext_lazy as _, pgettext_lazy
11 14
from django.utils.functional import allow_lazy
15
from django.utils import html
12 16
from django.template.defaultfilters import capfirst
17
from django.core.files import File
18
from django.core.files.storage import default_storage
13 19

  
14 20
from rest_framework import serializers
15 21

  
16 22
from .decorators import to_iter
17 23
from .plugins import collect_from_plugins
18 24
from . import app_settings
19
from .forms import widgets
25
from .forms import widgets, fields
20 26

  
21 27
capfirst = allow_lazy(capfirst, unicode)
22 28

  
......
100 106
    default_validators = [validate_fr_postcode]
101 107

  
102 108

  
109
class ProfileImageFile(object):
110
    def __init__(self, name):
111
        self.name = name
112

  
113
    @property
114
    def url(self):
115
        return default_storage.url(self.name)
116

  
117

  
118
def profile_image_serialize(uploadedfile):
119
    if not uploadedfile:
120
        return ''
121
    if hasattr(uploadedfile, 'url'):
122
        return uploadedfile.name
123
    h_computation = hashlib.md5()
124
    for chunk in uploadedfile.chunks():
125
        h_computation.update(chunk)
126
    hexdigest = h_computation.hexdigest()
127
    stored_file = default_storage.save(
128
        os.path.join('profile-image', hexdigest),
129
        uploadedfile)
130
    return stored_file
131

  
132

  
133
def profile_image_deserialize(name):
134
    if name:
135
        return ProfileImageFile(name)
136
    return None
137

  
138

  
139
def profile_image_html_value(attribute, value):
140
    if value:
141
        fragment = u'<a href="%s"><img class="%s" src="%s"/></a>' % (
142
            value.url, attribute.name, value.url)
143
        return html.mark_safe(fragment)
144
    return ''
145

  
146

  
103 147
DEFAULT_ALLOW_BLANK = True
104 148
DEFAULT_MAX_LENGTH = 256
105 149

  
......
160 204
        'field_class': PhoneNumberField,
161 205
        'rest_framework_field_class': PhoneNumberDRFField,
162 206
    },
207
    {
208
        'label': _('profile image'),
209
        'name': 'profile_image',
210
        'field_class': fields.ProfileImageField,
211
        'serialize': profile_image_serialize,
212
        'deserialize': profile_image_deserialize,
213
        'rest_framework_field_class': serializers.FileField,
214
        'rest_framework_field_kwargs': {
215
            'read_only': True,
216
            'use_url': True,
217
        },
218
        'html_value': profile_image_html_value,
219
    },
163 220
]
164 221

  
165 222

  
src/authentic2/forms/fields.py
1
from django.forms import CharField
1
import warnings
2
import io
3

  
4
from django.forms import CharField, FileField, ValidationError
5
from django.forms.fields import FILE_INPUT_CONTRADICTION
2 6
from django.utils.translation import ugettext_lazy as _
7
from django.core.files import File
3 8

  
9
from authentic2 import app_settings
4 10
from authentic2.passwords import password_help_text, validate_password
5
from authentic2.forms.widgets import PasswordInput, NewPasswordInput, CheckPasswordInput
11
from authentic2.forms.widgets import (PasswordInput, NewPasswordInput,
12
                                      CheckPasswordInput, ProfileImageInput)
13

  
14
import PIL.Image
6 15

  
7 16

  
8 17
class PasswordField(CharField):
......
33 42
        }
34 43
        super(CheckPasswordField, self).__init__(*args, **kwargs)
35 44

  
45

  
46
class ProfileImageField(FileField):
47
    widget = ProfileImageInput
48

  
49
    def __init__(self, *args, **kwargs):
50
        kwargs.setdefault(
51
            'help_text',
52
            _('Image must be JPG or PNG of size less '
53
              'than {max_size}x{max_size} pixels').format(max_size=self.max_size))
54
        super(ProfileImageField, self).__init__(*args, **kwargs)
55

  
56
    @property
57
    def max_size(self):
58
        return app_settings.A2_ATTRIBUTE_KIND_PROFILE_IMAGE_MAX_SIZE
59

  
60
    def clean(self, data, initial=None):
61
        if data is FILE_INPUT_CONTRADICTION or data is False or data is None:
62
            return super(ProfileImageField, self).clean(data, initial=initial)
63
        # we have a file
64
        try:
65
            with warnings.catch_warnings():
66
                image = PIL.Image.open(io.BytesIO(data.read()))
67
        except (IOError, PIL.Image.DecompressionBombWarning):
68
            raise ValidationError(_('The image is not valid'))
69
        width, height = image.size
70
        max_size = app_settings.A2_ATTRIBUTE_KIND_PROFILE_IMAGE_MAX_SIZE
71
        if width > max_size or height > max_size:
72
            raise ValidationError(_('The image is bigger than {max_size}x{max_size} pixels')
73
                                  .format(max_size=self.max_size))
74
        new_data = self.file_from_image(image, data.name)
75
        return super(ProfileImageField, self).clean(new_data, initial=initial)
76

  
77
    def file_from_image(self, image, name=None):
78
        output = io.BytesIO()
79
        if image.mode != 'RGB':
80
            image = image.convert('RGB')
81
        image.save(
82
            output,
83
            format='JPEG',
84
            quality=99,
85
            optimize=1)
86
        output.seek(0)
87
        return File(output, name=name)
src/authentic2/forms/widgets.py
11 11
import re
12 12
import uuid
13 13

  
14
from django.forms.widgets import DateTimeInput, DateInput, TimeInput
14
import django
15
from django.forms.widgets import DateTimeInput, DateInput, TimeInput, \
16
        ClearableFileInput
15 17
from django.forms.widgets import PasswordInput as BasePasswordInput
16 18
from django.utils.formats import get_language, get_format
17 19
from django.utils.safestring import mark_safe
......
246 248
                    json.dumps(_id),
247 249
                )
248 250
        return output
251

  
252

  
253
class ProfileImageInput(ClearableFileInput):
254
    if django.VERSION < (1, 9):
255
        template_with_initial = (
256
        '%(initial_text)s: <a href="%(initial_url)s"><img src="%(initial_url)s"/></a> '
257
        '%(clear_template)s<br />%(input_text)s: %(input)s'
258
        )
259
    else:
260
        template_name = "authentic2/profile_image_input.html"
src/authentic2/settings.py
22 22
DEBUG = False
23 23
DEBUG_DB = False
24 24
MEDIA = 'media'
25
MEDIA_ROOT = 'media'
26
MEDIA_URL = '/media/'
25 27

  
26 28
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
27 29
ALLOWED_HOSTS = []
src/authentic2/templates/authentic2/accounts_edit.html
12 12
{% endblock %}
13 13

  
14 14
{% block content %}
15
  <form method="post">
15
  <form enctype="multipart/form-data" method="post">
16

  
16 17
    {% csrf_token %}
17 18
    {{ form.as_p }}
18 19
    {% if form.instance and form.instance.id %}
src/authentic2/templates/authentic2/profile_image_input.html
1
{% if widget.is_initial %}{{ widget.initial_text }}: <a href="{{ widget.value.url }}"><img src="{{ widget.value.url }}"/></a>{% if not widget.required %}
2
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}" />
3
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}<br />
4
{{ widget.input_text }}:{% endif %}
5
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} />
src/authentic2/templates/registration/registration_form.html
15 15

  
16 16
<h2>{{ view.title }}</h2>
17 17

  
18
<form method="post">
18
<form enctype="multipart/form-data" method="post">
19

  
19 20
  {% csrf_token %}
20 21
  {{ form.as_p }}
21 22
  <button class="submit-button">{% trans 'Submit' %}</button>
src/authentic2/urls.py
2 2
from django.conf import settings
3 3
from django.contrib import admin
4 4
from django.contrib.staticfiles.views import serve
5
from django.views.static import serve as media_serve
5 6

  
6 7
from . import app_settings, plugins, views
7 8

  
......
44 45
    urlpatterns += [
45 46
        url(r'^static/(?P<path>.*)$', serve)
46 47
    ]
48
    urlpatterns += [
49
        url(r'^media/(?P<path>.*)$', media_serve, {
50
        'document_root': settings.MEDIA_ROOT})
51
    ]
47 52

  
48 53
if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS:
49 54
    import debug_toolbar
src/authentic2/views.py
444 444
            if attribute:
445 445
                if not attribute.user_visible:
446 446
                    continue
447
                html_value = attribute.get_kind().get('html_value', lambda a, b: b)
447 448
                qs = models.AttributeValue.objects.with_owner(request.user)
448 449
                qs = qs.filter(attribute=attribute)
449 450
                qs = qs.select_related()
450 451
                value = [at_value.to_python() for at_value in qs]
451 452
                value = filter(None, value)
453
                value = [html_value(attribute, at_value) for at_value in value]
452 454
                if not title:
453 455
                    title = unicode(attribute)
454 456
            else:
tests/conftest.py
339 339
    activate('fr')
340 340
    yield
341 341
    deactivate()
342

  
343

  
344
@pytest.fixture
345
def media(settings, tmpdir):
346
    settings.MEDIA_ROOT = str(tmpdir.mkdir('media'))
tests/test_attribute_kinds.py
5 5
from authentic2.models import Attribute
6 6

  
7 7
from utils import get_link_from_mail
8
from webtest import Upload
8 9

  
9 10

  
10 11
def test_string(db, app, admin, mailoutbox):
......
369 370
    app.post_json('/api/users/', params=payload)
370 371
    assert qs.get().attributes.birthdate == datetime.date(1900, 1, 1)
371 372
    qs.delete()
373

  
374

  
375
def test_profile_image(db, app, admin, mailoutbox, media):
376
    Attribute.objects.create(name='cityscape_image', label='cityscape', kind='profile_image',
377
                             asked_on_registration=True, required=False,
378
                             user_visible=True, user_editable=True)
379

  
380
    def john():
381
        return User.objects.get(first_name='John')
382

  
383
    response = app.get('/accounts/register/')
384
    form = response.form
385
    form.set('email', 'john.doe@example.com')
386
    response = form.submit().follow()
387
    assert 'john.doe@example.com' in response
388
    url = get_link_from_mail(mailoutbox[0])
389
    response = app.get(url)
390

  
391
    # verify empty file is refused
392
    form = response.form
393
    form.set('first_name', 'John')
394
    form.set('last_name', 'Doe')
395
    form.set('cityscape_image', Upload('/dev/null'))
396
    form.set('password1', '12345abcdA')
397
    form.set('password2', '12345abcdA')
398
    response = form.submit()
399
    assert response.pyquery.find('.form-field-error #id_cityscape_image')
400

  
401
    # verify 201x201 image is refused
402
    form = response.form
403
    form.set('cityscape_image', Upload('tests/201x201.jpg'))
404
    form.set('password1', '12345abcdA')
405
    form.set('password2', '12345abcdA')
406
    response = form.submit()
407
    assert response.pyquery.find('.form-field-error #id_cityscape_image')
408

  
409
    # verify 200x200 image is accepted
410
    form = response.form
411
    form.set('cityscape_image', Upload('tests/200x200.jpg'))
412
    form.set('password1', '12345abcdA')
413
    form.set('password2', '12345abcdA')
414
    response = form.submit()
415
    assert john().attributes.cityscape_image
416

  
417
    # verify API serves absolute URL for profile images
418
    app.authorization = ('Basic', (admin.username, admin.username))
419
    response = app.get('/api/users/%s/' % john().uuid)
420
    assert response.json['cityscape_image'] == 'http://testserver/media/%s' % john().attributes.cityscape_image.name
421
    app.authorization = None
422

  
423
    # verify we can clear the image
424
    response = app.get('/accounts/edit/')
425
    form = response.form
426
    form.set('edit-profile-first_name', 'John')
427
    form.set('edit-profile-last_name', 'Doe')
428
    form.set('edit-profile-cityscape_image-clear', True)
429
    response = form.submit()
430
    assert john().attributes.cityscape_image == None
431

  
432
    # verify API serves absolute URL for profile images
433
    app.authorization = ('Basic', (admin.username, admin.username))
434
    response = app.get('/api/users/%s/' % john().uuid)
435
    assert response.json['cityscape_image'] is None
372
-