0001-support-avatar-picture-in-user-profile-26022.patch
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 |
- |