Implement fargo

This commit is contained in:
Benjamin Dauvergne 2015-03-05 23:47:42 +01:00
parent f5c743994b
commit a5849218a3
14 changed files with 519 additions and 40 deletions

51
README
View File

@ -3,6 +3,7 @@ Fargo
To start do:
pip install -e .
./manage.py migrate
./manage.py runserver
@ -13,4 +14,52 @@ module, at its end.
Settings
========
FIXME
Nothing for now.
Requesting a file from another application
==========================================
Downloading a file from fargo is easy:
- Open http://fargo/?pick=http://yoursite/pick-a-file/ in a popup or iframe
- When the user choose a file it is returned to
http://yoursite/pick-a-file/?url=http://fargo/remote-download/name-of-the-file?token=xxxxxx
Your view on this URL should download the file from the given URL, do
something with it then close the popup/iframe.
Download URL are only valid during 60 seconds after the request will return
status 403. If the file has been removed a 404 is returned.
Displaying the list of files of a user
======================================
There are two methods JSONP and JSON, both are totally insecure, protect
them with your web server and IP limitations for now.
JSONP
-----
* Add this to your page
<script>function callback(data) {
// display the file list by modifying the DOM
}</script>
<script src="http://fargo/jsonp/?callback=callback"></script>
* data is structured like this:
[ { 'url': 'http://fargo/download/etc..', 'filename': 'facture.pdf'}, ... ]
JSON
----
* Do a get on http://fargo/json/?username=john.doe
Showing an upload form
======================
You can open an upload form to fargo by creating a popup or an iframe with
location http://fargo/upload/. You can pass a parameter ?next=http://yoursite/
if you want the user to come back to your site after the upload, to close the
popup or the destroy the iframe.

17
fargo/fargo/forms.py Normal file
View File

@ -0,0 +1,17 @@
from django.forms import ModelForm
from . import models
class UploadForm(ModelForm):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super(UploadForm, self).__init__(*args, **kwargs)
def save(self, *args, **kwargs):
self.instance.user = self.user
self.instance.document_filename = self.files['document_file'].name
return super(UploadForm, self).save(*args, **kwargs)
class Meta:
model = models.Document
fields = ['document_file']

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Document',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('document_filename', models.CharField(max_length=512, verbose_name='document filename')),
('document_file', models.FileField(upload_to=b'', verbose_name='file')),
('creation', models.DateTimeField(auto_now_add=True, verbose_name='creation date')),
('user', models.ForeignKey(verbose_name='user', to=settings.AUTH_USER_MODEL)),
],
options={
},
bases=(models.Model,),
),
]

View File

@ -1,3 +1,26 @@
from django.conf import settings
from django.db import models
from django.utils.translation import ugettext_lazy as _
# Create your models here.
class Document(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('user'))
document_filename = models.CharField(
verbose_name=_('document filename'),
max_length=512)
document_file = models.FileField(
verbose_name=_('file'))
creation = models.DateTimeField(
verbose_name=_('creation date'),
auto_now_add=True)
def delete(self):
'''Delete file on model delete'''
self.document_file.delete()
super(Document, self).delete()
class Meta:
verbose_name = _('document')
verbose_name_plural = _('documents')
ordering = ('-creation',)

8
fargo/fargo/tables.py Normal file
View File

@ -0,0 +1,8 @@
import django_tables2 as tables
from . import models
class DocumentTable(tables.Table):
class Meta:
model = models.Document
fields = ('document_filename', 'creation')
attrs = {'class': 'paleblue'}

View File

@ -1,6 +1,188 @@
from django.shortcuts import render
import urlparse
import urllib
import logging
from json import dumps
# Create your views here.
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
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden
from django.core import signing
from django.contrib import messages
from django.contrib.auth import get_user_model, REDIRECT_FIELD_NAME
from django.utils.translation import ugettext as _
def home(request):
return render(request, 'fargo/home.html')
from django_tables2 import SingleTableMixin
from . import models, forms, tables
class Logger(object):
def __init__(self, *args, **kwargs):
self.logger = logging.getLogger(__name__)
class CommonUpload(Logger, CreateView):
form_class = forms.UploadForm
model = models.Document
template_name = 'fargo/upload.html'
def get_form_kwargs(self, **kwargs):
kwargs = super(CommonUpload, self).get_form_kwargs(**kwargs)
kwargs['user'] = self.request.user
return kwargs
def form_valid(self, form):
result = super(CommonUpload, self).form_valid(form)
self.logger.info('user uploaded file %r(%s)',
self.object.document_filename, self.object.pk)
return result
class Upload(CommonUpload):
def get_success_url(self):
homepage = reverse('home')
return self.request.GET.get(REDIRECT_FIELD_NAME, homepage)
def post(self, request, *args, **kwargs):
if 'cancel' in request.POST:
return HttpResponseRedirect(self.get_success_url())
return super(Upload, self).post(request, *args, **kwargs)
class Documents(object):
def get_queryset(self):
return models.Document.objects.filter(user=self.request.user)
class Homepage(Documents, SingleTableMixin, CommonUpload):
'''Show documents of users, eventually paginate and sort them.
If a pick_url parameter is passed, allow picking a file and returning a
download URL to the pick_url.
'''
template_name = 'fargo/home.html'
form_class = forms.UploadForm
table_class = tables.DocumentTable
table_pagination = {
'per_page': 5,
}
def get_success_url(self):
return ''
def can_pick(self):
'''Check if user is currently picking a file'''
# FIXME: we should check the pick URL is authorized
return 'pick' in self.request.GET
def get_context_data(self, **kwargs):
ctx = super(Homepage, self).get_context_data(**kwargs)
if self.can_pick():
ctx['pick'] = True
return ctx
def post(self, request, *args, **kwargs):
if self.can_pick() and 'cancel' in request.POST:
return HttpResponseRedirect(request.GET['pick'])
return super(Upload, self).post(request, *args, **kwargs)
class Document(TemplateView):
template_name = 'fargo/document.html'
class Delete(Logger, DeleteView):
model = models.Document
def delete(self, request, *args, **kwargs):
result = super(Delete, self).delete(request, *args, **kwargs)
messages.info(request, _('File %s deleted') % self.object.document_filename)
self.logger.info('user deleted file %r(%s)',
self.object.document_filename, self.object.pk)
return result
def get_success_url(self):
return '../..?%s' % self.request.META['QUERY_STRING']
class Pick(Logger, View):
http_method_allowed = ['post']
def post(self, request, pk):
document = get_object_or_404(models.Document, pk=pk, user=self.request.user)
pick = self.request.GET['pick']
token = signing.dumps(document.pk)
url = reverse('remote_download', kwargs={'filename': document.document_filename})
url += '?%s' % urllib.urlencode({'token': token})
url = request.build_absolute_uri(url)
scheme, netloc, path, qs, fragment = urlparse.urlsplit(pick)
qs = urlparse.parse_qs(qs)
print url
qs['url'] = url
qs = urllib.urlencode(qs, True)
redirect = urlparse.urlunsplit((scheme, netloc, path, qs, fragment))
print 'redirect', redirect
self.logger.info('user picked file %r(%s) returned to %s',
document.document_filename, document.pk, pick)
return HttpResponseRedirect(redirect)
class Download(View):
def get(self, request, pk, filename):
document = get_object_or_404(models.Document, pk=pk, user=self.request.user)
return self.return_document(document)
def return_document(self, document):
response = HttpResponse(document.document_file.chunks(),
content_type='application/octet-stream')
response['Content-disposition'] = 'attachment'
return response
class RemoteDownload(Download):
'''Allow downloading any file given the URL contains a signed token'''
def get(self, request, filename):
if 'token' not in request.GET:
return HttpResponseForbidden('missing token')
# FIXME: maybe we should mark token as invalid after use using the
# cache ?
token = request.GET['token']
# token are valid only 1 minute
try:
pk = signing.loads(token, max_age=60)
except signing.SignatureExpired:
return HttpResponseForbidden('token has expired')
except signing.BadSignature:
return HttpResponseForbidden('token signature is invalid')
document = get_object_or_404(models.Document, pk=pk)
return self.return_document(document)
class JSONP(Documents, View):
def get_data(self, request):
d = []
for document in self.get_queryset():
url = reverse('download', kwargs={'pk': document.pk,
'filename': document.document_filename})
url = request.build_absolute_uri(url)
d.append({
'filename': document.document_filename,
'url': url,
})
return d
def get(self, request):
callback = request.GET.get('callback', 'callback')
s = '%s(%s)' % (callback.encode('ascii'),
dumps(self.get_data(request)))
return HttpResponse(s, content_type='application/javascript')
class JSON(JSONP):
def get(self, request):
username = request.GET.get('username')
User = get_user_model()
request.user = get_object_or_404(User, username=username)
return HttpResponse(dumps(self.get_data(request)),
content_type='application/json')
home = login_required(Homepage.as_view())
document = login_required(Document.as_view())
download = login_required(Download.as_view())
upload = login_required(Upload.as_view())
remote_download = RemoteDownload.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())

View File

@ -7,7 +7,7 @@ https://docs.djangoproject.com/en/1.7/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.7/ref/settings/
"""
from django.conf.global_settings import TEMPLATE_CONTEXT_PROCESSORS
from django.conf.global_settings import TEMPLATE_CONTEXT_PROCESSORS, STATICFILES_FINDERS
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
@ -37,7 +37,9 @@ INSTALLED_APPS = (
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_tables2',
'sekizai',
'gadjo',
'fargo.fargo',
)
@ -51,10 +53,15 @@ MIDDLEWARE_CLASSES = (
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
TEMPLATE_CONTEXT_PROCESSORS += (
'django.core.context_processors.request',
'sekizai.context_processors.sekizai',)
ROOT_URLCONF = 'fargo.urls'
WSGI_APPLICATION = 'fargo.wsgi.application'
STATICFILES_FINDERS = STATICFILES_FINDERS + ('gadjo.finders.XStaticFinder',)
# Database
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases
@ -63,6 +70,7 @@ DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'fargo.sqlite3'),
'ATOMIC_REQUESTS': True,
}
}
@ -87,10 +95,6 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
LOCALE_PATHS = [os.path.join(BASE_DIR, 'fargo', 'locale')]
TEMPLATE_CONTEXT_PROCESSORS += ('sekizai.context_processors.sekizai',)
ATOMIC = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.7/howto/static-files/

View File

@ -8,10 +8,16 @@
</head>
<body {% block bodyattr %}{% endblock %}>
{% if messages %}
<ul id="messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div id="content">
{% block content %}
{% endblock %}
</div>
{% render_block "js-endpage" %}
</body>
</html>

View File

@ -1,5 +1,20 @@
{% extends "base.html" %}
{% load render_table from django_tables2 %}
{% load sekizai_tags i18n %}
{% block content %}
Salut tout le monde!
{% addtoblock "css" %}<link rel="stylesheet" href="{{ STATIC_URL }}django_tables2/themes/paleblue/css/screen.css" />{% endaddtoblock %}
<div id="user-files">
{% render_table table "fargo/table.html" %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" name="submit" value="{% trans "Upload" %}">
{% if pick %}
<input type="submit" name="cancel" value="{% trans "Cancel" %}">
{% endif %}
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,105 @@
{% spaceless %}
{% load django_tables2 %}
{% load i18n %}
{% load sekizai_tags %}
{% load gadjo %}
{% addtoblock "js" %}<script type="text/javascript" src="{% xstatic 'jquery' 'jquery.min.js' %}"></script>{% endaddtoblock %}
<script>
$(function () {
$('tbody').on("click", "tr", function (event) {
var $target = $(event.target)
if (! $target.is('tr')) {
$target = $target.parents('tr');
}
var url = $target.data('url');
if (url) {
window.location.href = url;
}
});
})
</script>
{% if table.page %}
<div class="table-container">
{% endif %}
{% block table %}
<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}>
{% nospaceless %}
{% block table.thead %}
<thead>
<tr>
{% for column in table.columns %}
{% if column.orderable %}
<th {{ column.attrs.th.as_html }}><a href="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ column.header }}</a></th>
{% else %}
<th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
{% endif %}
{% endfor %}
<th class="delete-column"></th>
</tr>
</thead>
{% endblock table.thead %}
{% block table.tbody %}
<tbody>
{% for row in table.page.object_list|default:table.rows %} {# support pagination #}
{% block table.tbody.row %}
<tr data-url="{% url 'download' pk=row.record.pk filename=row.record.document_filename %}" class="{{ forloop.counter|divisibleby:2|yesno:"even,odd" }}"> {# avoid cycle for Django 1.2-1.6 compatibility #}
{% for column, cell in row.items %}
<td {{ column.attrs.td.as_html }}>{% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}</td>
{% endfor %}
<td class="delete-column">
{% if pick %}
<form method="post" action="{% url 'pick' pk=row.record.pk %}{% querystring %}">
{% csrf_token %}
<button>{% trans "Pick" %}</button>
</form>
{% else %}
<form method="post" action="{% url 'delete' pk=row.record.pk %}{% querystring %}">
{% csrf_token %}
<button>{% trans "Delete" %}</button>
</form>
{% endif %}
</th>
</tr>
</tr>
{% endblock table.tbody.row %}
{% empty %}
{% if table.empty_text %}
{% block table.tbody.empty_text %}
<tr><td colspan="{{ table.columns|length }}">{{ table.empty_text }}</td></tr>
{% endblock table.tbody.empty_text %}
{% endif %}
{% endfor %}
</tbody>
{% endblock table.tbody %}
{% block table.tfoot %}
<tfoot></tfoot>
{% endblock table.tfoot %}
{% endnospaceless %}
</table>
{% endblock table %}
{% if table.page %}
{% with table.page.paginator.count as total %}
{% with table.page.object_list|length as count %}
{% block pagination %}
<ul class="pagination">
{% if table.page.has_previous %}
{% nospaceless %}{% block pagination.previous %}<li class="previous"><a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}">{% trans "Previous" %}</a></li>{% endblock pagination.previous %}{% endnospaceless %}
{% endif %}
{% if table.page.has_previous or table.page.has_next %}
{% nospaceless %}{% block pagination.current %}<li class="current">{% blocktrans with table.page.number as current and table.paginator.num_pages as total %}Page {{ current }} of {{ total }}{% endblocktrans %}</li>{% endblock pagination.current %}{% endnospaceless %}
{% endif %}
{% if table.page.has_next %}
{% nospaceless %}{% block pagination.next %}<li class="next"><a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}">{% trans "Next" %}</a></li>{% endblock pagination.next %}{% endnospaceless %}
{% endif %}
{% nospaceless %}{% block pagination.cardinality %}<li class="cardinality">{% if total != count %}{% blocktrans %}{{ count }} of {{ total }}{% endblocktrans %}{% else %}{{ total }}{% endif %} {% if total == 1 %}{{ table.data.verbose_name }}{% else %}{{ table.data.verbose_name_plural }}{% endif %}</li>{% endblock pagination.cardinality %}{% endnospaceless %}
</ul>
{% endblock pagination %}
{% endwith %}
{% endwith %}
</div>
{% endif %}
{% endspaceless %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load render_table from django_tables2 %}
{% load sekizai_tags i18n %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" name="submit" value="{% trans "Upload" %}">
<input type="submit" name="cancel" value="{% trans "Cancel" %}">
</form>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="submit" name="{% trans "Login" %}">
</form>
{% endblock %}

View File

@ -1,9 +1,19 @@
from django.conf.urls import patterns, include, url
from django.contrib import admin
from .fargo.views import home
from .fargo.views import (home, jsonp, json, document, download, pick, delete, upload,
remote_download)
urlpatterns = patterns('',
url(r'^$', home, name='home'),
url(r'^jsonp/$', jsonp, name='jsonp'),
url(r'^json/$', json, name='json'),
url(r'^(?P<pk>\d+)/$', document, name='document'),
url(r'^(?P<pk>\d+)/delete/$', delete, name='delete'),
url(r'^(?P<pk>\d+)/pick/$', pick, name='pick'),
url(r'^(?P<pk>\d+)/download/(?P<filename>[^/]*)$', download, name='download'),
url(r'^upload/$', upload, name='upload'),
url(r'^remote-download/(?P<filename>[^/]*)$', remote_download, name='remote_download'),
url(r'^admin/', include(admin.site.urls)),
url('^accounts/', include('django.contrib.auth.urls')),
)

View File

@ -4,6 +4,7 @@
'''
import os
import subprocess
from setuptools import setup, find_packages
from setuptools.command.install_lib import install_lib as _install_lib
from distutils.command.build import build as _build
@ -36,40 +37,42 @@ class build(_build):
class sdist(_sdist):
sub_commands = [('compile_translations', None)] + _sdist.sub_commands
def run(self):
print "creating VERSION file"
if os.path.exists('VERSION'):
os.remove('VERSION')
version = get_version()
version_file = open('VERSION', 'w')
version_file.write(version)
version_file.close()
_sdist.run(self)
print "removing VERSION file"
if os.path.exists('VERSION'):
os.remove('VERSION')
class install_lib(_install_lib):
def run(self):
self.run_command('compile_translations')
_install_lib.run(self)
def get_version():
import glob
import re
import os
version = None
for d in glob.glob('*'):
if not os.path.isdir(d):
continue
module_file = os.path.join(d, '__init__.py')
if not os.path.exists(module_file):
continue
for v in re.findall("""__version__ *= *['"](.*)['"]""",
open(module_file).read()):
assert version is None
version = v
if version:
break
assert version is not None
'''Use the VERSION, if absent generates a version with git describe, if not
tag exists, take 0.0.0- and add the length of the commit log.
'''
if os.path.exists('VERSION'):
with open('VERSION', 'r') as v:
return v.read()
if os.path.exists('.git'):
import subprocess
p = subprocess.Popen(['git','describe','--dirty','--match=v*'],
stdout=subprocess.PIPE)
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
result = p.communicate()[0]
assert p.returncode == 0, 'git returned non-zero'
new_version = result.split()[0][1:]
assert new_version.split('-')[0] == version, '__version__ must match the last git annotated tag'
version = new_version.replace('-', '.')
return version
if p.returncode == 0:
return result.split()[0][1:].replace('-', '.')
else:
return '0.0.0-%s' % len(
subprocess.check_output(
['git', 'rev-list', 'HEAD']).splitlines())
return '0.0.0'
setup(name="fargo",
@ -84,9 +87,14 @@ setup(name="fargo",
include_package_data=True,
packages=find_packages(),
install_requires=[
'django>=1.7',
'django-tables2',
'django_sekizai',
'gadjo',
'XStatic',
'XStatic_jQuery',
],
setup_requires=[
'django>=1.7',
],
tests_require=[
'nose>=0.11.4',