Projet

Général

Profil

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

Paul Marillonnet, 18 septembre 2018 17:49

Télécharger (1,75 Mo)

Voir les différences:

Subject: [PATCH] support avatar picture in user profile (#26022)

 debian-wheezy/control                         |   3 +-
 debian/control                                |   3 +-
 setup.py                                      |   2 +
 src/authentic2/api_views.py                   |   2 +-
 src/authentic2/app_settings.py                |   2 +
 src/authentic2/attribute_kinds.py             |  36 ++++
 src/authentic2/custom_user/apps.py            |  20 ++-
 src/authentic2/custom_user/models.py          |   7 +-
 src/authentic2/forms/widgets.py               |  22 ++-
 src/authentic2/models.py                      |  29 +++-
 src/authentic2/settings.py                    |   3 +
 .../templates/authentic2/accounts.html        |  23 ++-
 .../templates/authentic2/accounts_edit.html   |   3 +-
 .../templates/authentic2/accounts_image.html  |   6 +
 .../registration/registration_form.html       |   3 +-
 src/authentic2/templatetags/authentic2.py     |  11 ++
 src/authentic2/urls.py                        |   5 +
 src/authentic2/utils.py                       |  86 +++++++++
 src/authentic2/views.py                       |   5 +
 tests/cityscape.png                           | Bin 0 -> 546650 bytes
 tests/conftest.py                             |   4 +
 tests/garden.png                              | Bin 0 -> 844498 bytes
 tests/test_api.py                             |   5 +-
 tests/test_attribute_kinds.py                 | 163 ++++++++++++++++++
 tests/test_manager.py                         |  23 +--
 tests/test_profile.py                         |  19 +-
 tests/test_user_manager.py                    |   2 +-
 tox.ini                                       |   2 +
 28 files changed, 442 insertions(+), 47 deletions(-)
 create mode 100644 src/authentic2/templates/authentic2/accounts_image.html
 create mode 100644 src/authentic2/templatetags/authentic2.py
 create mode 100644 tests/cityscape.png
 create mode 100644 tests/garden.png
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-sorl-thumbnail
29 30
Provides: ${python:Provides}
30 31
Recommends: python-ldap
31 32
Suggests: python-raven
debian/control
28 28
    python-jwcrypto (>= 0.3.1),
29 29
    python-cryptography (>= 1.3.4),
30 30
    python-django-filters (>= 1),
31
    python-django-filters (<< 2)
31
    python-django-filters (<< 2),
32
    python-sorl-thumbnail
32 33
Provides: ${python:Provides}
33 34
Recommends: python-ldap
34 35
Suggests: python-raven
setup.py
131 131
          'XStatic-jQuery',
132 132
          'XStatic-jquery-ui<1.12',
133 133
          'xstatic-select2',
134
          'sorl-thumbnail',
135
          'pillow',
134 136
      ],
135 137
      zip_safe=False,
136 138
      classifiers=[
src/authentic2/api_views.py
300 300
def user(request):
301 301
    if request.user.is_anonymous():
302 302
        return {}
303
    return request.user.to_json()
303
    return request.user.to_json(request)
304 304

  
305 305

  
306 306
def attributes_hash(attributes):
src/authentic2/app_settings.py
176 176
            'next try after a login failure'),
177 177
    A2_VERIFY_SSL=Setting(default=True, definition='Verify SSL certificate in HTTP requests'),
178 178
    A2_ATTRIBUTE_KIND_TITLE_CHOICES=Setting(default=(), definition='Choices for the title attribute kind'),
179
    A2_ATTRIBUTE_KIND_IMAGE_DIMENSIONS=Setting(default="", definition='Width x Height image dimensions in account management page.'),
180
    A2_ATTRIBUTE_KIND_IMAGE_CROPPING=Setting(default="", definition='Image cropping in account management page.'),
179 181
    A2_CORS_WHITELIST=Setting(default=(), definition='List of origin URL to whitelist, must be scheme://netloc[:port]'),
180 182
    A2_EMAIL_CHANGE_TOKEN_LIFETIME=Setting(default=7200, definition='Lifetime in seconds of the '
181 183
                                           'token sent to verify email adresses'),
src/authentic2/attribute_kinds.py
17 17
from .plugins import collect_from_plugins
18 18
from . import app_settings
19 19
from .forms import widgets
20
from .utils import image_serialize
20 21

  
21 22
capfirst = allow_lazy(capfirst, unicode)
22 23

  
......
25 26
    (pgettext_lazy('title', 'Mr'), pgettext_lazy('title', 'Mr')),
26 27
)
27 28

  
29
DEFAULT_IMAGE_DIMENSIONS = "150x150"
30
DEFAULT_IMAGE_CROPPING = "center"
28 31

  
29 32
class BirthdateWidget(widgets.DateWidget):
30 33
    help_text = _('Format: yyyy-mm-dd')
......
49 52
def get_title_choices():
50 53
    return app_settings.A2_ATTRIBUTE_KIND_TITLE_CHOICES or DEFAULT_TITLE_CHOICES
51 54

  
55

  
56
def get_image_dimensions():
57
    return app_settings.A2_ATTRIBUTE_KIND_IMAGE_DIMENSIONS or DEFAULT_IMAGE_DIMENSIONS
58

  
59

  
60
def get_image_cropping():
61
    return app_settings.A2_ATTRIBUTE_KIND_IMAGE_CROPPING or DEFAULT_IMAGE_CROPPING
62

  
52 63
validate_phone_number = RegexValidator('^\+?\d{,20}$', message=_('Phone number can start with a + '
53 64
                                                                 'an must contain only digits.'))
54 65

  
......
91 102
    default_validators = [validate_fr_postcode]
92 103

  
93 104

  
105
class A2ImageDRFField(serializers.ImageField):
106
    def to_representation(self, value):
107
        return self.context['request'].build_absolute_uri(value)
108

  
109

  
94 110
DEFAULT_ALLOW_BLANK = True
95 111
DEFAULT_MAX_LENGTH = 256
96 112

  
......
151 167
        'field_class': PhoneNumberField,
152 168
        'rest_framework_field_class': PhoneNumberDRFField,
153 169
    },
170
    {
171
        'label': _('image'),
172
        'name': 'image',
173
        'field_class': forms.ImageField,
174
        'kwargs': {
175
            'widget': widgets.AttributeImageInput,
176
        },
177
        'serialize': image_serialize,
178
        'serialize_eval_kwargs': {
179
            'owner_uuid': 'owner.uuid',
180
            'owner_pk': 'owner.pk',
181
            'attr_label': 'self.label',
182
        },
183
        'deserialize': lambda x: x,
184
        'rest_framework_field_class': A2ImageDRFField,
185
        'rest_framework_field_kwargs': {
186
            'read_only': True,
187
            },
188
        'value_is_relative_uri': True,
189
    },
154 190
]
155 191

  
156 192

  
src/authentic2/custom_user/apps.py
10 10
        from django.db.models.signals import post_migrate
11 11

  
12 12
        post_migrate.connect(
13
            self.create_first_name_last_name_attributes,
13
            self.create_custom_attributes,
14 14
            sender=self)
15 15

  
16
    def create_first_name_last_name_attributes(self, app_config, verbosity=2, interactive=True,
16
    def create_custom_attributes(self, app_config, verbosity=2, interactive=True,
17 17
                                               using=DEFAULT_DB_ALIAS, **kwargs):
18 18
        from django.utils import translation
19 19
        from django.utils.translation import ugettext_lazy as _
......
34 34
        content_type = ContentType.objects.get_for_model(User)
35 35

  
36 36
        attrs = {}
37
        attrs['avatar_picture'], created = Attribute.objects.get_or_create(
38
            name='avatar_picture',
39
            defaults={'kind': 'image',
40
                      'label': _('Avatar picture'),
41
                      'required': False,
42
                      'asked_on_registration': False,
43
                      'user_editable': True,
44
                      'user_visible': True})
37 45
        attrs['first_name'], created = Attribute.objects.get_or_create(
38 46
            name='first_name',
39 47
            defaults={'kind': 'string',
......
51 59
                      'user_editable': True,
52 60
                      'user_visible': True})
53 61

  
54
        serialize = get_kind('string').get('serialize')
55 62
        for user in User.objects.all():
56
            for attr_name in attrs:
63
            for at in attrs:
64
                serialize = get_kind(at.kind).get('serialize')
57 65
                av, created = AttributeValue.objects.get_or_create(
58 66
                    content_type=content_type,
59 67
                    object_id=user.id,
60
                    attribute=attrs[attr_name],
68
                    attribute=attrs[at],
61 69
                    defaults={
62 70
                        'multiple': False,
63 71
                        'verified': False,
64
                        'content': serialize(getattr(user, attr_name, None))
72
                        'content': serialize(getattr(user, at, None))
65 73
                    })
src/authentic2/custom_user/models.py
219 219
    def has_verified_attributes(self):
220 220
        return AttributeValue.objects.with_owner(self).filter(verified=True).exists()
221 221

  
222
    def to_json(self):
222
    def to_json(self, request=None):
223 223
        d = {}
224 224
        for av in AttributeValue.objects.with_owner(self):
225
            d[str(av.attribute.name)] = av.to_python()
225
            if request:
226
                d[str(av.attribute.name)] = av.to_python(request)
227
            else:
228
                d[str(av.attribute.name)] = av.to_python()
226 229
        d.update({
227 230
            'uuid': self.uuid,
228 231
            'username': self.username,
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
from django.forms.widgets import DateTimeInput, DateInput, TimeInput, \
15
        ClearableFileInput
15 16
from django.forms.widgets import PasswordInput as BasePasswordInput
16 17
from django.utils.formats import get_language, get_format
17 18
from django.utils.safestring import mark_safe
......
246 247
                    json.dumps(_id),
247 248
                )
248 249
        return output
250

  
251

  
252
class AttributeImageInput(ClearableFileInput):
253
    template_name = "authentic2/accounts_image.html"
254
    # Deprecated (django<=1.8 only):
255
    template_with_initial = (
256
        '%(initial_text)s: <br /> <img src="%(initial)s"/> <br />'
257
        '%(clear_template)s<br />%(input_text)s: %(input)s'
258
    )
259

  
260
    def is_initial(self, value):
261
        return bool(value)
262

  
263
    def get_template_substitution_values(self, value):
264
        subs_values = dict()
265
        subs_values.update({
266
            'initial': value,
267
        })
268
        return subs_values
src/authentic2/models.py
1
import logging
1 2
import time
2 3
import urlparse
3 4
import uuid
......
203 204
                return kind['default']
204 205

  
205 206
    def set_value(self, owner, value, verified=False):
207
        logger = logging.getLogger(__name__)
206 208
        serialize = self.get_kind()['serialize']
207 209
        # setting to None is to delete
208 210
        if value is None:
......
225 227
                    av.verified = verified
226 228
                    av.save()
227 229
        else:
228
            content = serialize(value)
230
            kwargs = dict()
231
            for key, flat_value in self.get_kind().get('serialize_eval_kwargs', {}).items():
232
                try:
233
                    evalue = eval(flat_value)
234
                except NameError:
235
                    logger.error("Couldn't evaluate {} for attribute <{}: {}>".format(
236
                            flat_value,
237
                            self.get_kind()['kind'],
238
                            self.label))
239
                    continue
240
                kwargs.update({key: evalue})
241
            if kwargs:
242
                content = serialize(value, **kwargs)
243
            else:
244
                content = serialize(value)
245

  
229 246
            av, created = AttributeValue.objects.get_or_create(
230 247
                    content_type=ContentType.objects.get_for_model(owner),
231 248
                    object_id=owner.pk,
......
275 292

  
276 293
    objects = managers.AttributeValueManager()
277 294

  
278
    def to_python(self):
279
        deserialize = self.attribute.get_kind()['deserialize']
280
        return deserialize(self.content)
295
    def to_python(self, request=None):
296
        kind = self.attribute.get_kind()
297
        deserialize = kind['deserialize']
298
        content = self.content
299
        if request and kind.get('value_is_relative_uri'):
300
            content = request.build_absolute_uri(content)
301
        return deserialize(content)
281 302

  
282 303
    def natural_key(self):
283 304
        if not hasattr(self.owner, 'natural_key'):
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 = []
......
132 134
    'xstatic.pkg.jquery',
133 135
    'xstatic.pkg.jquery_ui',
134 136
    'xstatic.pkg.select2',
137
    'sorl.thumbnail',
135 138
)
136 139

  
137 140
INSTALLED_APPS = tuple(plugins.register_plugins_installed_apps(INSTALLED_APPS))
src/authentic2/templates/authentic2/accounts.html
1 1
{% extends "authentic2/base-page.html" %}
2 2
{% load i18n %}
3
{% load thumbnail %}
4
{% load authentic2 %}
3 5

  
4 6
{% block page-title %}
5 7
  {{ block.super }} - {{ view.title }}
......
18 20
          {% for attribute in attributes %}
19 21
            <dt>{{ attribute.attribute.label|capfirst }}&nbsp;:</dt>
20 22
            <dd>
21
              {% if attribute.values|length == 1 %}
22
                {{ attribute.values.0 }}
23
              {% if attribute.attribute.kind == 'image' %}
24
                  {% get_image_url attribute.values.0 request as img_url %}
25
                  {% thumbnail img_url img_dimensions crop=img_cropping as thumb_im %}
26
                    <img class={{ attribute.attribute.name }} src="{{ thumb_im.url }}">
27
                  {% endthumbnail %}
23 28
              {% else %}
24
                <ul>
25
                  {% for value in attribute.values %}
26
                    <li>{{ value }}</li>
27
                  {% endfor %}
28
                </ul>
29
                {% if attribute.values|length == 1 %}
30
                  {{ attribute.values.0 }}
31
                {% else %}
32
                  <ul>
33
                    {% for value in attribute.values %}
34
                      <li>{{ value }}</li>
35
                    {% endfor %}
36
                  </ul>
37
                {% endif %}
29 38
              {% endif %}
30 39
            </dd>
31 40
          {% endfor %}
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/accounts_image.html
1
{% if widget.is_initial %}{{ widget.initial_text }}: <img src="{{ widget.value }}"/><br />
2
{% if not widget.required %}
3
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}" />
4
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>{% endif %}
5
{{ widget.input_text }}:{% endif %}
6
<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/templatetags/authentic2.py
1
from django import template
2

  
3
register = template.Library()
4

  
5

  
6
@register.assignment_tag
7
def get_image_url(relative_uri, request):
8
    try:
9
        return request.build_absolute_uri(relative_uri)
10
    except:
11
        return ''
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/utils.py
8 8
import uuid
9 9
import datetime
10 10
import copy
11
import fcntl
12
import os
13
import tempfile
11 14

  
12 15
from functools import wraps
13 16
from itertools import islice, chain, count
......
30 33
from django.template.loader import render_to_string, TemplateDoesNotExist
31 34
from django.core.mail import send_mail
32 35
from django.core import signing
36
from django.core.files.storage import default_storage
33 37
from django.core.urlresolvers import reverse, NoReverseMatch
34 38
from django.utils.formats import localize
35 39
from django.contrib import messages
......
1073 1077
        if ou_value is not None:
1074 1078
            return ou_value
1075 1079
    return default
1080

  
1081

  
1082
def _store_image(in_memory_image, owner_uuid, attr_label):
1083
    from hashlib import md5
1084

  
1085
    logger = logging.getLogger(__name__)
1086

  
1087
    img_media_dir = '{label}/{uuid_short}/'.format(
1088
            label=attr_label,
1089
            uuid_short=owner_uuid[0:4])
1090
    img_media_path = '{imdir}/{uuid}{hexdigest}.{ext}'.format(
1091
            imdir=img_media_dir,
1092
            uuid=owner_uuid,
1093
            hexdigest = md5(in_memory_image.read()).hexdigest(),
1094
            ext=in_memory_image.image.format)
1095

  
1096
    if not default_storage.exists(img_media_dir):
1097
        os.makedirs(default_storage.path(img_media_dir))
1098

  
1099
    with open(default_storage.path(img_media_path), 'wb') as f:
1100
        try:
1101
            fcntl.lockf(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
1102
            with tempfile.NamedTemporaryFile(
1103
                    mode='wb',
1104
                    dir=default_storage.path(img_media_dir),
1105
                    delete=False) as temp:
1106
                in_memory_image.seek(0)
1107
                temp.write(in_memory_image.read())
1108
                temp.flush()
1109
                os.rename(temp.name, default_storage.path(img_media_path))
1110
        except:
1111
            logger.error("Couldn't store file {}".format(img_media_path))
1112
        finally:
1113
            fcntl.lockf(f, fcntl.LOCK_UN)
1114

  
1115
    return img_media_path
1116

  
1117

  
1118
def _delete_images_from_user(owner_pk, attr_label):
1119
    from .models import Attribute, AttributeValue
1120

  
1121
    logger = logging.getLogger(__name__)
1122
    User = get_user_model()
1123

  
1124
    try:
1125
        owner = User.objects.get(pk=owner_pk)
1126
    except User.DoesNotExist:
1127
        logger.error("Primary key {} doesn't match with any user.".format(owner_pk))
1128
        return
1129

  
1130
    try:
1131
        attr = Attribute.objects.get(label=attr_label)
1132
        all_values =  AttributeValue.objects.with_owner(owner)
1133
        values = all_values.filter(attribute=attr)
1134
    except:
1135
        logger.error("Couldn't retrieve values for Attribute {}.".format(attr_label))
1136

  
1137
    for value in values:
1138
        # Direct URI <-> file location correspondence
1139
        local_file = value.content.split(default_storage.base_url)[-1]
1140
        if not local_file:
1141
            continue
1142
        media_file = default_storage.path(local_file)
1143

  
1144
        try:
1145
            os.remove(media_file)
1146
            value.delete()
1147
        except:
1148
            logger.error("Couldn't delete image {}".format(media_file))
1149

  
1150

  
1151
def image_serialize(image, owner_uuid, owner_pk, attr_label):
1152
    uri = ''
1153
    if isinstance(image, basestring):
1154
        uri = image
1155
    else:
1156
        # Discard previous user avatars
1157
        _delete_images_from_user(owner_pk, attr_label)
1158
        if image:
1159
            img_media_path = _store_image(image, owner_uuid, attr_label)
1160
            uri = os.path.join(settings.MEDIA_URL, img_media_path)
1161
    return uri
src/authentic2/views.py
402 402
        return super(ProfileView, self).dispatch(request, *args, **kwargs)
403 403

  
404 404
    def get_context_data(self, **kwargs):
405
        from . import attribute_kinds
406

  
405 407
        context = super(ProfileView, self).get_context_data(**kwargs)
406 408
        frontends = utils.get_backends('AUTH_FRONTENDS')
407 409

  
......
502 504
            # TODO: deprecated should be removed when publik-base-theme is updated
503 505
            'allow_password_change': request.user.can_change_password(),
504 506
            'federation_management': federation_management,
507
            'img_dimensions': attribute_kinds.get_image_dimensions(),
508
            'img_cropping': attribute_kinds.get_image_cropping(),
509
            'host': request.build_absolute_uri('/')[0:-1],
505 510
        })
506 511
        hooks.call_hooks('modify_context_data', self, context)
507 512
        return context
tests/conftest.py
339 339
    activate('fr')
340 340
    yield
341 341
    deactivate()
342

  
343
@pytest.fixture(autouse=True)
344
def authentic_settings(settings, tmpdir):
345
    settings.MEDIA_ROOT = str(tmpdir)
... Ce différentiel a été tronqué car il excède la taille maximale pouvant être affichée.