Browse Source

Initial commit

Matthias Vogelgesang 7 years ago
commit
ad59e1c366

+ 3 - 0
.bowerrc

@@ -0,0 +1,3 @@
+{
+    "directory": "nova/static"
+}

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+*.pyc
+build/
+dist/
+nova.egg-info/
+nova/static/

+ 25 - 0
README.md

@@ -0,0 +1,25 @@
+## Installation
+
+1. Install Flask and dependencies
+
+    $ pip install -r requirements
+
+2. Install the `nova` binary
+
+    $ python setup.py install
+
+3. Install frontend dependencies
+
+    $ bower install
+
+4. Create database and initial admin user
+
+    $ python manage.py initdb --name john --fullname "John Doe" --email "jd@jd.com"
+
+5. Run the server
+
+    $ python manage.py runserver
+
+If you run from source make sure to upgrade the database with
+
+    $ python manage.py db upgrade

+ 138 - 0
bin/nova

@@ -0,0 +1,138 @@
+#!/usr/bin/env python
+
+import os
+import sys
+import io
+import argparse
+import requests
+import ConfigParser
+from nova import memtar
+
+
+class Config(object):
+    def __init__(self, args, dataset_id=None):
+        self.root = os.path.abspath('.nova')
+        args = vars(args)
+
+        self.token = args.get('token', None)
+        self.remote = args.get('remote', None)
+        self.id = args.get('id', None) or dataset_id
+
+        if os.path.exists(self.path):
+            parser = ConfigParser.RawConfigParser()
+            parser.read(self.path)
+
+            self.token = parser.get('core', 'token')
+            self.remote = parser.get('core', 'remote')
+            self.id = parser.get('core', 'id')
+
+    @property
+    def path(self):
+        return os.path.join(self.root, 'config')
+
+    def write(self):
+        parser = ConfigParser.RawConfigParser()
+        parser.add_section('core')
+        parser.set('core', 'remote', self.remote)
+        parser.set('core', 'token', self.token)
+        parser.set('core', 'id', self.id)
+
+        print self.root
+        if not os.path.exists(self.root):
+            os.mkdir(self.root)
+
+        with open(self.path, 'wb') as f:
+            parser.write(f)
+
+    def url(self, u):
+        if not self.remote:
+            sys.exit("No remote specified in configuration or as an argument.")
+
+        return '{}{}'.format(self.remote, u)
+
+    def __repr__(self):
+        return '<Config remote={}, token={}, id={}>'.format(self.remote, self.token, self.id)
+
+
+def push(args):
+    config = Config(args)
+    params = dict(token=config.token)
+    f = memtar.create_tar(os.path.abspath('.'))
+    f.seek(0L)
+    url = config.remote + '/upload/' + config.id
+    r = requests.post(url, params=params, data=f)
+    print r.status_code
+
+
+def clone(args):
+    config = Config(args)
+    params = dict(token=config.token)
+
+    if not args.name:
+        r = requests.get(config.url('/api/datasets/') + config.id, params=params)
+        name = r.json()['name']
+    else:
+        name = args.name
+
+    url = config.url('/clone/' + config.id)
+    r = requests.get(url, params=params)
+    f = io.BytesIO(r.content)
+    memtar.extract_tar(f, os.path.join(os.path.abspath('.'), name))
+
+    config.root = os.path.join(os.path.abspath(name), '.nova')
+    config.write()
+
+
+def init(args):
+    name = args.name if args.name else os.path.basename(os.path.abspath(os.curdir))
+    config = Config(args)
+
+    # create dataset on remote
+    data = dict(name=name)
+    params = dict(token=args.token)
+    r = requests.post(config.url('/api/datasets'), params=params, data=data)
+    result = r.json()
+
+    # write initial configuration
+    config.id = result['id']
+    config.write()
+
+
+def list_datasets(args):
+    config = Config(args)
+    params = dict(token=config.token)
+    r = requests.get(config.url('/api/datasets'), params=params)
+
+    for d in r.json():
+        print d['id'], d['name']
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser()
+
+    cmd_parsers = parser.add_subparsers(title="Commands")
+
+    init_parser = cmd_parsers.add_parser('init', help="Initialize dataset in current directory")
+    init_parser.add_argument('--name', type=str, help="Dataset name, if not given current directory name")
+    init_parser.add_argument('--remote', type=str, help="URL of remote NOVA instance")
+    init_parser.add_argument('--token', type=str,
+                             help="Access token")
+    init_parser.set_defaults(run=init)
+
+    list_parser = cmd_parsers.add_parser('list', help="List datasets assigned to me")
+    list_parser.add_argument('--remote', type=str, help="URL of remote NOVA instance")
+    list_parser.add_argument('--token', type=str, help="Access token")
+    list_parser.set_defaults(run=list_datasets)
+
+    push_parser = cmd_parsers.add_parser('push', help="Finalize data and push to remote")
+    push_parser.set_defaults(run=push)
+
+    clone_parser = cmd_parsers.add_parser('clone', help="Clone dataset")
+    clone_parser.add_argument('--remote', type=str, help="URL of remote NOVA instance")
+    clone_parser.add_argument('--token', type=str, help="Access token")
+    clone_parser.add_argument('--id', required=True, type=str, help="Dataset identifier")
+    clone_parser.add_argument('--name', type=str, help="Alternative directory name")
+    clone_parser.set_defaults(run=clone)
+
+    args = parser.parse_args()
+    args.run(args)

+ 17 - 0
bower.json

@@ -0,0 +1,17 @@
+{
+  "name": "nova",
+  "version": "0.0.0",
+  "authors": [
+    "Matthias Vogelgesang"
+  ],
+  "description": "NOVA data management",
+  "license": "MIT",
+  "ignore": [
+    "**/.*",
+    "nova/static"
+  ],
+  "dependencies": {
+    "fontawesome": "~4.6.3",
+    "bootswatch-dist": "cosmo"
+  }
+}

+ 51 - 0
docs/design.md

@@ -0,0 +1,51 @@
+# NOVA data management
+
+## Goals
+
+The main goal of the system is to provide *users* and *groups* of users remote
+access to *datasets*. The workflow should center around users accessing files
+locally but allow mirroring datasets remotely. To facilitate larger compute
+resources, non-attending remote computation should be made possible too.
+
+### Related and prior work
+
+* dCache
+* iCAT
+
+## Overview
+
+### Datasets
+
+In a generic sense datasets are files and directories of files and can either be
+*original* root datasets or *derived* from a parent dataset. For example, a
+tomographic scan yielding dark, reference and projection frames is the origin
+for subsequent datasets that might contain sinograms, reconstructions,
+segmentations and final analysis.
+
+Datasets are *owned* by one user but can be given *read* access to other users
+and groups of users.
+
+#### Typed datasets
+
+A generic dataset covers all kinds of data but cannot be used to deduce
+information for automatic processing. Hence, a hierarchy of types with
+pre-determined attributes is required.
+
+### Architecture
+
+The system is based on a client-server architecture. The server manages user
+roles, authorization, authentication and remote data storage. Moreover, it
+provides a managing system view for web clients as well as an API view for
+programmatic access. This API is consumed by a local client to
+
+1. create a new dataset from a local directory,
+2. list available datasets for *cloning* and *deletion* as well as
+3. push data to the remote server.
+
+#### Token-based authentication
+
+Storing user name and password for the local client is not advised, hence each
+user can generate a token that is used by the API to authenticate and authorize
+resource access.
+
+## Technical details

+ 35 - 0
manage.py

@@ -0,0 +1,35 @@
+import sys
+import getpass
+from nova import app, db
+from nova.models import User
+from flask_script import Manager, Command, Option
+from flask_migrate import MigrateCommand
+
+
+class InitDatabaseCommand(Command):
+
+    option_list = (
+        Option('--name', dest='name', required=True),
+        Option('--fullname', dest='fullname', required=True),
+        Option('--email', dest='email', required=True),
+    )
+
+    def run(self, name, fullname, email):
+        password = getpass.getpass("Password: ")
+        repeated_password = getpass.getpass("Repeat password: ")
+
+        if password != repeated_password:
+            sys.exit("Passwords not matching.")
+
+        db.create_all()
+        db.session.add(User(name=name, fullname=fullname, email=email, is_admin=True, password=password))
+        db.session.commit()
+
+
+manager = Manager(app)
+manager.add_command('initdb', InitDatabaseCommand)
+manager.add_command('db', MigrateCommand)
+
+
+if __name__ == '__main__':
+    manager.run()

+ 1 - 0
migrations/README

@@ -0,0 +1 @@
+Generic single-database configuration.

+ 45 - 0
migrations/alembic.ini

@@ -0,0 +1,45 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 87 - 0
migrations/env.py

@@ -0,0 +1,87 @@
+from __future__ import with_statement
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+from logging.config import fileConfig
+import logging
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+from flask import current_app
+config.set_main_option('sqlalchemy.url',
+                       current_app.config.get('SQLALCHEMY_DATABASE_URI'))
+target_metadata = current_app.extensions['migrate'].db.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(url=url)
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online():
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+
+    # this callback is used to prevent an auto-migration from being generated
+    # when there are no changes to the schema
+    # reference: http://alembic.readthedocs.org/en/latest/cookbook.html
+    def process_revision_directives(context, revision, directives):
+        if getattr(config.cmd_opts, 'autogenerate', False):
+            script = directives[0]
+            if script.upgrade_ops.is_empty():
+                directives[:] = []
+                logger.info('No changes in schema detected.')
+
+    engine = engine_from_config(config.get_section(config.config_ini_section),
+                                prefix='sqlalchemy.',
+                                poolclass=pool.NullPool)
+
+    connection = engine.connect()
+    context.configure(connection=connection,
+                      target_metadata=target_metadata,
+                      process_revision_directives=process_revision_directives,
+                      **current_app.extensions['migrate'].configure_args)
+
+    try:
+        with context.begin_transaction():
+            context.run_migrations()
+    finally:
+        connection.close()
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 22 - 0
migrations/script.py.mako

@@ -0,0 +1,22 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision}
+Create Date: ${create_date}
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}

+ 69 - 0
nova/__init__.py

@@ -0,0 +1,69 @@
+import os
+import humanize
+from flask import Flask
+from flask_login import LoginManager, current_user
+from flask_sqlalchemy import SQLAlchemy
+from flask_migrate import Migrate
+from flask_admin import Admin
+from flask_admin.contrib.sqla import ModelView
+from flask_restful import Api
+from nova.fs import Filesystem
+
+__version__ = '0.1.0'
+
+app = Flask(__name__)
+app.secret_key = 'KU5bF1K4ZQdjHSg91bJGnAkStAeEDIAg'
+
+app.config['DEBUG'] = True
+app.config['NOVA_ROOT_PATH'] = '/home/matthias/tmp/nova'
+app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(app.config['NOVA_ROOT_PATH'], 'nova.db')
+app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
+
+
+@app.template_filter('naturaltime')
+def naturaltime(t):
+    return humanize.naturaltime(t)
+
+
+@app.template_filter('naturalsize')
+def naturalsize(s):
+    return humanize.naturalsize(int(s))
+
+
+db = SQLAlchemy(app)
+
+login_manager = LoginManager(app)
+login_manager.login_view = 'login'
+
+fs = Filesystem(app)
+
+migrate = Migrate(app, db)
+
+
+from nova.models import User, Dataset, Access
+
+class AdminModelView(ModelView):
+    def is_accessible(self):
+        return current_user.is_authenticated and current_user.is_admin
+
+admin = Admin(app)
+admin.add_view(AdminModelView(User, db.session))
+admin.add_view(AdminModelView(Dataset, db.session))
+admin.add_view(AdminModelView(Access, db.session))
+
+
+from nova.resources import Datasets, Dataset
+
+errors = {
+    'BadSignature': {
+        'message': "Token signature could not be verified",
+        'status': 409,
+    },
+}
+
+api = Api(app, errors=errors)
+api.add_resource(Datasets, '/api/datasets')
+api.add_resource(Dataset, '/api/datasets/<dataset_id>')
+
+
+import nova.views

+ 32 - 0
nova/control.py

@@ -0,0 +1,32 @@
+import os
+import json
+import hashlib
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from models import Dataset
+
+
+
+DB_NAME = 'nova.db'
+
+
+def database_uri(path='.'):
+    return 'sqlite:///{}'.format(os.path.join(os.path.abspath(path), DB_NAME))
+
+
+class Control(object):
+    def __init__(self, path='.'):
+        self.path = os.path.abspath(path)
+        self.engine = create_engine(database_uri(path))
+        self.session_factory = sessionmaker(bind=self.engine)
+        self.session = self.session_factory()
+
+    def create_dataset(self, user, name, parent=None):
+        if parent:
+            parent = self.session.query(Dataset).filter(Dataset.name == parent).first()
+
+        path = hashlib.sha256(user.name + name).hexdigest()
+        dataset = Dataset(name=name, owner=user, path=path, parent=[parent])
+        abspath = os.path.join(self.path, path)
+        os.makedirs(abspath)
+

+ 69 - 0
nova/dataset.py

@@ -0,0 +1,69 @@
+import os
+import re
+import json
+
+
+def parent_path(parent):
+    chain = [os.path.join(s, '.nova') for s in parent.split('/') if s]
+    return os.path.join(*chain) if chain else ''
+
+
+class Dataset(object):
+    def __init__(self, path, name, parent, owner):
+        self.path = path
+        self.name = name
+        self.parent = parent
+        self.owner = owner
+
+    def commit(self):
+        path = os.path.join(self.path, parent_path(self.parent), self.name, '.nova')
+        os.makedirs(path)
+
+        with open(os.path.join(path, 'metadata.json'), 'w') as fp:
+            json.dump(dict(name=self.name, owner=self.owner.uid, parent=self.parent), fp)
+
+
+# def load_dataset(metadata_path):
+
+
+class Control(object):
+    def __init__(self, user_control, path='.'):
+        self.user_control = user_control
+        self.path = os.path.abspath(path)
+
+        for root, dirs, files in os.walk(self.path):
+            if '.nova' in dirs:
+                print root, dirs, files
+
+    def find_dataset(self, name, parent):
+        chain = [os.path.join(s, '.nova') for s in parent.split('/') if s]
+        chain = os.path.join(*chain) if chain else ''
+        d = os.path.join(self.path, chain, name, '.nova', 'metadata.json')
+
+        # if os.path.exists(d):
+        #     return load_dataset(
+        # print d
+
+    def create_dataset(self, name, parent, owner):
+        owner_uid = int(owner) if owner.isdigit() else None
+        owner_name = owner if not owner.isdigit() else None
+        result = self.user_control.find_user(name=owner_name, uid=owner_uid)
+        
+        if not result:
+            raise Exception("Owner {} not found".format(owner))
+
+        owner = result[0][1]
+
+        # Check if name is valid filename
+        if not re.match(r'^[A-Za-z0-9\.\[\]\(\)\+]+$', name):
+            raise Exception("{} is not a valid dataset".format(name))
+
+        # Check if dataset already exists
+        if self.find_dataset(name, parent):
+            raise Exception("{} already exists".format(name))
+
+        dataset = Dataset(self.path, name, parent, owner)
+        dataset.commit()
+        # path = os.path.join(self.path, parent, '.nova', name, '.nova')
+        # print path
+        # os.makedirs(path)

+ 31 - 0
nova/fs.py

@@ -0,0 +1,31 @@
+import os
+
+
+class Filesystem(object):
+    def __init__(self, app):
+        self.path = os.path.abspath(app.config.get('NOVA_ROOT_PATH', '.'))
+
+    def get_entries(self, dataset, path):
+        path = path if path else '.'
+        path = os.path.join(self.path, dataset.path, path)
+        return (os.path.join(path, e) for e in os.listdir(path))
+
+    def get_files(self, dataset, path):
+        return [(os.path.basename(e), os.stat(e).st_size) for e in self.get_entries(dataset, path) if not os.path.isdir(e)]
+
+    def get_dirs(self, dataset, path):
+        return [os.path.basename(e) for e in self.get_entries(dataset, path) if os.path.isdir(e)]
+
+    def get_statistics(self, datasets):
+        num_files = 0
+        total_size = 0
+
+        for dataset in datasets:
+            for root, dirs, files in os.walk(os.path.join(self.path, dataset.path)):
+                num_files += len(files)
+
+                for filename in files:
+                    path = os.path.join(root, filename)
+                    total_size += os.stat(path).st_size
+
+        return num_files, total_size

+ 33 - 0
nova/logic.py

@@ -0,0 +1,33 @@
+import os
+import datetime
+import hashlib
+from flask import abort
+from nova import app, db, models
+from itsdangerous import Signer, BadSignature
+
+
+def create_dataset(name, user, parent=None):
+    root = app.config['NOVA_ROOT_PATH']
+    path = hashlib.sha256(user.name + name + str(datetime.datetime.now())).hexdigest()
+    dataset = models.Dataset(name=name, path=path, parent=[parent] if parent else [])
+    abspath = os.path.join(root, path)
+    os.makedirs(abspath)
+
+    access = models.Access(user=user, dataset=dataset, owner=True, writable=True, seen=True)
+    db.session.add_all([dataset, access])
+    db.session.commit()
+    return dataset
+
+
+def check_token(token):
+    uid, signature = token.split('.')
+    user = db.session.query(models.User).filter(models.User.id == int(uid)).first()
+    signer = Signer(user.password.hash + user.token_time.isoformat())
+
+    try:
+        if uid != signer.unsign(token):
+            abort(401)
+    except BadSignature:
+        abort(401)
+
+    return user

+ 28 - 0
nova/memtar.py

@@ -0,0 +1,28 @@
+import os
+import io
+import tarfile
+
+
+def create_tar(path):
+    fileobj = io.BytesIO()
+    tar = tarfile.open(mode='w:gz', fileobj=fileobj)
+
+    for root, dirs, files in os.walk(path):
+        for fn in files:
+            p = os.path.join(root, fn)
+
+            # remove one more character to remove trailing slash
+            arcname = p[p.find(path)+len(path)+1:]
+
+            if not arcname.startswith('.nova'):
+                tar.add(p, arcname=arcname)
+
+    tar.close()
+    return fileobj
+
+
+def extract_tar(fileobj, path):
+    fileobj.seek(0)
+    tar = tarfile.open(mode='r:gz', fileobj=fileobj)
+    tar.extractall(path)
+    tar.close()

+ 87 - 0
nova/models.py

@@ -0,0 +1,87 @@
+import datetime
+import hashlib
+from nova import db
+from sqlalchemy_utils import PasswordType, force_auto_coercion
+
+
+force_auto_coercion()
+
+
+class User(db.Model):
+
+    __tablename__ = 'users'
+
+    id = db.Column(db.Integer, primary_key=True)
+    name = db.Column(db.String, unique=True)
+    email = db.Column(db.String)
+    fullname = db.Column(db.String)
+    is_admin = db.Column(db.Boolean, default=False)
+    password = db.Column(PasswordType(
+        schemes=['pbkdf2_sha512'],
+        pbkdf2_sha512__default_rounds=50000,
+        pbkdf2_sha512__salt_size=16),
+        nullable=False)
+    token = db.Column(db.String)
+    token_time = db.Column(db.DateTime)
+    gravatar = db.Column(db.String)
+
+    def __init__(self, name=None, fullname=None, email=None, password=None, is_admin=False):
+        self.name = name
+        self.fullname = fullname
+        self.email = email
+        self.password = password
+        self.is_admin = is_admin
+        self.gravatar = hashlib.md5(email.lower()).hexdigest()
+        self.token = None
+
+    def __repr__(self):
+        return '<User(name={}, fullname={}>'.format(self.name, self.fullname)
+
+    def is_authenticated(self):
+        return True
+
+    def is_active(self):
+        return True
+
+    def is_anonymous(self):
+        False
+
+    def get_id(self):
+        return self.name
+
+
+class Dataset(db.Model):
+
+    __tablename__ = 'datasets'
+
+    id = db.Column(db.Integer, primary_key=True)
+    name = db.Column(db.String)
+    path = db.Column(db.String)
+    created = db.Column(db.DateTime, default=datetime.datetime.utcnow)
+    closed = db.Column(db.Boolean, default=False)
+    parent_id = db.Column(db.Integer, db.ForeignKey('datasets.id'), nullable=True)
+
+    parent = db.relationship('Dataset')
+
+    def __repr__(self):
+        return '<Dataset(name={}, path={}>'.format(self.name, self.path)
+
+
+class Access(db.Model):
+
+    __tablename__ = 'accesses'
+
+    id = db.Column(db.Integer, primary_key=True)
+    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
+    dataset_id = db.Column(db.Integer, db.ForeignKey('datasets.id'))
+
+    owner = db.Column(db.Boolean)
+    writable = db.Column(db.Boolean)
+    seen = db.Column(db.Boolean, default=False)
+
+    user = db.relationship('User')
+    dataset = db.relationship('Dataset')
+
+    def __repr__(self):
+        return '<Access(user={}, dataset={}, owner={}, writable={}>'.format(
+                self.user.name, self.dataset.name, self.owner, self.writable)

+ 6 - 0
nova/processors.py

@@ -0,0 +1,6 @@
+import subprocess
+
+
+class CommandLine(object):
+    def __init__(self):
+        pass

+ 51 - 0
nova/resources.py

@@ -0,0 +1,51 @@
+from functools import wraps
+from flask import request
+from flask_restful import Resource, abort, reqparse
+from itsdangerous import Signer, BadSignature
+from nova import db, models, logic
+
+
+def get_user():
+    uid = int(request.args['token'].split('.')[0])
+    return db.session.query(models.User).filter(models.User.id == uid).first()
+
+
+def authenticate(func):
+    @wraps(func)
+    def wrapper(*args, **kwargs):
+        if logic.check_token(request.args['token']):
+            return func(*args, **kwargs)
+    return wrapper
+
+
+class Datasets(Resource):
+    method_decorators = [authenticate]
+
+    def get(self):
+        user = get_user()
+
+        return [dict(name=d.name, id=d.id) for d in 
+                    db.session.query(models.Dataset).\
+                    filter(models.Access.user == user).\
+                    all()]
+
+    def post(self):
+        parser = reqparse.RequestParser()
+        parser.add_argument('name', type=str, help="Dataset name")
+        args = parser.parse_args()
+
+        user = get_user()
+        dataset = logic.create_dataset(args.name, user)
+        return dict(id=dataset.id)
+
+
+class Dataset(Resource):
+    method_decorators = [authenticate]
+
+    def get(self, dataset_id):
+        user = get_user()
+        dataset = db.session.query(models.Dataset).\
+                filter(models.Access.user == user).\
+                filter(models.Dataset.id == dataset_id).\
+                first()
+        return dict(name=dataset.name)

+ 32 - 0
nova/templates/dataset/create.html

@@ -0,0 +1,32 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>New dataset</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-6">
+    <div class="bs-component">
+      <form class="form-horizontal" method="POST" action="/create">
+        <fieldset>
+          <div class="form-group">
+            <label for="inputName" class="col-lg-2 control-label">Name</label>
+            <div class="col-lg-10">
+              {{ form.csrf_token }}
+              <input type="text" id="inputName" name="name" class="form-control">
+            </div>
+          </div>
+          <div class="form-group">
+            <div class="col-lg-10 col-lg-offset-2">
+            <button type="submit" class="btn btn-primary">Create</button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    </div>
+  </div>
+</div>
+{% endblock %}

+ 69 - 0
nova/templates/dataset/detail.html

@@ -0,0 +1,69 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>Preview</h2>
+    </div>
+  </div>
+</div>
+{% if origin %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>Related</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    <p>
+    {% for p in origin %}
+    <a class="btn btn-link" href="/detail/{{ p.id }}">{{ p.name }}</a> <i class="fa fa-angle-right"></i>
+    {% endfor %}
+    <a class="btn btn-link active" href="#">{{ dataset.name }}</a>
+    </p>
+  </div>
+</div>
+{% endif %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>Files</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    <ol class="breadcrumb">
+      <li><a href="/detail/{{ dataset.id }}">root</a></li>
+      {% for part, path in subpaths %}
+      <li><a href="/detail/{{ dataset.id }}/{{ path }}">{{ part }}</a></li>
+      {% endfor %}
+    </ol>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    <table class="table table-hover">
+    {% for dirname in dirs %}
+    <tr>
+      {% if path %}
+        <td><a href="/detail/{{ dataset.id }}/{{ path }}/{{ dirname }}">{{ dirname }}</a></td>
+      {% else %}
+        <td><a href="/detail/{{ dataset.id }}/{{ dirname }}">{{ dirname }}</a></td>
+      {% endif %}
+      <td></td>
+    </tr>
+    {% endfor %}
+    {% for filename, filesize in files %}
+    <tr>
+      <td>{{ filename }}</td>
+      <td>{{ filesize | naturalsize }}</td>
+    </tr>
+    {% endfor %}
+    </table>
+  </div>
+</div>
+{% endblock %}
+

+ 43 - 0
nova/templates/dataset/process.html

@@ -0,0 +1,43 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>Process</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    <div class="panel-group" id="accordion" role="tablist">
+      <div class="panel panel-default">
+        <div class="panel-heading" role="tab">
+          <h4 class="panel-title">
+            Copy
+          </h4>
+        </div>
+        <div class="panel-collapse collapse in" role="tabpanel">
+          <div class="panel-body">
+            <p>Duplicates the original data <em>as is</em>.</p>
+
+            <form class="form-horizontal" method="POST" action="/process/{{ dataset.id }}/copy">
+            <div class="form-group">
+              <label for="inputName" class="col-lg-2 control-label">Name</label>
+              <div class="col-lg-10">
+                <input type="text" id="inputName" name="name" class="form-control" placeholder="{{ dataset.name }}-copy">
+              </div>
+            </div>
+              <div class="form-group">
+                <div class="col-lg-10 col-lg-offset-2">
+                <button type="submit" class="btn btn-primary"><i class="fa fa-cogs"></i>&nbsp; Run</button>
+                </div>
+              </div>
+            </form>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+{% endblock %}

+ 20 - 0
nova/templates/dataset/share.html

@@ -0,0 +1,20 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>Share with ...</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    <ul>
+      {% for user in users %}
+      <li><a href="/share/{{ dataset_id }}/{{ user.id }}">{{ user.name }}</a></li>
+      {% endfor %}
+    </ul>
+  </div>
+</div>
+{% endblock %}
+

+ 66 - 0
nova/templates/index.html

@@ -0,0 +1,66 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>Datasets</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    {% if shared %}
+    <div class="alert alert-info">
+      <button type="button"class="close" data-dismiss="alert" aria-label="Close">&times;</button>
+      {{ shared | join(', ', attribute='name') }} has been shared with you.
+    </div>
+    {% endif %}
+    {% if result %}
+    <table class="table table-hover">
+      <thead>
+        <td>Name</td>
+        <td>Created</td>
+        <td></td>
+      </thead>
+    {% for dataset, access in result %}
+      <tr>
+        <td><a href="/detail/{{ dataset.id }}">{{ dataset.name }}</a></td>
+        <td>{{ dataset.created | naturaltime }}</td>
+        <td>
+          <div class="btn-group-xs" role="group">
+            <a href="/download/{{ dataset.id }}" type="button" class="btn
+              btn-default"><i class="fa fa-download"></i></a>
+            <a href="/share/{{ dataset.id }}" type="button" class="btn btn-default"><i class="fa fa-share-alt"></i></a>
+            {% if access.writable %}
+            <a href="/process/{{ dataset.id }}" type="button" class="btn btn-default"><i class="fa fa-cogs"></i></a>
+            <a href="/delete/{{ dataset.id }}" type="button" class="btn btn-danger"><i class="fa fa-trash"></i></a>
+            {% endif %}
+          </div>
+        </td>
+      </tr>
+    {% endfor %}
+    </table>
+    {% endif %}
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    <a href="/create" type="button" class="btn btn-primary">New dataset</a>
+  </div>
+</div>
+
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>Statistics</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    <p>You are using {{ total_size | naturalsize }} in {{ num_files }} files spanning {{ datasets|length
+    }} datasets.</p>
+  </div>
+</div>
+
+{% endblock %}

+ 44 - 0
nova/templates/layout.html

@@ -0,0 +1,44 @@
+<!doctype html>
+<html>
+  <head>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <link rel="stylesheet" style="text/css" href="{{ url_for('static', filename='bootswatch-dist/css/bootstrap.min.css') }}" />
+
+    <link rel="stylesheet" href="{{ url_for('static', filename='fontawesome/css/font-awesome.min.css') }}">
+    <title>NOVA</title>
+  </head>
+  <body>
+    <nav class="navbar navbar-default">
+      <div class="container">
+        <div class="navbar-header">
+          <a class="navbar-brand" href="/">NOVA</a>
+        </div>
+        {% if current_user.is_authenticated %}
+        <ul class="nav navbar-nav navbar-right">
+          <li class="dropdown">
+          <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button">
+            <img src="https://www.gravatar.com/avatar/{{ current_user.gravatar
+            }}?s=24"/>&nbsp; {{ current_user.name }} &nbsp;<i class="fa
+              fa-caret-down" aria-hidden="true"></i></a>
+          <ul class="dropdown-menu">
+            <li>
+            <a href="/user/settings"><i class="fa fa-user"></i>&nbsp; Settings</a></li>
+            {% if current_user.is_admin %}
+            <li><a href="/user/admin"><i class="fa fa-users"></i>&nbsp; Users</a></li>
+            {% endif %}
+            <li role="separator" class="divider"></li>
+            <li><a href="/logout"><i class="fa fa-sign-out"></i>&nbsp; Sign out</a></li>
+          </ul>
+          </li>
+        </ul>
+        {% endif %}
+      </div>
+    </nav>
+    <div class="container">
+    {% block body %}{% endblock %}
+    </div>
+   <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script>
+
+   <script src="{{ url_for('static', filename='bootswatch-dist/js/bootstrap.min.js') }}"></script>
+  </body>
+</html>

+ 35 - 0
nova/templates/user/admin.html

@@ -0,0 +1,35 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>Users</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    <table class="table table-hover">
+      <thead>
+        <tr>
+          <th>Name</th>
+          <th>Full name</th>
+          <th>Email</th>
+          <th>Admin</th>
+        </tr>
+      </thead>
+      <tbody>
+      {% for user in users %}
+        <tr>
+          <td>{{ user.name }}</td>
+          <td>{{ user.fullname }}</td>
+          <td>{{ user.email }} </td>
+          <td>{% if user.is_admin %}<i class="fa fa-check"></i>{% endif %}</td>
+        </tr>
+      {% endfor %}
+      </tbody>
+    </table>
+    <a href="/signup" class="btn btn-primary">New user</a>
+  </div>
+</div>
+{% endblock %}

+ 38 - 0
nova/templates/user/login.html

@@ -0,0 +1,38 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h1>Login</h1>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-6">
+    <div class="well bs-component">
+      <form class="form-horizontal" method="POST" action="/login">
+        <fieldset>
+          <div class="form-group">
+            <label for="inputName" class="col-lg-2 control-label">Name</label>
+            <div class="col-lg-10">
+              {{ form.csrf_token }}
+              <input type="text" id="inputName" name="name" class="form-control">
+            </div>
+          </div>
+          <div class="form-group">
+            <label for="inputPassword" class="col-lg-2 control-label">Password</label>
+            <div class="col-lg-10">
+              <input type="password" id="inputPassword" name="password" class="form-control" placeholder="Password">
+            </div>
+          </div>
+          <div class="form-group">
+            <div class="col-lg-10 col-lg-offset-2">
+            <button type="submit" class="btn btn-primary">Login</button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    </div>
+  </div>
+</div>
+{% endblock %}

+ 25 - 0
nova/templates/user/settings.html

@@ -0,0 +1,25 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>Settings</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    <h3>Token</h3>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-12">
+    {% if not current_user.token %}
+    <a href="/user/token/generate" type="button" class="btn btn-primary">Generate</a>
+    {% else %}
+    <p>Use <strong>{{ current_user.token }}</strong> for authentication.</p>
+    <p><a href="/user/token/revoke" type="button" class="btn btn-default">Revoke</a></p>
+    {% endif %}
+  </div>
+</div>
+{% endblock %}

+ 60 - 0
nova/templates/user/signup.html

@@ -0,0 +1,60 @@
+{% extends "layout.html" %}
+{% block body %}
+<div class="row">
+  <div class="col-lg-12">
+    <div class="page-header">
+      <h2>New dataset</h2>
+    </div>
+  </div>
+</div>
+<div class="row">
+  <div class="col-lg-6">
+    <div class="bs-component">
+      <form class="form-horizontal" method="POST" action="/signup">
+        <fieldset>
+          <div class="form-group">
+            <label for="inputName" class="col-lg-2 control-label">Name</label>
+            <div class="col-lg-10">
+              {{ form.csrf_token }}
+              <input type="text" id="inputName" name="name" class="form-control">
+            </div>
+          </div>
+          <div class="form-group">
+            <label for="inputFullName" class="col-lg-2 control-label">Full name</label>
+            <div class="col-lg-10">
+              <input type="text" id="inputFullName" name="fullname" class="form-control">
+            </div>
+          </div>
+          <div class="form-group">
+            <label for="inputEmail" class="col-lg-2 control-label">Email</label>
+            <div class="col-lg-10">
+              <input type="text" id="inputEmail" name="email" class="form-control">
+            </div>
+          </div>
+          <div class="form-group">
+            <label for="inputPassword" class="col-lg-2 control-label">Password</label>
+            <div class="col-lg-10">
+              <input type="password" id="inputPassword" name="password" class="form-control">
+            </div>
+          </div>
+          <div class="form-group">
+            <div class="col-lg-offset-2 col-lg-10">
+              <div class="checkbox">
+                <label>
+                  <input type="checkbox" name="is_admin"> Admin
+                </label>
+              </div>
+            </div>
+          </div>
+        </div>
+          <div class="form-group">
+            <div class="col-lg-10">
+            <button type="submit" class="btn btn-primary">Submit</button>
+            </div>
+          </div>
+        </fieldset>
+      </form>
+    </div>
+  </div>
+</div>
+{% endblock %}

+ 313 - 0
nova/views.py

@@ -0,0 +1,313 @@
+import os
+import io
+import shutil
+import datetime
+from functools import wraps
+from nova import app, db, login_manager, fs, logic, memtar
+from nova.models import User, Dataset, Access
+from flask import (Response, render_template, request, flash, redirect,
+                   abort, url_for)
+from flask_login import login_required, login_user, logout_user, current_user
+from flask_wtf import Form
+from wtforms import StringField, BooleanField
+from wtforms.validators import DataRequired
+from itsdangerous import Signer
+
+
+
+def login_required(admin=False):
+    def wrapper(func):
+        @wraps(func)
+        def decorated_view(*args, **kwargs):
+            if not current_user.is_authenticated:
+                return login_manager.unauthorized()
+
+            if admin and not current_user.is_admin:
+                return login_manager.unauthorized()
+
+            return func(*args, **kwargs)
+        return decorated_view
+    return wrapper
+
+
+class LoginForm(Form):
+    name = StringField('name', validators=[DataRequired()])
+    password = StringField('password', validators=[DataRequired()])
+
+
+class SignupForm(Form):
+    name = StringField('name', validators=[DataRequired()])
+    fullname = StringField('fullname', validators=[DataRequired()])
+    email = StringField('email', validators=[DataRequired()])
+    password = StringField('password', validators=[DataRequired()])
+    is_admin = BooleanField('is_admin')
+
+
+class CreateForm(Form):
+    name = StringField('name', validators=[DataRequired()])
+
+
+@login_manager.user_loader
+def load_user(user_id):
+    return db.session.query(User).filter(User.name == user_id).first()
+
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+    form = LoginForm()
+
+    if form.validate_on_submit():
+        user = db.session.query(User).filter(User.name == form.name.data).first()
+
+        if user.password == form.password.data:
+            login_user(user)
+
+            flash('Logged in successfully')
+            return redirect(url_for('index'))
+        else:
+            return abort(401)
+
+    return render_template('user/login.html', form=form)
+
+
+@app.route('/logout')
+@login_required(admin=False)
+def logout():
+    logout_user()
+    return redirect(url_for('index'))
+
+
+@app.route('/')
+@login_required(admin=False)
+def index():
+    result = db.session.query(Dataset, Access).\
+            filter(Access.user == current_user).\
+            filter(Access.dataset_id == Dataset.id).\
+            all()
+
+    datasets, accesses = zip(*result) if result else ([], [])
+
+    shared = db.session.query(Dataset, Access).\
+            filter(Access.user == current_user).\
+            filter(Access.dataset_id == Dataset.id).\
+            filter(Access.owner == False).\
+            filter(Access.seen == False).\
+            all()
+
+    shared, shared_accesses = zip(*shared) if shared else ([], [])
+
+    for access in shared_accesses:
+        access.seen = True
+
+    db.session.commit()
+
+    # XXX: we should cache this and compute outside
+    num_files, total_size = fs.get_statistics(datasets)
+    return render_template('index.html', result=result, shared=shared, num_files=num_files, total_size=total_size)
+
+
+@app.route('/user/admin')
+@login_required(admin=True)
+def admin():
+    users = db.session.query(User).all()
+    return render_template('user/admin.html', users=users)
+
+
+@app.route('/user/settings')
+@login_required(admin=False)
+def settings():
+    return render_template('user/settings.html')
+
+
+@app.route('/user/token/generate')
+@login_required(admin=False)
+def generate_token():
+    time = datetime.datetime.utcnow()
+    signer = Signer(current_user.password.hash + time.isoformat())
+    current_user.token = signer.sign(str(current_user.id))
+    current_user.token_time = time
+    db.session.commit()
+    return redirect(url_for('settings'))
+
+
+@app.route('/user/token/revoke')
+@login_required(admin=False)
+def revoke_token():
+    signer = Signer(current_user.password.hash)
+    current_user.token = None
+    db.session.commit()
+    return redirect(url_for('settings'))
+
+
+@app.route('/signup', methods=['GET', 'POST'])
+@login_required(admin=True)
+def signup():
+    form = SignupForm()
+
+    if form.validate_on_submit():
+        user = User(name=form.name.data, fullname=form.fullname.data,
+                    email=form.email.data, password=form.password.data,
+                    is_admin=form.is_admin.data)
+        db.session.add(user)
+        db.session.commit()
+        return redirect(url_for('admin'))
+
+    return render_template('user/signup.html', form=form)
+
+
+@app.route('/create', methods=['GET', 'POST'])
+@login_required(admin=False)
+def create():
+    form = CreateForm()
+
+    if form.validate_on_submit():
+        logic.create_dataset(form.name.data, current_user)
+        return redirect(url_for('index'))
+
+    return render_template('dataset/create.html', form=form)
+
+
+@app.route('/share/<int:dataset_id>')
+@app.route('/share/<int:dataset_id>/<int:user_id>')
+@login_required(admin=False)
+def share(dataset_id, user_id=None):
+    if not user_id:
+        users = db.session.query(User).filter(User.id != current_user.id).all()
+        return render_template('dataset/share.html', users=users, dataset_id=dataset_id)
+
+    user = db.session.query(User).filter(User.id == user_id).first()
+    dataset = db.session.query(Dataset).filter(Dataset.id == dataset_id).first()
+    access = Access(user=user, dataset=dataset, owner=False, writable=False)
+    db.session.add(access)
+    db.session.commit()
+    return redirect(url_for('index'))
+
+
+def copytree(src, dst, symlinks=False, ignore=None):
+    for item in os.listdir(src):
+        s = os.path.join(src, item)
+        d = os.path.join(dst, item)
+        if os.path.isdir(s):
+            copytree(s, d, symlinks, ignore)
+        else:
+            if not os.path.exists(d) or os.stat(s).st_mtime - os.stat(d).st_mtime > 1:
+                shutil.copy2(s, d)
+
+
+def copy(dataset, parent):
+    root = app.config['NOVA_ROOT_PATH']
+    src = os.path.join(root, parent.path)
+    dst = os.path.join(root, dataset.path)
+    app.logger.info("Copy data from {} to {}".format(src, dst))
+    copytree(src, dst)
+
+
+processors = {
+    'copy': copy
+}
+
+
+@app.route('/process/<int:dataset_id>')
+@app.route('/process/<int:dataset_id>/<process>', methods=['GET', 'POST'])
+@login_required(admin=False)
+def process(dataset_id, process=None):
+    parent = db.session.query(Dataset).filter(Dataset.id == dataset_id).first()
+
+    if not process:
+        return render_template('dataset/process.html', dataset=parent)
+
+    dataset = logic.create_dataset(request.form['name'], current_user, parent=parent)
+    processors[process](dataset, parent)
+
+    return redirect(url_for('index'))
+
+
+@app.route('/detail/<int:dataset_id>')
+@app.route('/detail/<int:dataset_id>/<path:path>')
+@login_required(admin=False)
+def detail(dataset_id=None, path=''):
+    dataset = db.session.query(Dataset).\
+            filter(Dataset.id == dataset_id).\
+            filter(Access.user == current_user).\
+            filter(Access.dataset_id == dataset_id).first()
+
+    # FIXME: scream if no dataset found
+    origin = []
+    parent = dataset.parent[0] if dataset.parent else None
+
+    while parent:
+        origin.append(parent)
+        parent = parent.parent[0] if parent.parent else None
+
+    origin = origin[::-1]
+
+    parts = path.split('/')
+    subpaths = []
+
+    for part in parts:
+        if subpaths:
+            subpaths.append((part, os.path.join(subpaths[-1][1], part)))
+        else:
+            subpaths.append((part, part))
+
+    dirs = fs.get_dirs(dataset, path)
+    files = fs.get_files(dataset, path)
+    params = dict(dataset=dataset, path=path, subpaths=subpaths, 
+                  files=files, dirs=dirs, origin=origin)
+
+    return render_template('dataset/detail.html', **params)
+
+
+@app.route('/delete/<int:dataset_id>')
+@login_required(admin=False)
+def delete(dataset_id=None):
+    result = db.session.query(Dataset, Access).\
+            filter(Access.user == current_user).\
+            filter(Access.dataset_id == dataset_id).first()
+
+    dataset, access = result
+
+    if dataset:
+        db.session.delete(dataset)
+        db.session.delete(access)
+        db.session.commit()
+
+    return redirect(url_for('index'))
+
+
+@app.route('/upload/<int:dataset_id>', methods=['POST'])
+def upload(dataset_id):
+    user = logic.check_token(request.args.get('token'))
+    dataset = db.session.query(Dataset).\
+            filter(Access.user == user).\
+            filter(Access.dataset_id == dataset_id).\
+            filter(Dataset.id == dataset_id).first()
+
+    f = io.BytesIO(request.data)
+    memtar.extract_tar(f, os.path.join(app.config['NOVA_ROOT_PATH'], dataset.path))
+    return ''
+
+
+@app.route('/clone/<int:dataset_id>')
+def clone(dataset_id):
+    user = logic.check_token(request.args.get('token'))
+    dataset = db.session.query(Dataset).\
+            filter(Access.user == user).\
+            filter(Access.dataset_id == dataset_id).\
+            filter(Dataset.id == dataset_id).first()
+
+    root = app.config['NOVA_ROOT_PATH']
+    path = os.path.join(root, dataset.path)
+    fileobj = memtar.create_tar(path)
+    fileobj.seek(0)
+
+    def generate():
+        while True:
+            data = fileobj.read(4096)
+
+            if not data:
+                break
+
+            yield data
+
+    return Response(generate(), mimetype='application/gzip')

+ 10 - 0
requirements.txt

@@ -0,0 +1,10 @@
+humanize
+Flask==0.11.0
+Flask-Admin
+Flask-Cache
+Flask-Login
+Flask-Migrate
+Flask-SQLAlchemy
+Flask-WTF
+SQLAlchemy-Utils
+passlib

+ 23 - 0
setup.py

@@ -0,0 +1,23 @@
+import os
+from nova import __version__
+from setuptools import setup, find_packages
+
+
+
+setup(
+    name='nova',
+    version=__version__,
+    author='Matthias Vogelgesang',
+    author_email='matthias.vogelgesang@kit.edu',
+    url='http://github.com/ufo-kit/nova',
+    license='LGPL',
+    packages=find_packages(exclude=['*.tests']),
+    scripts=['bin/novactl', 'bin/nova'],
+    exclude_package_data={'': ['README.rst']},
+    description="NOVA data suite",
+    install_requires=[
+        'passlib',
+        'sqlalchemy>=0.11',
+        ],
+    # long_description=open('README.rst').read(),
+)