add account validation subsystem (fixes #23303, fixes #17650)

This commit is contained in:
Benjamin Dauvergne 2018-04-23 14:52:33 +02:00
parent 0a9efb7684
commit c73c5b400a
14 changed files with 615 additions and 5 deletions

3
debian/control vendored
View File

@ -10,6 +10,7 @@ X-Python-Version: >= 2.6
Package: python-authentic2-cut
Architecture: all
Depends: ${misc:Depends},
python-authentic2 (>= 2.1.20.1054)
python-authentic2 (>= 2.1.20.1054),
python-pil
Description: CUT plugin for Authentic2

View File

@ -96,6 +96,8 @@ setup(
},
install_requires=[
'authentic2',
'Pillow',
'python-magic',
],
entry_points={
'authentic2.plugin': [

View File

@ -0,0 +1,16 @@
from django.contrib import admin
from . import models
class AttachmentsInline(admin.TabularInline):
model = models.ValidationRequestAttachment
class ValidationRequestAdmin(admin.ModelAdmin):
list_display = ('created', 'user', 'origin', 'status', 'validated')
readonly_fields = ['created']
inlines = [AttachmentsInline]
admin.site.register(models.ValidationRequest, ValidationRequestAdmin)

View File

@ -0,0 +1,195 @@
# -*- coding: utf-8 -*-
# authentic2_cut - Authentic2 plugin for CUT
# Copyright (C) 2017 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import magic
import uuid
from django.core.files.base import ContentFile
from django.db.transaction import atomic
from django.conf import settings
from django.db import IntegrityError
from django.core.exceptions import ValidationError
from rest_framework.generics import ListAPIView
from rest_framework import serializers, pagination, permissions
from rest_framework.decorators import list_route, detail_route
from rest_framework.response import Response
from rest_framework import status
from django_filters.rest_framework import FilterSet
from authentic2_idp_oidc.utils import make_pairwise_reversible_sub
from authentic2 import api_views
from . import models
class Base64ImageField(serializers.FileField):
def to_internal_value(self, data):
# base64 encoded image - decode
if not isinstance(data, unicode):
raise ValidationError('must be base64 string')
id = uuid.uuid4()
try:
content = base64.b64decode(data)
except:
raise ValidationError('invalid base64')
data = ContentFile(content, name = id.urn[9:] + '.jpg')
return super(Base64ImageField, self).to_internal_value(data)
class CUTValidateAttachmentSerializer(serializers.Serializer):
b64_content = Base64ImageField()
class CUTCreateValidateSerializer(serializers.Serializer):
justificatifs = serializers.ListField(
child=CUTValidateAttachmentSerializer(),
allow_empty=False)
external_id = serializers.CharField()
def get_origin(request):
origin = request.user
if hasattr(origin, 'oidc_client'):
origin = origin.oidc_client
return origin
class CUTValidateSerializer(serializers.ModelSerializer):
sub = serializers.SerializerMethodField()
def get_sub(self, instance):
request = self.context['request']
origin = get_origin(request)
if hasattr(origin, 'identifier_policy'):
oidc_client = origin
if oidc_client.identifier_policy == oidc_client.POLICY_PAIRWISE_REVERSIBLE:
return make_pairwise_reversible_sub(oidc_client, instance.user)
else:
return ''
return instance.user.uuid
class Meta:
model = models.ValidationRequest
fields = ('id', 'created', 'external_id', 'status', 'reason', 'validated', 'sub')
@detail_route(methods=['get', 'post'], url_path='validate', permission_classes=(api_views.DjangoPermission('custom_user.cut_validate_user'),))
def validate_cut(self, request, uuid):
user = self.get_object()
origin = get_origin(request)
if request.method == 'GET':
qs = models.ValidationRequest.objects.for_origin(origin).filter(user=user).select_related('user')
serializer = CUTValidateSerializer(qs, many=True, context={'request': request})
return Response({
'result': 1,
'next': None,
'previous': None,
'results': serializer.data,
})
serializer = CUTCreateValidateSerializer(data=request.data)
max_size = getattr(settings, 'CUT_VALIDATION_ATTACHMENT_MAX_SIZE', 300 * 1024 * 1024)
if serializer.is_valid():
errors = []
attachments = serializer.validated_data['justificatifs']
external_id = serializer.validated_data['external_id']
for i, attachment in enumerate(attachments):
content = attachment['b64_content']
if content.size > max_size:
errors.append({
'code': 'justificatifs-too-big',
'page': i,
'max-size': max_size,
})
if magic.from_buffer(content.read(10000), mime=True) != 'image/jpeg':
errors.append({
'code': 'justificatifs-bad-format',
'page': i,
'accepted': ['image/jpeg'],
})
# rewind cursor
content.seek(0)
if models.ValidationRequest.objects.filter(
user=user, external_id=external_id).exists():
errors.append({
'code': 'already-exists',
})
if errors:
response = {'result': 0, 'errors': errors}
return Response(response, status.HTTP_400_BAD_REQUEST)
with atomic():
try:
validation_request = models.ValidationRequest.objects.create(
user=user, external_id=external_id, origin=origin)
except IntegrityError as e:
response = {'result': 0, 'errors': [{'code': 'already-exists'}]}
return Response(response, status.HTTP_400_BAD_REQUEST)
for attachment in attachments:
models.ValidationRequestAttachment.objects.create(
validation_request=validation_request,
image=attachment['b64_content'])
return Response({
'result': 1,
'status': 'received',
'sub': uuid,
'id': validation_request.id,
'external_id': external_id,
})
else:
errors = []
for key in serializer.errors:
errors.append({'code': 'schema-error', 'field': key, 'sub-errors': serializer.errors[key]})
response = {'result': 0, 'errors': errors}
return Response(response, status.HTTP_400_BAD_REQUEST)
# attach new API to users' api
api_views.UsersAPI.validate_cut = validate_cut
# re-register UsersAPI to get validate_cut
#api_views.router.register(r'users', api_views.UsersAPI, base_name='a2-api-users')
class ValidateFilter(FilterSet):
class Meta:
model = models.ValidationRequest
fields = {
'validated': {
'gt',
}
}
class ValidateAPI(api_views.ExceptionHandlerMixin, ListAPIView):
methods = ['GET']
ordering_fields = ['id']
serializer_class = CUTValidateSerializer
pagination_class = pagination.CursorPagination
ordering = ['id']
filter_class = ValidateFilter
permission_classes = (api_views.DjangoPermission('custom_user.cut_validate_user'),)
def get_queryset(self):
qs = models.ValidationRequest.objects
origin = get_origin(self.request)
qs = qs.for_origin(origin).select_related('user')
return qs
validate = ValidateAPI.as_view()

View File

@ -43,6 +43,7 @@ class AppConfig(django.apps.AppConfig):
def ready(self):
from django.db.models.signals import post_migrate, post_save
from django_rbac.utils import get_ou_model
from . import api_views
post_migrate.connect(
self.post_migrate,
@ -72,7 +73,7 @@ class AppConfig(django.apps.AppConfig):
return user_datas
def a2_hook_manager_modify_form(self, view, form):
from django.forms.widgets import DateTimeInput, HiddenInput
from django.forms.widgets import DateTimeInput, HiddenInput, Textarea
from authentic2.passwords import generate_password
from . import models
@ -106,6 +107,7 @@ class AppConfig(django.apps.AppConfig):
form.fields['creation_partner'].initial = (
view.request.user.ou.name if view.request.user.ou else 'super-utilisateur')
form.fields['creation_partner'].widget = HiddenInput()
form.fields['comment'].widget = Textarea(attrs={'rows': 4})
del form.fields['creation_domain']
else:
del form.fields['creation_domain']
@ -134,6 +136,7 @@ class AppConfig(django.apps.AppConfig):
'validation_context', 'validation_date', 'ou']:
if field_name in form.fields:
del form.fields[field_name]
form.fields['comment'].widget = Textarea(attrs={'rows': 4})
else:
for field_name in form.fields:
if field_name not in ['username',
@ -197,6 +200,7 @@ class AppConfig(django.apps.AppConfig):
'creation_partner', 'creation_domain']:
if field_name in form.fields:
del form.fields[field_name]
form.fields['comment'].widget = Textarea(attrs={'readonly': '', 'rows': 4})
else:
for field_name in form.fields:
if field_name not in ['username', 'first_name', 'last_name', 'email']:

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# authentic2_cut - Authentic2 plugin for CUT
# Copyright (C) 2017 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django import forms
from . import models
class ValidationForm(forms.Form):
reason = forms.ChoiceField(
choices=models.ValidationRequest.REASON_CHOICES,
label=u'Raison du refus')

View File

@ -0,0 +1,39 @@
import os
from django.core.management.base import BaseCommand
from django.core.files.base import ContentFile
from django.core.urlresolvers import reverse
from django.contrib.auth import get_user_model
from django.db.transaction import atomic
from authentic2_idp_oidc.models import OIDCClient
from authentic2_cut.models import ValidationRequest, ValidationRequestAttachment
class Command(BaseCommand):
help = 'Create validation requests'
def add_arguments(self, parser):
parser.add_argument('--ou-slug')
parser.add_argument('--oidc-client-slug')
parser.add_argument('--user-pk', type=int)
parser.add_argument('paths', nargs='+')
def handle(self, ou_slug, oidc_client_slug, user_pk, paths, **options):
oidc_client = OIDCClient.objects.get(ou__slug=ou_slug, slug=oidc_client_slug)
User = get_user_model()
user = User.objects.get(pk=user_pk)
with atomic():
validation_request = ValidationRequest.objects.create(
user=user,
origin=oidc_client)
for path in paths:
with open(path) as file_object:
filename = os.path.basename(path)
f = ContentFile(file_object.read(), name=filename)
ValidationRequestAttachment.objects.create(
validation_request=validation_request,
image=f)
self.stdout.write('New validation request %s' % validation_request.pk)

View File

@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('authentic2_cut', '0002_auto_20171116_1052'),
]
operations = [
migrations.CreateModel(
name='ValidationRequest',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Date de cr\xe9ation', db_index=True)),
('origin_id', models.PositiveIntegerField(verbose_name=b'origin id')),
('status', models.CharField(default=b'received', max_length=16, verbose_name='Statut', choices=[(b'received', 're\xe7u'), (b'accepted', 'accept\xe9'), (b'refused', 'refus\xe9')])),
('reason', models.TextField(blank=True, verbose_name='Raison du refus', choices=[(b'unreadable', 'pi\xe8ce(s) illisible(s)'), (b'invalid', 'pi\xe8ce(s) invalides(s)'), (b'underaged', 'invividu mineur')])),
('validated', models.DateTimeField(null=True, verbose_name='Date de validation')),
('external_id', models.TextField(null=True, verbose_name='Identifiant externe', blank=True)),
('taken', models.DateTimeField(null=True, verbose_name='En cours')),
('origin_ct', models.ForeignKey(verbose_name=b'origin ct', to='contenttypes.ContentType')),
('taken_by', models.ForeignKey(related_name='validation_requests_taken', verbose_name='Trait\xe9 par', to=settings.AUTH_USER_MODEL, null=True)),
('user', models.ForeignKey(related_name='validation_requests', verbose_name='Utilisateur', to=settings.AUTH_USER_MODEL)),
('validated_by', models.ForeignKey(related_name='validation_requests_validated', verbose_name='Valid\xe9/refus\xe9 par', to=settings.AUTH_USER_MODEL, null=True)),
],
options={
'ordering': ('-created',),
'verbose_name': 'Requ\xeate de validation',
'verbose_name_plural': 'Requ\xeates de validation',
},
),
migrations.CreateModel(
name='ValidationRequestAttachment',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('image', models.ImageField(upload_to=b'', verbose_name=b'contenu')),
('validation_request', models.ForeignKey(related_name='attachments', verbose_name='requ\xeate de validation', to='authentic2_cut.ValidationRequest')),
],
options={
'ordering': ('pk',),
'verbose_name': 'Pi\xe8ce jointe',
'verbose_name_plural': 'Pi\xe8ces jointes',
},
),
migrations.AlterUniqueTogether(
name='validationrequest',
unique_together=set([('origin_ct', 'origin_id', 'user', 'external_id')]),
),
]

View File

@ -1,5 +1,14 @@
# -*- coding: utf-8 -*-
from datetime import timedelta
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.conf import settings
from django.db.models.query import Q
from django.utils.timezone import now
from django.contrib.contenttypes.fields import GenericForeignKey
class Journal(models.Model):
@ -23,3 +32,127 @@ class Journal(models.Model):
verbose_name = 'historique'
verbose_name_plural = 'historiques'
ordering = ('-timestamp', '-id')
class ValidationRequestManager(models.Manager):
def for_origin(self, origin):
ct = ContentType.objects.get_for_model(origin)
pk = origin.pk
return self.filter(origin_ct=ct, origin_id=pk)
def next_request(self, user, after=None):
qs = self.select_for_update()
qs = qs.order_by('pk')
# la demande est nouvelle
# la demande est en cours de traitement mais
# aucune date de prise en main n'est posée
# la demande est prise depuis plus d'une heure
# la demande est prise par <user>
qs = qs.filter(
Q(status=ValidationRequest.STATUS_RECEIVED)
& (Q(taken__isnull=True)
| Q(taken__lt=now() - timedelta(seconds=3600))
| Q(taken_by=user)))
# on veut la prochaine demande à traiter
if after:
qs = qs.filter(pk__gt=after.pk)
validation_request = qs[:1].first()
if validation_request:
# la demande est associée à l'utilisateur en cours
validation_request.taken_by = user
validation_request.taken = now()
validation_request.save()
return validation_request
def received(self):
return self.filter(status=ValidationRequest.STATUS_RECEIVED)
class ValidationRequest(models.Model):
STATUS_RECEIVED = 'received'
STATUS_ACCEPTED = 'accepted'
STATUS_REFUSED = 'refused'
STATUS_CHOICES = (
(STATUS_RECEIVED, u'reçu'),
(STATUS_ACCEPTED, u'accepté'),
(STATUS_REFUSED, u'refusé'),
)
REASON_UNREADABLE = 'unreadable'
REASON_INVALID = 'invalid'
REASON_UNDERAGED = 'underaged'
REASON_CHOICES = (
(REASON_UNREADABLE, u'pièce(s) illisible(s)'),
(REASON_INVALID, u'pièce(s) invalides(s)'),
(REASON_UNDERAGED, u'invividu mineur'),
)
created = models.DateTimeField(
verbose_name=u'Date de création',
db_index=True,
auto_now_add=True)
origin_ct = models.ForeignKey(
'contenttypes.ContentType',
verbose_name='origin ct')
origin_id = models.PositiveIntegerField(
verbose_name='origin id')
origin = GenericForeignKey('origin_ct', 'origin_id')
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=u'Utilisateur',
related_name='validation_requests')
status = models.CharField(
max_length=16,
choices=STATUS_CHOICES,
verbose_name=u'Statut',
default=STATUS_RECEIVED)
reason = models.TextField(
choices=REASON_CHOICES,
blank=True,
verbose_name=u'Raison du refus')
validated = models.DateTimeField(
null=True,
verbose_name=u'Date de validation')
validated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=u'Validé/refusé par',
null=True,
related_name='validation_requests_validated')
external_id = models.TextField(
blank=True,
null=True,
verbose_name=u'Identifiant externe')
taken = models.DateTimeField(
verbose_name=u'En cours',
null=True)
taken_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=u'Traité par',
null=True,
related_name='validation_requests_taken')
objects = ValidationRequestManager()
class Meta:
verbose_name = u'Requête de validation'
verbose_name_plural = u'Requêtes de validation'
ordering = ('-created',)
unique_together = (
('origin_ct', 'origin_id', 'user', 'external_id'),
)
class ValidationRequestAttachment(models.Model):
validation_request = models.ForeignKey(
ValidationRequest,
verbose_name=u'requête de validation',
related_name='attachments')
image = models.ImageField(
verbose_name='contenu')
class Meta:
ordering = ('pk',)
verbose_name = u'Pièce jointe'
verbose_name_plural = u'Pièces jointes'

View File

@ -0,0 +1,49 @@
{% extends "authentic2/manager/form.html" %}
{% load i18n %}
{% block appbar %}
<h2>Validation d'identité</h2>
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
<a href="{% url 'a2-manager-users' %}{% if multiple_ou and object.ou %}?search-ou={{ object.ou.pk }}{% endif %}">{% trans 'Users' %}{% if multiple_ou and object.ou %}&nbsp;: {{ object.ou }}{% endif %}</a>
<a href="{% url 'a2-manager-user-detail' pk=object.pk %}">{{ object.get_full_name }}</a>
<a href="#">Validation d'identité</a>
{% endblock %}
{% block content %}
<p>Validation demandée par {{ validation_request.origin }} le {{ validation_request.created }}, identifiant externe de la demande <em>{{ validation_request.external_id }}</em>.</p>
{% if validation_request.taken_by != user %}
<p>Demande en cours de traitement par {{ validation_request.taken_by }} depuis le {{ validation_request.taken }}</p>
{% endif %}
<div>
{% if attachment_urls %}
<h4>Pièces jointes</h4>
<ul>
{% for attachment_url in attachment_urls %}
<li><a target="_blank" href="{{ attachment_url }}">Pièce jointe {{ forloop.counter }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
<h4>Identité</h4>
<div>
{{ block.super }}
</div>
{% endblock %}
{% block buttons %}
<div class="buttons">
<button name="validate">Valider</button>
</div>
<h4>Refus</h4>
{{ validation_form.as_p }}
<div class="buttons">
<button name="refuse">Refuser</button>
<button style="float: right; margin-right: 30px" name="next">Suivant</button>
</div>
{% endblock %}

View File

@ -17,7 +17,7 @@
from django.conf.urls import patterns, url
from authentic2.manager.urls import manager_login_required
from . import views
from . import views, api_views
from authentic2.decorators import required
urlpatterns = required(
@ -35,6 +35,12 @@ urlpatterns = required(
name='cut-manager-user-actions-journal'),
url('^manage/users/(?P<pk>\d+)/modifications-journal/$', views.user_modifications_journal,
name='cut-manager-user-modifications-journal'),
url('^manage/validation/next/$', views.next_validation,
name='cut-manager-user-next-validation'),
url('^manage/validation/(?P<pk>\d+)/$', views.validation,
name='cut-manager-user-validation'),
url('^manage/validation/attachment/(?P<pk>\d*)/$', views.validation_attachment,
name='cut-manager-user-validation-attachment'),
)
)
@ -42,4 +48,5 @@ urlpatterns += patterns(
'',
url('^cgu/$', views.cgu,
name='cut-cgu'),
url('^api/validate/$', api_views.validate, name='api-cut-validate'),
)

View File

@ -15,19 +15,24 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.utils.timezone import now
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.views.generic.base import TemplateView
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.shortcuts import get_object_or_404
from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.db.transaction import atomic
from authentic2.views import EditProfile
from authentic2.manager.views import SimpleSubTableView
from authentic2.manager.user_views import UserEditView, UserDetailView
from authentic2 import hooks
from authentic2.utils import redirect
from .custom_settings import CORE_ATTRIBUTES
from . import tables
from . import tables, models, forms
class EditCoreView(EditProfile):
@ -166,3 +171,81 @@ class CGU(TemplateView):
return super(CGU, self).get_context_data(**kwargs)
cgu = CGU.as_view()
def next_validation(request):
if not request.user.is_authenticated() or not request.user.has_perm_any('custom_user.cut_validate_user'):
raise PermissionDenied
with atomic():
validation_request = models.ValidationRequest.objects.next_request(request.user)
if not validation_request:
messages.info(request, u'Il n\'y aucune demande de validation en ce moment.')
return redirect(request, 'a2-manager-homepage')
return redirect(request, 'cut-manager-user-validation',
kwargs={'pk': validation_request.pk})
class Validation(UserEditCoreView):
template_name = 'authentic2/cut_manager_user_validation.html'
permissions = ['custom_user.cut_validate_user']
def get_object(self, queryset=None):
qs = models.ValidationRequest.objects.received()
self.validation_request = super(Validation, self).get_object(queryset=qs)
return self.validation_request.user
def dispatch(self, request, *args, **kwargs):
# si la demande en cours a déjà été traitée, on passe à la suivante
try:
return super(Validation, self).dispatch(request, *args, **kwargs)
except Http404:
return redirect(request, 'cut-manager-user-next-validation')
def get_context_data(self, **kwargs):
ctx = super(Validation, self).get_context_data(**kwargs)
ctx['validation_request'] = self.validation_request
ctx['attachment_urls'] = [reverse('cut-manager-user-validation-attachment', kwargs={'pk': attachment.pk}) for attachment in self.validation_request.attachments.all()]
ctx['action'] = u'Valider'
ctx['validation_form'] = forms.ValidationForm()
return ctx
def post(self, request, *args, **kwargs):
if 'refuse' in request.POST:
reason = request.POST.get('reason')
if not reason:
messages.error(request, u'Vous devez préciser une raison pour le refus')
return HttpResponseRedirect('')
self.validation_request.reason = reason
self.validation_request.status = models.ValidationRequest.STATUS_REFUSED
self.validation_request.validated = now()
self.validation_request.validated_by = request.user
self.validation_request.save()
elif 'validate' in request.POST:
super(Validation, self).post(request, *args, **kwargs)
with atomic():
validation_request = models.ValidationRequest.objects.next_request(
request.user, after=self.validation_request)
# on ne trouve plus de requête de validation après celle-ci
# recommençons du début
if not validation_request:
return redirect(request, 'cut-manager-user-next-validation')
return redirect(request, 'cut-manager-user-validation',
kwargs={'pk': validation_request.pk})
def form_valid(self, form):
self.validation_request.status = models.ValidationRequest.STATUS_ACCEPTED
self.validation_request.validated = now()
self.validation_request.validated_by = self.request.user
self.validation_request.save()
if hasattr(self.validation_request.origin, 'ou') and self.validation_request.origin.ou:
forn.instance.attributes.validation_partner = self.validation_request.origin.ou.slug
return super(Validation, self).form_valid(form)
validation = Validation.as_view()
def validation_attachment(request, pk):
if not request.user.is_authenticated() or not request.user.has_perm_any('custom_user.cut_validate_user'):
raise PermissionDenied
attachment = models.ValidationRequestAttachment.objects.get(pk=pk)
return HttpResponse(attachment.image, content_type='image/jpeg')