Index: ow/migrations/3.py
===================================================================
--- ow/migrations/3.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/migrations/3.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
@@ -0,0 +1,24 @@
+from ow.models.bulk import BulkFiles
+
+
+def migrate(root):
+    """
+    Adds the _bulk_files folder to the root object (a ow.models.BulkFiles
+    instance, see ticket #77 for more information)
+
+    >>> from ow.models.root import OpenWorkouts
+    >>> from ow.models.bulk import BulkFiles
+    >>> root = OpenWorkouts()
+    >>> '_bulk_files' in root.keys()
+    False
+    >>> migrate(root)
+    >>> '_bulk_files' in root.keys()
+    True
+    >>> isinstance(root['_bulk_files'], BulkFiles)
+    True
+    >>> len(root['_bulk_files'])
+    0
+    >>>
+    """
+    if '_bulk_files' not in root.keys():
+        root['_bulk_files'] = BulkFiles()
Index: ow/models/__init__.py
===================================================================
--- ow/models/__init__.py	(revision dba0ae9aecb236e6740e43c7b15b6353bde0a79b)
+++ ow/models/__init__.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
@@ -2,4 +2,5 @@
 
 from ow.models.root import OpenWorkouts
+from ow.models.bulk import BulkFiles
 from ow.catalog import get_catalog
 
@@ -8,4 +9,5 @@
     if 'app_root' not in zodb_root:
         app_root = OpenWorkouts()
+        app_root['_bulk_files'] = BulkFiles()
         zodb_root['app_root'] = app_root
         transaction.commit()
Index: ow/models/bulk.py
===================================================================
--- ow/models/bulk.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/models/bulk.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
@@ -0,0 +1,86 @@
+from uuid import uuid1
+from datetime import datetime, timedelta, timezone
+
+from repoze.folder import Folder
+from pyramid.security import Allow, Deny, Everyone, ALL_PERMISSIONS
+
+import pytz
+
+
+class BulkFile(Folder):
+
+    """
+    Object that maps to a compressed file uploaded by a user to upload several
+    workout tracking files at once.
+    """
+
+    __parent__ = __name__ = None
+
+    def __acl__(self):
+        """
+        Owner of the compressed file has full permissions to the file, as well
+        as system admins. Everybody else has no permissions.
+        """
+        permissions = [
+            (Allow, str(self.uid), 'view'),
+            (Allow, str(self.uid), 'edit'),
+            (Allow, str(self.uid), 'delete'),
+            (Deny, Everyone, ALL_PERMISSIONS)
+        ]
+        return permissions
+
+    def __init__(self, **kw):
+        super(BulkFile, self).__init__()
+        self.bfid = uuid1()
+        self.uid = kw['uid']  # required, so let it blow if none is given
+        self.uploaded = kw.get('uploaded', datetime.now(timezone.utc))
+        self.compressed_file = kw.get('compressed_file', None)  # Blob
+        self.file_name = ''  # unicode string
+        self.file_type = ''  # unicode string
+        self.loaded = False  # datetime when workouts have been loaded
+        self.workout_ids = []  # ids of the workouts loaded from this file
+
+    def _in_timezone(self, timezone, value):
+        """
+        Return a string representation of the given value  date and time,
+        localized into the given timezone
+        """
+        _value = value.astimezone(pytz.timezone(timezone))
+        return _value.strftime('%d/%m/%Y %H:%M (%Z)')
+
+    def uploaded_in_timezone(self, timezone):
+        return self._in_timezone(timezone, self.uploaded)
+
+    def loaded_in_timezone(self, timezone):
+        if self.loaded:
+            return self._in_timezone(timezone, self.loaded)
+        return ''
+
+
+class BulkFiles(Folder):
+
+    """
+    Container for bulk upload compressed files
+    """
+
+    __parent__ = __name__ = None
+
+    def __acl__(self):
+        """
+        Everybody can view, super users can edit
+        """
+        permissions = [
+            (Allow, Everyone, 'view'),
+            (Allow, 'admins', 'edit'),
+            (Deny, Everyone, ALL_PERMISSIONS)
+        ]
+        return permissions
+
+    def add_bulk_file(self, bulk_file):
+        self[str(bulk_file.bfid)] = bulk_file
+
+    def get_by_uid(self, uid):
+        """
+        Return bulk files owned by the given uid
+        """
+        return [self[bf] for bf in self if self[bf].uid == str(uid)]
Index: ow/schemas/bulk.py
===================================================================
--- ow/schemas/bulk.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/schemas/bulk.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
@@ -0,0 +1,15 @@
+from formencode import Schema, validators
+
+from ow.schemas.blob import FieldStorageBlob
+
+
+class BulkFileSchema(Schema):
+    """
+    Schema for a compressed file used to upload workouts in "bulk" mode.
+    We ask only for the compressed file.
+    """
+    allow_extra_fields = True
+    filter_extra_fields = True
+    # This one can not be none, it is a required field
+    compressed_file = FieldStorageBlob(not_empty=True,
+                                       whitelist=['zip', 'tgz'])
Index: ow/templates/add_bulk_file.pt
===================================================================
--- ow/templates/add_bulk_file.pt	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/templates/add_bulk_file.pt	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
@@ -0,0 +1,85 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xml:lang="en"
+      xmlns:tal="http://xml.zope.org/namespaces/tal"
+      xmlns:metal="http://xml.zope.org/namespaces/metal"
+      xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+      i18n:domain="OpenWorkouts"
+      metal:use-macro="load: base.pt"
+      tal:attributes="lang request.locale_name">
+
+  <metal:head-title metal:fill-slot="head-title">
+    <tal:t i18n:translate="">Upload file with multiple workouts</tal:t>
+  </metal:head-title>
+
+  <metal:title metal:fill-slot="title">
+    <tal:t i18n:translate="">Upload file with multiple workouts</tal:t>
+  </metal:title>
+
+  <metal:content metal:fill-slot="content">
+
+    <div class="uploading-workout hidden">
+      <h2 i18n:translate="">Uploading workouts file, please wait...</h2>
+      <p i18n:translate="">
+        This can take a moment, depending on the size of your workouts file and your connection speed. Be patient and please don't hit the stop or go back buttons on your browser.
+      </p>
+      <img src="" alt="Uploading workouts file" i18n:attributes="alt"
+           tal:attributes="src request.static_url('ow:static/media/img/loading.gif')">
+    </div>
+
+    <div class="upload-workout ow-forms">
+      <a href="" class="back"
+         tal:attributes="href request.resource_url(context)"
+         i18n:translate="">Back</a>
+      <h2 i18n:translate="">Upload file with multiple workouts</h2>
+
+      <p tal:condition="request.root['_bulk_files'].get_by_uid(context.uid)">
+        <a href="" tal:attributes="href request.resource_url(context, 'bulk-files')">Manage your uploaded bulk files</a>
+      </p>
+
+      <p i18n:translate="">
+        Add several workouts by providing several tracking files (fit, gpx) in a compressed file (zip, gz, etc)
+      </p>
+
+      <p i18n:translate="">
+        Once uploaded, the tracking files will be extracted, analyzed and imported into your workouts timeline.
+        This task can take some time, so be patient, workouts will start appearing on your timeline as they are
+        added.
+      </p>
+
+      ${form.begin(multipart=True)}
+      ${form.csrf_token()}
+
+
+      <fieldset>
+
+        <p>
+          <label for="compressed_file" i18n:translate="">
+            Workouts file (zip, gz, tgz)</label>
+          ${form.errorlist('compressed_file')}
+          ${form.file('compressed_file')}
+        </p>
+
+      </fieldset>
+
+      <tal:with-localizer tal:define="localizer get_localizer(request)">
+        ${form.submit("submit", localizer.translate(_('Save')),  **{'class':"button button-normal"})}
+      </tal:with-localizer>
+
+      ${form.end()}
+        </div>
+
+  </metal:content>
+
+  <metal:body-js metal:fill-slot="body-js">
+    <script>
+        $(document).ready(function(){
+            $("div.upload-workout form").on("submit", function(){
+                console.log('Showing spinner');
+                $("div.upload-workout").fadeOut();
+                $("div.uploading-workout").fadeIn();
+            });
+        });
+    </script>
+  </metal:body-js>
+
+</html>
Index: ow/templates/base.pt
===================================================================
--- ow/templates/base.pt	(revision dba0ae9aecb236e6740e43c7b15b6353bde0a79b)
+++ ow/templates/base.pt	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
@@ -85,4 +85,9 @@
                   <li>
                     <a href="" i18n:translate=""
+                       tal:attributes="href request.resource_url(request.root, current_user, 'add-bulk-file')">
+                      Bulk upload</a>
+                  </li>
+                  <li>
+                    <a href="" i18n:translate=""
                        tal:attributes="href request.resource_url(request.root, current_user, 'add-workout-manually')">
                       Add manually</a>
Index: ow/templates/bulk_files.pt
===================================================================
--- ow/templates/bulk_files.pt	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/templates/bulk_files.pt	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
@@ -0,0 +1,69 @@
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xml:lang="en"
+      xmlns:tal="http://xml.zope.org/namespaces/tal"
+      xmlns:metal="http://xml.zope.org/namespaces/metal"
+      xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+      i18n:domain="OpenWorkouts"
+      metal:use-macro="load: base.pt"
+      tal:attributes="lang request.locale_name">
+
+  <metal:head-title metal:fill-slot="head-title">
+    <tal:t i18n:translate="">Multiple file uploads</tal:t>
+  </metal:head-title>
+
+  <metal:title metal:fill-slot="title">
+    <tal:t i18n:translate="">Multiple file uploads</tal:t>
+  </metal:title>
+
+  <metal:content metal:fill-slot="content">
+
+    <div class="bulk-files ow-forms">
+      <a href="" class="back"
+         tal:attributes="href request.resource_url(context)"
+         i18n:translate="">Back</a>
+      <h2 i18n:translate="">Multiple file uploads</h2>
+
+      <tal:bulk_files tal:repeat="bulk_file bulk_files">
+        <div class="bulk-file">
+
+          <h3 tal:content="bulk_file.file_name"></h3>
+
+          <p>
+            <span i18n:translate="">Uploaded on</span>
+            <span tal:content="bulk_file.uploaded_in_timezone(context.timezone)"></span>
+          </p>
+
+          <p>
+            <tal:loaded tal:condition="bulk_file.loaded">
+              <span i18n:translate="">File loaded on></span>
+              <span tal:content="bulk_file.loaded_in_timezone(context.timezone)"></span>
+            </tal:loaded>
+            <tal:not_loaded tal:condition="not bulk_file.loaded">
+              <span i18n:translate="">This file has not been yet processed</span>
+            </tal:not_loaded>
+          </p>
+
+          <tal:workouts tal:condition="bulk_file.loaded and bulk_file.workout_ids">
+            <ul class="bulk-loaded-workouts">
+              <tal:workout tal:repeat="workout bulk_file.workouts">
+                <li>
+                  <span tal:content="workout.start_in_timezone(context.timezone)"></span>,
+                  <span tal:content="workout.sport"></span>,
+                  <span tal:content="workout.title"></span>
+                </li>
+              </tal:workout>
+            </ul>
+          </tal:workouts>
+
+          <tal:no_workouts tal:condition="bulk_file.loaded and not bulk_file.workout_ids">
+            <p><span i18n:translate>No workouts have been loaded from this file</span></p>
+          </tal:no_workouts>
+
+        </div>
+      </tal:bulk_files>
+
+    </div>
+
+  </metal:content>
+
+</html>
Index: ow/views/bulk.py
===================================================================
--- ow/views/bulk.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/views/bulk.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
@@ -0,0 +1,69 @@
+from pyramid.httpexceptions import HTTPFound, HTTPNotFound
+from pyramid.view import view_config
+from pyramid.response import Response
+from pyramid_simpleform import Form
+from pyramid_simpleform.renderers import FormRenderer
+from pyramid.i18n import TranslationStringFactory
+
+from ..models.user import User
+from ..models.bulk import BulkFile
+from ..schemas.bulk import (
+    BulkFileSchema,
+)
+
+_ = TranslationStringFactory('OpenWorkouts')
+
+
+@view_config(
+    context=User,
+    permission='edit',
+    name='add-bulk-file',
+    renderer='ow:templates/add_bulk_file.pt')
+def add_bulk_file(context, request):
+    """
+    Add a compressed file that should contain tracking files, so we can
+    do a "bulk" upload of tracking files and workouts
+    """
+    # if not given a file there is an empty byte in POST, which breaks
+    # our blob storage validator.
+    # dirty fix until formencode fixes its api.is_empty method
+    if isinstance(request.POST.get('tracking_file', None), bytes):
+        request.POST['tracking_file'] = ''
+
+    form = Form(request, schema=BulkFileSchema())
+
+    if 'submit' in request.POST and form.validate():
+        # get the extension of the compressed file. We use this later to
+        # know how to decompress it.
+        file_name = file_ext = request.POST['compressed_file'].filename
+        file_ext = file_name.split('.')[-1]
+        # Create a BulkFile instance based on the input from the form
+        bulk_file = form.bind(BulkFile(uid=str(context.uid)))
+        # Set the type of compressed file
+        bulk_file.file_name = file_name
+        bulk_file.file_type = file_ext
+
+        # save the bulk file
+        request.root['_bulk_files'].add_bulk_file(bulk_file)
+        # Send the user to his/her bulk files page
+        return HTTPFound(location=request.resource_url(context, 'bulk-files'))
+
+    return {
+        'form': FormRenderer(form),
+    }
+
+
+
+@view_config(
+    context=User,
+    permission='edit',
+    name='bulk-files',
+    renderer='ow:templates/bulk_files.pt')
+def bulk_files(context, request):
+    """
+    Render a page where users can see their bulk uploads (finished,
+    pending, status, etc)
+    """
+    return {
+        'bulk_files': request.root['_bulk_files'].get_by_uid(context.uid)
+    }
