From a973305ba8f07821a4d1b0bed1b49e04156cb222 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 29 Apr 2015 14:43:46 +0200 Subject: [PATCH] Add a send-to view (fixes #7080) To you it, make a POST using the multipart/form-data content type on /send-to/?email= Files are transmitted in the 'documents' fields of multipart/form-data body. Each file must have a filename given in its content-disposition header. Example: POST /send-to/?email=john.doe@exampke.com HTTP/1.1 Host: localhost:8000 Content-Type: multipart/form-data; boundary=---------------------------9051914041544843365972754266 Content-Length: xxx -----------------------------9051914041544843365972754266 Content-Disposition: form-data; name="documents"; filename="attachment1.txt" Content-Type: text/plain Content of a.txt. -----------------------------9051914041544843365972754266 Content-Disposition: form-data; name="documents"; filename="attachment2.html" Content-Type: text/html Content of a.html. -----------------------------9051914041544843365972754266-- --- COPYING | 11 +++++++- fargo/fargo/fields.py | 53 +++++++++++++++++++++++++++++++++++++ fargo/fargo/forms.py | 40 +++++++++++++++++++++++++--- fargo/fargo/views.py | 24 ++++++++++++----- fargo/tests.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++++-- fargo/urls.py | 11 ++++---- 6 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 fargo/fargo/fields.py diff --git a/COPYING b/COPYING index bc8a80e..90be19f 100644 --- a/COPYING +++ b/COPYING @@ -1,2 +1,11 @@ -Fargo is entirely under the copyright of Entr'ouvert and distributed +Fargo is mainly under the copyright of Entr'ouvert and distributed under the license AGPLv3 or later. + +A file was copied from the project django-multiupload +(https://github.com/Chive/django-multiupload) and licensed under the MIT +license. + + The MIT License (MIT) + + Copyright (c) 2014 Chive - Kim Thoenen (kim@smuzey.ch) + diff --git a/fargo/fargo/fields.py b/fargo/fargo/fields.py new file mode 100644 index 0000000..171384e --- /dev/null +++ b/fargo/fargo/fields.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Copied from https://github.com/Chive/django-multiupload/blob/master/multiupload/fields.py +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + + +class MultiFileInput(forms.FileInput): + def render(self, name, value, attrs=None): + attrs['multiple'] = 'multiple' + return super(MultiFileInput, self).render(name, value, attrs) + + def value_from_datadict(self, data, files, name): + if hasattr(files, 'getlist'): + return files.getlist(name) + else: + return [files.get(name)] + + +class MultiFileField(forms.FileField): + widget = MultiFileInput + default_error_messages = { + 'min_num': _(u'Ensure at least %(min_num)s files are uploaded (received %(num_files)s).'), + 'max_num': _(u'Ensure at most %(max_num)s files are uploaded (received %(num_files)s).'), + 'file_size': _(u'File %(uploaded_file_name)s exceeded maximum upload size.'), + } + + def __init__(self, *args, **kwargs): + self.min_num = kwargs.pop('min_num', 0) + self.max_num = kwargs.pop('max_num', None) + self.maximum_file_size = kwargs.pop('max_file_size', None) + super(MultiFileField, self).__init__(*args, **kwargs) + + def to_python(self, data): + ret = [] + for item in data: + i = super(MultiFileField, self).to_python(item) + if i: + ret.append(i) + return ret + + def validate(self, data): + super(MultiFileField, self).validate(data) + num_files = len(data) + if len(data) and not data[0]: + num_files = 0 + if num_files < self.min_num: + raise ValidationError(self.error_messages['min_num'] % {'min_num': self.min_num, 'num_files': num_files}) + elif self.max_num and num_files > self.max_num: + raise ValidationError(self.error_messages['max_num'] % {'max_num': self.max_num, 'num_files': num_files}) + for uploaded_file in data: + if self.maximum_file_size and uploaded_file.size > self.maximum_file_size: + raise ValidationError(self.error_messages['file_size'] % {'uploaded_file_name': uploaded_file.name}) diff --git a/fargo/fargo/forms.py b/fargo/fargo/forms.py index 0ed5d0d..ba9ab49 100644 --- a/fargo/fargo/forms.py +++ b/fargo/fargo/forms.py @@ -1,8 +1,14 @@ -from django.forms import ModelForm +import base64 -from . import models +from django.utils.text import slugify +from django.core.exceptions import ValidationError +from django import forms +from django.contrib.auth import get_user_model +from django.core.files.base import ContentFile -class UploadForm(ModelForm): +from . import models, fields + +class UploadForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') super(UploadForm, self).__init__(*args, **kwargs) @@ -15,3 +21,31 @@ class UploadForm(ModelForm): class Meta: model = models.Document fields = ['document_file'] + +class SendToForm(forms.Form): + email = forms.EmailField() + documents = fields.MultiFileField() + + def clean_email(self): + User = get_user_model() + try: + self.cached_user = User.objects.get( + email=self.cleaned_data['email']) + except User.DoesNotExist: + raise ValidationError('no such user') + return self.cleaned_data['email'] + + + def clean_documents(self): + for i, document in enumerate(self.cleaned_data['documents']): + if not document.name or document.name == 'documents': + raise ValidationError('%d-th document is missing a name' % i) + return self.cleaned_data['documents'] + + def save(self, *args, **kwargs): + for document in self.cleaned_data['documents']: + document = models.Document( + user=self.cached_user, + document_filename=document.name, + document_file=document) + document.save() diff --git a/fargo/fargo/views.py b/fargo/fargo/views.py index 4b2047b..dbb7480 100644 --- a/fargo/fargo/views.py +++ b/fargo/fargo/views.py @@ -1,13 +1,13 @@ import urlparse import urllib import logging -from json import dumps +import json from django.views.generic import CreateView, DeleteView, View, TemplateView from django.core.urlresolvers import reverse from django.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404, resolve_url -from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden +from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden, HttpResponseBadRequest from django.core import signing from django.contrib import messages from django.contrib.auth import get_user_model, REDIRECT_FIELD_NAME @@ -181,7 +181,7 @@ class JSONP(Documents, View): def get(self, request): callback = request.GET.get('callback', 'callback') s = '%s(%s)' % (callback.encode('ascii'), - dumps(self.get_data(request))) + json.dumps(self.get_data(request))) return HttpResponse(s, content_type='application/javascript') class JSON(JSONP): @@ -192,11 +192,22 @@ class JSON(JSONP): request.user = get_object_or_404(User, username=username) elif not request.user.is_authenticated(): return method_decorator(login_required)(JSON.get)(self, request) - response = HttpResponse(dumps(self.get_data(request)), + response = HttpResponse(json.dumps(self.get_data(request)), content_type='application/json') response['Access-Control-Allow-Origin'] = '*' return response +class SendTo(View): + http_method_allowed = ['post'] + + def post(self, request, *args, **kwargs): + form = forms.SendToForm(request.GET, request.FILES) + if form.is_valid(): + form.save() + return HttpResponse('ok', content_type='text/plain') + else: + result = {'errors': form.errors} + return HttpResponseBadRequest(json.dumps(result)) def login(request, *args, **kwargs): if any(get_idps()): @@ -223,7 +234,8 @@ document = login_required(Document.as_view()) download = login_required(Download.as_view()) upload = login_required(Upload.as_view()) remote_download = RemoteDownload.as_view() +send_to = SendTo.as_view() delete = login_required(Delete.as_view()) pick = login_required(Pick.as_view()) -jsonp = login_required(JSONP.as_view()) -json = login_required(JSON.as_view()) +jsonp_view = login_required(JSONP.as_view()) +json_view = login_required(JSON.as_view()) diff --git a/fargo/tests.py b/fargo/tests.py index 7ce503c..fb7a67d 100644 --- a/fargo/tests.py +++ b/fargo/tests.py @@ -1,3 +1,72 @@ -from django.test import TestCase +from django.test import TestCase, Client -# Create your tests here. +class FargoTestCase(TestCase): + def setUp(self): + import StringIO + from django.contrib.auth import get_user_model + + User = get_user_model() + self.email = 'john.doe@example.com' + self.username = 'john.doe' + self.user = User.objects.create( + username=self.username, + email=self.email) + self.filename = 'attachment.pdf' + self.content = 'coucou' + self.contentfile = StringIO.StringIO(self.content) + self.contentfile.content_type = 'application/pdf' + self.contentfile.name = self.filename + + def test_send_to(self): + from fargo.models import Document + + client = Client() + response = client.post('/send-to/?email=%s' % self.email, + data={'documents': [self.contentfile]}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, 'ok') + self.assertEqual(Document.objects.count(), 1, 'no document found') + document = Document.objects.get() + self.assertEqual(document.user, self.user) + self.assertEqual(document.document_filename, self.filename) + self.assertEqual(document.document_file.read(), self.content) + + def test_remote_send_to_unknown_user(self): + client = Client() + response = client.post('/send-to/?email=%s' % 'unknown@example.com', + data={'documents': [self.contentfile]}) + self.assertEqual(response.status_code, 400) + self.assertJSONEqual(response.content, { + 'errors': { + 'email': ['no such user'], + } + } + ) + + def test_remote_upload_missing_fields(self): + client = Client() + response = client.post('/send-to/') + self.assertEqual(response.status_code, 400) + self.assertJSONEqual(response.content, { + 'errors': { + 'email': ['This field is required.'], + 'documents': ['This field is required.'], + } + } + ) + + def test_remote_upload_missing_name(self): + import StringIO + + client = Client() + contentfile = StringIO.StringIO(self.content) + contentfile.content_type = 'application/pdf' + response = client.post('/send-to/?email=%s' % self.email, + data={'documents': [contentfile]}) + self.assertEqual(response.status_code, 400) + self.assertJSONEqual(response.content, { + 'errors': { + 'documents': ['0-th document is missing a name'], + } + } + ) diff --git a/fargo/urls.py b/fargo/urls.py index 2eddf1a..109dade 100644 --- a/fargo/urls.py +++ b/fargo/urls.py @@ -2,24 +2,25 @@ from django.conf import settings from django.conf.urls import patterns, include, url from django.contrib import admin -from .fargo.views import (home, jsonp, json, document, download, pick, delete, upload, - remote_download, login, logout) +from .fargo.views import (home, jsonp_view, json_view, document, download, + pick, delete, upload, remote_download, send_to, login, + logout) urlpatterns = patterns('', url(r'^$', home, name='home'), - url(r'^jsonp/$', jsonp, name='jsonp'), - url(r'^json/$', json, name='json'), + url(r'^jsonp/$', jsonp_view, name='jsonp'), + url(r'^json/$', json_view, name='json'), url(r'^(?P\d+)/$', document, name='document'), url(r'^(?P\d+)/delete/$', delete, name='delete'), url(r'^(?P\d+)/pick/$', pick, name='pick'), url(r'^(?P\d+)/download/(?P[^/]*)$', download, name='download'), url(r'^upload/$', upload, name='upload'), url(r'^remote-download/(?P[^/]*)$', remote_download, name='remote_download'), + url(r'^send-to/$', send_to, name='send_to'), url(r'^admin/', include(admin.site.urls)), url(r'^login/$', login, name='auth_login'), url(r'^logout/$', logout, name='auth_logout'), ) - if 'mellon' in settings.INSTALLED_APPS: urlpatterns += patterns('', url(r'^accounts/mellon/', include('mellon.urls'))) -- 2.1.4