Projet

Général

Profil

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

Benjamin Dauvergne, 23 octobre 2018 19:16

Télécharger (29,6 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             |  70 +++++++++++++++++-
 .../attributes_ng/sources/django_user.py      |   9 ++-
 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 +
 src/authentic2_idp_oidc/utils.py              |   9 ++-
 src/authentic2_idp_oidc/views.py              |  17 ++++-
 tests/200x200.jpg                             | Bin 0 -> 317 bytes
 tests/201x201.jpg                             | Bin 0 -> 330 bytes
 tests/conftest.py                             |   5 ++
 tests/test_attribute_kinds.py                 |  64 ++++++++++++++++
 tests/test_idp_oidc.py                        |  21 +++++-
 tests/test_idp_saml2.py                       |  19 +++++
 tests/utils.py                                |   5 ++
 23 files changed, 299 insertions(+), 18 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

  
147
def profile_attributes_ng_serialize(ctx, value):
148
    if value and getattr(value, 'url', None):
149
        request = ctx.get('request')
150
        if request:
151
            return request.build_absolute_uri(value.url)
152
        else:
153
            return value.url
154
    return None
155

  
156

  
103 157
DEFAULT_ALLOW_BLANK = True
104 158
DEFAULT_MAX_LENGTH = 256
105 159

  
......
160 214
        'field_class': PhoneNumberField,
161 215
        'rest_framework_field_class': PhoneNumberDRFField,
162 216
    },
217
    {
218
        'label': _('profile image'),
219
        'name': 'profile_image',
220
        'field_class': fields.ProfileImageField,
221
        'serialize': profile_image_serialize,
222
        'deserialize': profile_image_deserialize,
223
        'rest_framework_field_class': serializers.FileField,
224
        'rest_framework_field_kwargs': {
225
            'read_only': True,
226
            'use_url': True,
227
        },
228
        'html_value': profile_image_html_value,
229
        'attributes_ng_serialize': profile_attributes_ng_serialize,
230
    },
163 231
]
164 232

  
165 233

  
src/authentic2/attributes_ng/sources/django_user.py
49 49

  
50 50

  
51 51
def get_dependencies(instance, ctx):
52
    return ('user',)
52
    return ('user', 'request')
53 53

  
54 54

  
55 55
def get_attributes(instance, ctx):
......
68 68
    if user.ou:
69 69
        for attr in ('uuid', 'slug', 'name'):
70 70
            ctx['django_user_ou_' + attr] = getattr(user.ou, attr)
71
    for av in AttributeValue.objects.with_owner(user):
72
        ctx['django_user_' + str(av.attribute.name)] = av.to_python()
71
    for av in AttributeValue.objects.with_owner(user).select_related('attribute'):
72
        serialize = av.attribute.get_kind().get('attributes_ng_serialize', lambda a, b: b)
73
        value = av.to_python()
74
        serialized = serialize(ctx, value)
75
        ctx['django_user_' + str(av.attribute.name)] = serialized
73 76
        ctx['django_user_' + str(av.attribute.name) + ':verified'] = av.verified
74 77
    ctx['django_user_groups'] = [group for group in user.groups.all()]
75 78
    ctx['django_user_group_names'] = [unicode(group) for group in user.groups.all()]
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:
src/authentic2_idp_oidc/utils.py
161 161
    return values_list
162 162

  
163 163

  
164
def create_user_info(client, user, scope_set, id_token=False):
164
def create_user_info(request, client, user, scope_set, id_token=False):
165 165
    '''Create user info dictionnary'''
166 166
    user_info = {
167 167
        'sub': make_sub(client, user)
168 168
    }
169 169
    attributes = get_attributes({
170
        'user': user, 'request': None, 'service': client,
171
        '__wanted_attributes': client.get_wanted_attributes()})
170
        'user': user,
171
        'request': request,
172
        'service': client,
173
        '__wanted_attributes': client.get_wanted_attributes(),
174
    })
172 175
    for claim in client.oidcclaim_set.filter(name__isnull=False):
173 176
        if not set(claim.get_scopes()).intersection(scope_set):
174 177
            continue
src/authentic2_idp_oidc/views.py
279 279
        acr = '0'
280 280
        if nonce is not None and last_auth.get('nonce') == nonce:
281 281
            acr = '1'
282
        id_token = utils.create_user_info(client, request.user, scopes, id_token=True)
282
        id_token = utils.create_user_info(request,
283
                                          client,
284
                                          request.user,
285
                                          scopes,
286
                                          id_token=True)
283 287
        id_token.update({
284 288
            'iss': utils.get_issuer(request),
285 289
            'aud': client.client_id,
......
386 390
            oidc_code.nonce):
387 391
        acr = '1'
388 392
    # prefill id_token with user info
389
    id_token = utils.create_user_info(client, oidc_code.user, oidc_code.scope_set(), id_token=True)
393
    id_token = utils.create_user_info(
394
        request,
395
        client,
396
        oidc_code.user,
397
        oidc_code.scope_set(),
398
        id_token=True)
390 399
    id_token.update({
391 400
        'iss': utils.get_issuer(request),
392 401
        'sub': utils.make_sub(client, oidc_code.user),
......
430 439
    access_token = authenticate_access_token(request)
431 440
    if access_token is None:
432 441
        return HttpResponse('unauthenticated', status=401)
433
    user_info = utils.create_user_info(access_token.client, access_token.user,
442
    user_info = utils.create_user_info(request,
443
                                       access_token.client,
444
                                       access_token.user,
434 445
                                       access_token.scope_set())
435 446
    return HttpResponse(json.dumps(user_info), content_type='application/json')
436 447

  
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
tests/test_idp_oidc.py
11 11
import utils
12 12

  
13 13
from django.core.urlresolvers import reverse
14
from django.core.files import File
14 15
from django.db import connection
15 16
from django.db.migrations.executor import MigrationExecutor
16 17
from django.utils.timezone import now
......
19 20

  
20 21
User = get_user_model()
21 22

  
23
from authentic2.models import Attribute
22 24
from authentic2_idp_oidc.models import OIDCClient, OIDCAuthorization, OIDCCode, OIDCAccessToken, OIDCClaim
23 25
from authentic2_idp_oidc.utils import make_sub
24 26
from authentic2.a2_rbac.utils import get_default_ou
......
98 100

  
99 101

  
100 102
@pytest.fixture(params=OIDC_CLIENT_PARAMS)
101
def oidc_client(request, superuser, app):
103
def oidc_client(request, superuser, app, simple_user, media):
104
    Attribute.objects.create(
105
        name='cityscape_image',
106
        label='cityscape',
107
        kind='profile_image',
108
        asked_on_registration=True,
109
        required=False,
110
        user_visible=True,
111
        user_editable=True)
112

  
102 113
    url = reverse('admin:authentic2_idp_oidc_oidcclient_add')
103 114
    assert OIDCClient.objects.count() == 0
104 115
    response = utils.login(app, superuser, path=url)
......
256 267
    # when adding extra attributes
257 268
    OIDCClaim.objects.create(client=oidc_client, name='ou', value='django_user_ou_name', scopes='profile')
258 269
    OIDCClaim.objects.create(client=oidc_client, name='roles', value='a2_role_names', scopes='profile, role')
270
    OIDCClaim.objects.create(client=oidc_client,
271
                             name='cityscape_image',
272
                             value='django_user_cityscape_image',
273
                             scopes='profile')
259 274
    simple_user.roles.add(get_role_model().objects.create(
260 275
        name='Whatever', slug='whatever', ou=get_default_ou()))
261 276
    response = app.get(user_info_url, headers=bearer_authentication_headers(access_token))
262 277
    assert response.json['ou'] == simple_user.ou.name
263 278
    assert response.json['roles'][0] == 'Whatever'
279
    assert response.json.get('cityscape_image') is None
280
    simple_user.attributes.cityscape_image = File(open('tests/200x200.jpg'))
281
    response = app.get(user_info_url, headers=bearer_authentication_headers(access_token))
282
    assert response.json['cityscape_image'].startswith('http://testserver/media/profile-image/')
264 283

  
265 284
    # check against a user without username
266 285
    simple_user.username = None
tests/test_idp_saml2.py
1
import re
1 2
import datetime
2 3
import base64
3 4
import unittest
......
9 10
from django.test.utils import override_settings
10 11
from django.contrib.auth import get_user_model, REDIRECT_FIELD_NAME
11 12
from django.core.urlresolvers import reverse
13
from django.core.files import File
12 14
from django.utils.translation import gettext as _
13 15

  
14 16
from authentic2.saml import models as saml_models
......
85 87
        self.code_attribute = Attribute.objects.create(kind='string', name='code', label='Code')
86 88
        self.mobile_attribute = Attribute.objects.create(kind='string', name='mobile',
87 89
                                                         label='Mobile')
90
        self.avatar_attribute = Attribute.objects.create(
91
            kind='profile_image',
92
            name='avatar',
93
            label='Avatar')
88 94
        self.user = get_user_model().objects.create(
89 95
            email=self.email,
90 96
            username=self.username,
......
92 98
            last_name=self.last_name)
93 99
        self.code_attribute.set_value(self.user, '1234', verified=True)
94 100
        self.mobile_attribute.set_value(self.user, '5678', verified=True)
101
        self.avatar_attribute.set_value(self.user, File(open('tests/200x200.jpg')))
95 102
        self.user.set_password(self.password)
96 103
        self.user.save()
97 104
        self.default_ou = OrganizationalUnit.objects.get()
......
154 161
            name='verified_attributes',
155 162
            friendly_name='Verified attributes',
156 163
            attribute_name='@verified_attributes@')
164
        self.liberty_provider.attributes.create(
165
            name_format='basic',
166
            name='avatar',
167
            friendly_name='Avatar',
168
            attribute_name='django_user_avatar')
157 169
        self.role_authorized = Role.objects.create(name='PC Delta', slug='pc-delta')
158 170
        self.liberty_provider.unauthorized_url = 'https://whatever.com/loser/'
159 171
        self.liberty_provider.save()
......
406 418
                ("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='mobile']/"
407 419
                    "saml:AttributeValue", '5678'),
408 420

  
421
                ("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='avatar']/"
422
                    "@NameFormat", lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC),
423
                ("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='avatar']/"
424
                    "@FriendlyName", 'Avatar'),
425
                ("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='avatar']/"
426
                    "saml:AttributeValue", re.compile('^http://testserver/media/profile-image/.*$')),
427

  
409 428
                ("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='verified_attributes']/"
410 429
                    "@NameFormat", lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC),
411 430
                ("/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name='verified_attributes']/"
tests/utils.py
121 121
                    self.assertEqual(set(values), content)
122 122
                elif isinstance(content, list):
123 123
                    self.assertEqual(values, content)
124
                elif hasattr(content, 'pattern'):
125
                    for value in values:
126
                        self.assertRegexpMatches(
127
                            value, content,
128
                            msg='xpath %s does not match regexp %s' % (xpath, content.pattern))
124 129
                else:
125 130
                    raise NotImplementedError('comparing xpath result to type %s: %r is not '
126 131
                                              'implemented' % (type(content), content))
127
-