Index: ow/models/bulk.py
===================================================================
--- ow/models/bulk.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/models/bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
@@ -1,9 +1,18 @@
+import os
+import logging
+from shutil import unpack_archive, rmtree
 from uuid import uuid1
-from datetime import datetime, timedelta, timezone
-
+from datetime import datetime, timezone
+
+import pytz
 from repoze.folder import Folder
 from pyramid.security import Allow, Deny, Everyone, ALL_PERMISSIONS
-
-import pytz
+from fitparse.utils import FitHeaderError, FitEOFError
+from gpxpy.gpx import GPXXMLSyntaxException
+
+from ow.utilities import create_blob
+from ow.models.workout import Workout
+
+log = logging.getLogger(__name__)
 
 
@@ -40,4 +49,5 @@
         self.loaded = False  # datetime when workouts have been loaded
         self.workout_ids = []  # ids of the workouts loaded from this file
+        self.loaded_info = {}  # per-file information (loaded or not, errors..)
 
     def _in_timezone(self, timezone, value):
@@ -56,4 +66,108 @@
             return self._in_timezone(timezone, self.loaded)
         return ''
+
+    def extract(self, tmp_path, path):
+        """
+        Extract the files contained in this bulk/compressed file into the
+        given path.
+        """
+        if self.compressed_file is None:
+            return []
+
+        # save the blob into a temporal file
+        tmp_file_path = os.path.join(tmp_path, self.file_name)
+        with open(tmp_file_path, 'wb') as tmp_file:
+            with self.compressed_file.open() as blob:
+                tmp_file.write(blob.read())
+        # extract
+        unpack_archive(tmp_file_path, path)
+        # remove temporary file
+        os.remove(tmp_file_path)
+        # analyze the extracted contents, return some data
+        extracted = []
+        if os.path.exists(path):
+            for extracted_file in os.listdir(path):
+                extracted.append(os.path.join(path, extracted_file))
+        return extracted
+
+    def load(self, root, tmp_path):
+        user = root.get_user_by_uid(self.uid)
+        # extract
+        tmp_extract_path = os.path.join(tmp_path, str(self.bfid))
+        log.info(self.file_name + ' extracting to ' + tmp_extract_path)
+        extracted = self.extract(tmp_path, tmp_extract_path)
+        log.info(self.file_name + ' ' + str(len(extracted)) +
+                 ' files extracted')
+
+        # loop over extracted files and create the workouts, taking
+        # care of duplicates. Store some stats/info in a dict, so we can
+        # keep that somewhere, to show to the user later on
+        for extracted_file in extracted:
+            base_file_name = os.path.basename(extracted_file)
+            log_header = self.file_name + '/' + base_file_name
+            log.info(log_header + ' loading file')
+
+            file_extension = os.path.splitext(base_file_name)[1].strip('.')
+
+            # gpx files are text, but fit files are binary files
+            open_mode = 'r'
+            binary = False
+            if file_extension == 'fit':
+                open_mode = 'rb'
+                binary = True
+
+            with open(extracted_file, open_mode) as f_obj:
+                blob = create_blob(
+                    f_obj.read(), file_extension=file_extension, binary=binary)
+
+            workout = Workout()
+            workout.tracking_file = blob
+            workout.tracking_filetype = file_extension
+
+            try:
+                workout.load_from_file()
+            except (FitHeaderError, FitEOFError, GPXXMLSyntaxException) as e:
+                log.error(log_header + ' error loading tracking file ')
+                log.error(e)
+                self.loaded_info[base_file_name] = {
+                    'loaded': False,
+                    'error': 'tracking file load error',
+                    'workout': None,
+                }
+            else:
+                # check for duplicates
+                # hashed is not "complete" for a workout that has not been
+                # added yet, as it does not have the owner set, so we have to
+                # "build it"
+                hashed = str(self.uid) + workout.hashed
+                duplicate = root.get_workout_by_hash(hashed)
+                if duplicate:
+                    log.warning(
+                        log_header +
+                        ' cannot create workout, possible duplicate')
+                    self.loaded_info[base_file_name] = {
+                        'loaded': False,
+                        'error': 'Possible duplicate workout',
+                        'workout': None,
+                    }
+                else:
+                    # add the workout only if no errors happened
+                    user.add_workout(workout)
+                    log.info(log_header + ' workout added')
+                    self.loaded_info[base_file_name] = {
+                        'loaded': True,
+                        'error': None,
+                        'workout': workout.workout_id,
+                    }
+                    self.workout_ids.append(workout.workout_id)
+
+        # clean-up, we have to check if the temporary directory exists,
+        # as extract() won't create such directory if the compressed file
+        # is empty
+        if os.path.exists(tmp_extract_path):
+            rmtree(tmp_extract_path)
+
+        # mark this bulk file as loaded
+        self.loaded = datetime.now(timezone.utc)
 
 
Index: ow/schemas/bulk.py
===================================================================
--- ow/schemas/bulk.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/schemas/bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
@@ -1,3 +1,3 @@
-from formencode import Schema, validators
+from formencode import Schema
 
 from ow.schemas.blob import FieldStorageBlob
Index: ow/tasks/bulk.py
===================================================================
--- ow/tasks/bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
+++ ow/tasks/bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
@@ -0,0 +1,39 @@
+import fcntl
+import logging
+
+log = logging.getLogger(__name__)
+
+
+def process_compressed_files(env):
+    """
+    Go over any pending-to-be-processed compressed files for bulk-upload
+    workouts from those files.
+    """
+
+    settings = env['registry'].settings
+    lock_filename = settings.get('workouts.bulk_loading_lock')
+    lock_file = open(lock_filename, 'w')
+    try:
+        fcntl.lockf(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+    except IOError:
+        # Can't lock, probably another process is running, report to logging
+        # and exit
+        log.warning('Could not run the workout bulk load task, '
+                    'could not acquire lock (maybe another process is '
+                    'running?)')
+        return False
+
+    request = env['request']
+    root = env['root']
+    workouts_loaded = False
+    tmp_path = settings.get('workouts.bulk_tmp_path')
+
+    for bulk_file in root['_bulk_files'].values():
+        if not bulk_file.loaded:
+            bulk_file.load(root, tmp_path)
+            workouts_loaded = True
+
+    if workouts_loaded:
+        request.tm.commit()
+
+    return workouts_loaded
Index: ow/tasks/run.py
===================================================================
--- ow/tasks/run.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/tasks/run.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
@@ -3,4 +3,5 @@
 from ow.tasks.manager import TasksManager
 from ow.tasks.mail import queue_processor
+from ow.tasks.bulk import process_compressed_files
 
 
@@ -10,4 +11,5 @@
     # "register" the tasks
     tasks_manager.add_task('send_emails', queue_processor)
+    tasks_manager.add_task('bulk_import', process_compressed_files)
 
     if len(sys.argv) != 3:
Index: ow/templates/bulk_files.pt
===================================================================
--- ow/templates/bulk_files.pt	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/templates/bulk_files.pt	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
@@ -20,5 +20,5 @@
     <div class="bulk-files ow-forms">
       <a href="" class="back"
-         tal:attributes="href request.resource_url(context)"
+         tal:attributes="href request.resource_url(context, 'add-bulk-file')"
          i18n:translate="">Back</a>
       <h2 i18n:translate="">Multiple file uploads</h2>
@@ -36,6 +36,22 @@
           <p>
             <tal:loaded tal:condition="bulk_file.loaded">
-              <span i18n:translate="">File loaded on></span>
+              <span i18n:translate="">File loaded on</span>
               <span tal:content="bulk_file.loaded_in_timezone(context.timezone)"></span>
+              <ul>
+                <tal:loaded_files tal:repeat="file_name bulk_file.loaded_info.keys()">
+                  <li tal:define="file_info bulk_file.loaded_info[file_name]">
+                    <span tal:content="file_name"></span>
+                    <tal:file_loaded tal:condition="file_info['loaded']">
+                      <span i18n:translate="">Loaded correctly</span>
+                      <a href="" tal:attributes="href request.resource_url(context, file_info['workout'])"
+                         i18n:translate="">See workout</a>
+                    </tal:file_loaded>
+                    <tal:file_not_loaded tal:condition="not file_info['loaded']">
+                      <span i18n:translate="">Couldn't be loaded:</span>
+                      <span tal:content="file_info['error']"></span>
+                    </tal:file_not_loaded>
+                  </li>
+                </tal:loaded_files>
+              </ul>
             </tal:loaded>
             <tal:not_loaded tal:condition="not bulk_file.loaded">
@@ -44,16 +60,17 @@
           </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: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>
Index: ow/tests/models/test_bulk.py
===================================================================
--- ow/tests/models/test_bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
+++ ow/tests/models/test_bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
@@ -0,0 +1,268 @@
+import os
+from datetime import datetime, timezone, timedelta
+from tempfile import TemporaryDirectory
+from unittest.mock import patch
+
+import pytest
+import pytz
+from pyramid.security import Allow, Everyone, Deny, ALL_PERMISSIONS
+
+from ow.models.user import User
+from ow.models.root import OpenWorkouts
+from ow.models.bulk import BulkFile, BulkFiles
+from ow.utilities import create_blob
+
+
+class TestBulkFile(object):
+
+    @pytest.fixture
+    def root(self):
+        root = OpenWorkouts()
+        root['_bulk_files'] = BulkFiles()
+        john = User(firstname='John', lastname='Doe',
+                    email='john.doe@example.net')
+        john.password = 's3cr3t'
+        root.add_user(john)
+        uid = str(john.uid)
+        bulk_file = BulkFile(uid=uid)
+        bulk_file_path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'fixtures/bulk-fit.zip')
+        with open(bulk_file_path, 'rb') as _bulk_file:
+            bulk_file.compressed_file = create_blob(
+                _bulk_file.read(), file_extension='zip', binary=True)
+        bulk_file.file_name = 'bulk-fit.zip'
+        bulk_file.file_type = 'zip'
+        root['_bulk_files'].add_bulk_file(bulk_file)
+        return root
+
+    def test__acl__(self, root):
+        bulk_file = root['_bulk_files'].values()[0]
+        permissions = [
+            (Allow, str(bulk_file.uid), 'view'),
+            (Allow, str(bulk_file.uid), 'edit'),
+            (Allow, str(bulk_file.uid), 'delete'),
+            (Deny, Everyone, ALL_PERMISSIONS)
+        ]
+        assert bulk_file.__acl__() == permissions
+
+    def test_uploaded_in_timezone(self, root):
+        bulk_file = root['_bulk_files'].values()[0]
+        timezones = ['UTC', 'Europe/Madrid', 'Asia/Tokyo', 'Canada/Pacific']
+        for _timezone in timezones:
+            expected = bulk_file.uploaded.astimezone(pytz.timezone(_timezone))
+            expected = expected.strftime('%d/%m/%Y %H:%M (%Z)')
+            assert bulk_file.uploaded_in_timezone(_timezone) == expected
+
+    def test_loaded_in_timezone(self, root):
+        timezones = ['UTC', 'Europe/Madrid', 'Asia/Tokyo', 'Canada/Pacific']
+        # first try a non-loaded bulk file
+        bulk_file = root['_bulk_files'].values()[0]
+        for _timezone in timezones:
+            assert bulk_file.loaded_in_timezone(_timezone) == ''
+        # now, "mark" it as loaded, try again
+        bulk_file.loaded = datetime.now(timezone.utc) - timedelta(hours=5)
+        for _timezone in timezones:
+            expected = bulk_file.loaded.astimezone(pytz.timezone(_timezone))
+            expected = expected.strftime('%d/%m/%Y %H:%M (%Z)')
+            assert bulk_file.loaded_in_timezone(_timezone) == expected
+
+    @patch('ow.models.bulk.os')
+    @patch('ow.models.bulk.unpack_archive')
+    def test_extract_none(self, unpack_archive, _os, root):
+        """
+        Call extract on a bulk file without an associated compressed file.
+        """
+        user = root.users[0]
+        uid = str(user.uid)
+        bulk_file = BulkFile(uid=uid)
+        with TemporaryDirectory() as tmp_path:
+            extracted = bulk_file.extract(tmp_path, tmp_path)
+            assert extracted == []
+            assert len(os.listdir(tmp_path)) == 0
+            assert not unpack_archive.called
+            assert not _os.path.join.called
+            assert not _os.remove.called
+
+    params = (
+        ('fixtures/bulk-fit.zip', {
+            'extracted': [
+                '2019-09-19-09-42-41.fit', '2019-09-17-09-42-50.fit'
+            ],
+        }),
+        ('fixtures/bulk-fit.tgz', {
+            'extracted': [
+                '2019-09-19-09-42-41.fit', '2019-09-17-09-42-50.fit'
+            ],
+        }),
+        ('fixtures/bulk-gpx.zip', {
+            'extracted': [
+                '20181230_101115.gpx', '20181231_110728.gpx'
+            ],
+        }),
+        ('fixtures/bulk-gpx.tgz', {
+            'extracted': [
+                '20181230_101115.gpx', '20181231_110728.gpx'
+            ],
+        }),
+        ('fixtures/bulk-empty.zip', {'extracted': []}),
+    )
+
+    @pytest.mark.parametrize(('filename', 'expected'), params)
+    def test_extract(self, filename, expected, root):
+        base_name, extension = os.path.splitext(filename)
+        extension = extension.lstrip('.')
+
+        user = root.users[0]
+        uid = str(user.uid)
+
+        bulk_file = BulkFile(uid=uid)
+        bulk_file_path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)), filename)
+        with open(bulk_file_path, 'rb') as _bulk_file:
+            bulk_file.compressed_file = create_blob(
+                _bulk_file.read(), file_extension=extension, binary=True)
+        bulk_file.file_name = os.path.basename(filename)
+        bulk_file.file_type = extension
+
+        root['_bulk_files'].add_bulk_file(bulk_file)
+
+        with TemporaryDirectory() as tmp_path:
+            extracted = bulk_file.extract(tmp_path, tmp_path)
+            assert expected['extracted'] == os.listdir(tmp_path)
+            expected_extracted = [
+                os.path.join(tmp_path, p) for p in expected['extracted']]
+            assert extracted == expected_extracted
+
+    params = (
+        ('fixtures/bulk-fit.zip', {
+            'extracted': [
+                '2019-09-19-09-42-41.fit', '2019-09-17-09-42-50.fit'
+            ],
+            'loaded': [
+                '2019-09-19-09-42-41.fit', '2019-09-17-09-42-50.fit'
+            ],
+        }),
+        ('fixtures/bulk-fit.tgz', {
+            'extracted': [
+                '2019-09-19-09-42-41.fit', '2019-09-17-09-42-50.fit'
+            ],
+            'loaded': [
+                '2019-09-19-09-42-41.fit', '2019-09-17-09-42-50.fit'
+            ],
+        }),
+        ('fixtures/bulk-gpx.zip', {
+            'extracted': [
+                '20181230_101115.gpx', '20181231_110728.gpx'
+            ],
+            'loaded': [
+                '20181230_101115.gpx', '20181231_110728.gpx'
+            ],
+        }),
+        ('fixtures/bulk-gpx.tgz', {
+            'extracted': [
+                '20181230_101115.gpx', '20181231_110728.gpx'
+            ],
+            'loaded': [
+                '20181230_101115.gpx', '20181231_110728.gpx'
+            ],
+        }),
+        ('fixtures/bulk-empty.zip', {'extracted': [], 'loaded': []}),
+        ('fixtures/bulk-invalid.zip', {
+            'extracted': [
+                'empty.fit', 'empty.gpx', 'invalid.fit', 'invalid.gpx',
+                '20181230_101115.gpx', '20181230_101115-duplicate.gpx'
+            ],
+            'loaded': ['empty.gpx', '20181230_101115.gpx'],
+        }),
+    )
+
+    @pytest.mark.parametrize(('filename', 'expected'), params)
+    def test_load(self, filename, expected, root):
+        base_name, extension = os.path.splitext(filename)
+        extension = extension.lstrip('.')
+
+        user = root.users[0]
+        uid = str(user.uid)
+
+        bulk_file = BulkFile(uid=uid)
+        bulk_file_path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)), filename)
+        with open(bulk_file_path, 'rb') as _bulk_file:
+            bulk_file.compressed_file = create_blob(
+                _bulk_file.read(), file_extension=extension, binary=True)
+        bulk_file.file_name = os.path.basename(filename)
+        bulk_file.file_type = extension
+
+        root['_bulk_files'].add_bulk_file(bulk_file)
+
+        assert list(user.workouts()) == []
+        assert not bulk_file.loaded
+        assert bulk_file.loaded_info == {}
+        assert bulk_file.workout_ids == []
+
+        num_extracted = len(expected['extracted'])
+        num_loaded = len(expected['loaded'])
+        with TemporaryDirectory() as tmp_path:
+            bulk_file.load(root, tmp_path)
+            assert isinstance(bulk_file.loaded, datetime)
+            assert len(bulk_file.loaded_info.keys()) == num_extracted
+            assert len(bulk_file.workout_ids) == num_loaded
+            for key, value in bulk_file.loaded_info.items():
+                if value['loaded']:
+                    assert value['error'] is None
+                    assert value['workout'] is not None
+                else:
+                    assert isinstance(value['error'], str)
+                    assert len(value['error']) > 2
+                    assert value['workout'] is None
+            assert len(user.workouts()) == num_loaded
+
+
+class TestBulkFiles(object):
+
+    @pytest.fixture
+    def root(self):
+        root = OpenWorkouts()
+        root['_bulk_files'] = BulkFiles()
+        return root
+
+    def test__acl__(self, root):
+        permissions = [
+            (Allow, Everyone, 'view'),
+            (Allow, 'admins', 'edit'),
+            (Deny, Everyone, ALL_PERMISSIONS)
+        ]
+        assert root['_bulk_files'].__acl__() == permissions
+
+    def test_add_bulk_file(self, root):
+        assert len(root['_bulk_files']) == 0
+        bulk_file = BulkFile(uid='faked-uid')
+        root['_bulk_files'].add_bulk_file(bulk_file)
+        assert len(root['_bulk_files']) == 1
+        assert list(root['_bulk_files'].keys()) == [str(bulk_file.bfid)]
+        assert list(root['_bulk_files'].values()) == [bulk_file]
+
+    def test_get_by_uid(self, root):
+        # no bulk files uploaded, trying to get one for 'faked-uid'
+        bulk_files = root['_bulk_files'].get_by_uid('faked-uid')
+        assert bulk_files == []
+        # add a bulk file, trying to get it back
+        bulk_file = BulkFile(uid='faked-uid')
+        root['_bulk_files'].add_bulk_file(bulk_file)
+        bulk_files = root['_bulk_files'].get_by_uid('faked-uid')
+        assert bulk_files == [bulk_file]
+        # trying to get files for another user, who did not upload anything
+        bulk_files = root['_bulk_files'].get_by_uid('other-faked-uid')
+        assert bulk_files == []
+        # add another bulk file, for the same user, and get both files
+        other_bulk_file = BulkFile(uid='faked-uid')
+        root['_bulk_files'].add_bulk_file(other_bulk_file)
+        bulk_files = root['_bulk_files'].get_by_uid('faked-uid')
+        assert bulk_file in bulk_files
+        assert other_bulk_file in bulk_files
+        assert len(bulk_files) == 2
+        # this user still did not upload anything, so no files returned for
+        # him
+        bulk_files = root['_bulk_files'].get_by_uid('other-faked-uid')
+        assert bulk_files == []
Index: ow/tests/tasks/test_bulk.py
===================================================================
--- ow/tests/tasks/test_bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
+++ ow/tests/tasks/test_bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
@@ -0,0 +1,128 @@
+from unittest.mock import patch, Mock
+
+import pytest
+
+from ow.tasks.bulk import process_compressed_files
+
+
+class TestBulkTasks(object):
+
+    @pytest.fixture
+    def settings(self):
+        settings = {
+            'workouts.bulk_tmp_path': '/tmp',
+            'workouts.bulk_loading_lock': '/tmp/queue-processor.lock'}
+        return settings
+
+    @pytest.fixture
+    def root(self):
+        root = {
+            '_bulk_files': {}
+        }
+        return root
+
+    @pytest.fixture
+    def env(self, settings, root):
+        env = {
+            'request': Mock(),
+            'root': root,
+            'registry': Mock(settings=settings)
+        }
+        return env
+
+    @patch('fcntl.lockf')
+    @patch('ow.tasks.bulk.log')
+    def test_process_compressed_files_no_files(self, log, lockf, env):
+        workouts_loaded = process_compressed_files(env)
+        # the lockf was called, to set the lock on the lock file
+        assert lockf.called
+        # no warnings shown
+        assert not log.warning.called
+        # nothing loaded, no transaction committed
+        assert not workouts_loaded
+        assert not env['request'].tm.commit.called
+
+    @patch('fcntl.lockf')
+    @patch('ow.tasks.bulk.log')
+    def test_process_compressed_files_single_file(self, log, lockf, env):
+        bulk_file = Mock(loaded=False)
+        env['root']['_bulk_files']['faked-bulk_id'] = bulk_file
+        workouts_loaded = process_compressed_files(env)
+        # the lockf was called, to set the lock on the lock file
+        assert lockf.called
+        # no warnings shown
+        assert not log.warning.called
+        # file loaded, transaction commited
+        bulk_file.load.assert_called_once_with(
+            env['root'],
+            env['registry'].settings['workouts.bulk_tmp_path'])
+        assert workouts_loaded
+        env['request'].tm.commit.assert_called_once
+
+    @patch('fcntl.lockf')
+    @patch('ow.tasks.bulk.log')
+    def test_process_compressed_files_already_loaded(self, log, lockf, env):
+        bulk_file = Mock(loaded=True)
+        env['root']['_bulk_files']['faked-bulk_id'] = bulk_file
+        workouts_loaded = process_compressed_files(env)
+        # the lockf was called, to set the lock on the lock file
+        assert lockf.called
+        # no warnings shown
+        assert not log.warning.called
+        # file already loaded, no transaction commited
+        assert not bulk_file.load.called
+        assert not workouts_loaded
+        assert not env['request'].tm.commit.called
+
+    @patch('fcntl.lockf')
+    @patch('ow.tasks.bulk.log')
+    def test_process_compressed_files_multiple_files(self, log, lockf, env):
+        bulk_file = Mock(loaded=False)
+        loaded_bulk_file = Mock(loaded=True)
+        additional_bulk_file = Mock(loaded=False)
+        env['root']['_bulk_files']['faked-bulk_id'] = bulk_file
+        env['root']['_bulk_files']['loaded-bulk_id'] = loaded_bulk_file
+        env['root']['_bulk_files']['additional-bulk_id'] = additional_bulk_file
+        workouts_loaded = process_compressed_files(env)
+        # the lockf was called, to set the lock on the lock file
+        assert lockf.called
+        # no warnings shown
+        assert not log.warning.called
+        # 2 files loaded, transaction commited once
+        bulk_file.load.assert_called_once_with(
+            env['root'],
+            env['registry'].settings['workouts.bulk_tmp_path'])
+        assert not loaded_bulk_file.load.called
+        additional_bulk_file.load.assert_called_once_with(
+            env['root'],
+            env['registry'].settings['workouts.bulk_tmp_path'])
+        assert workouts_loaded
+        env['request'].tm.commit.assert_called_once
+
+    @patch('fcntl.lockf')
+    @patch('ow.tasks.bulk.log')
+    def test_process_compressed_files_lock_ioerror(self, log, lockf, env):
+        """
+        A second call to this task, while a first call is still running,
+        does not do anything, just log a warning message to the logger
+        """
+        # Make lock raise an IOError, mimicing what happens when the lock has
+        # been acquired by another process
+        e = IOError('could not lock file!')
+        lockf.side_effect = e
+
+        workouts_loaded = process_compressed_files(env)
+        # Exception raised, False returned
+        assert workouts_loaded is False
+
+        # Calls: fcntl.lock has been called, then the IOError was raised
+        # and no more calls to the api have been made
+        assert lockf.called
+
+        assert not log.info.called
+        assert not log.error.called
+        # except for the log call, which was a warning
+        assert log.warning.called
+        log_msg = log.warning.call_args_list[0][0][0]
+        assert 'not run the workout bulk load task' in log_msg
+        assert 'could not acquire lock' in log_msg
Index: ow/tests/views/test_bulk.py
===================================================================
--- ow/tests/views/test_bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
+++ ow/tests/views/test_bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
@@ -0,0 +1,217 @@
+import os
+from io import BytesIO
+from cgi import FieldStorage
+
+import pytest
+
+from pyramid.testing import DummyRequest
+from pyramid.httpexceptions import HTTPFound
+
+from webob.multidict import MultiDict
+
+from ow.models.root import OpenWorkouts
+from ow.models.user import User
+from ow.models.bulk import BulkFile, BulkFiles
+from ow.schemas.bulk import BulkFileSchema
+from ow.utilities import create_blob
+
+import ow.views.bulk as bulk_views
+
+
+class TestBulkViews(object):
+
+    # paths to sample compressed files we can use for testing.
+    bulk_filenames = (
+        # valid FIT files, in different compressed formats
+        'fixtures/bulk-fit.zip',
+        'fixtures/bulk-fit.tgz',
+        # valid GPX files, also in different formats
+        'fixtures/bulk-gpx.zip',
+        'fixtures/bulk-gpx.tgz',
+        # empty ZIP file
+        'fixtures/bulk-empty.zip',
+        # ZIP file containing invalid files in different formats
+        'fixtures/bulk-invalid.zip'
+    )
+
+    def open_uploaded_file(self, path):
+        """
+        Open the uploaded compressed file fixture from disk
+        """
+        uploaded_file_path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)), path)
+        uploaded_file = open(uploaded_file_path, 'rb')
+        return uploaded_file
+
+    def close_uploaded_file(self, uploaded_file):
+        """
+        Close the opened uploaded compressed file
+        """
+        uploaded_file.close()
+
+    def create_filestorage(self, uploaded_file):
+        """
+        Create a FileStorage instance from an open compressed tracking file,
+        suitable for testing file uploads later
+        """
+        storage = FieldStorage()
+        storage.filename = os.path.basename(uploaded_file.name)
+        storage.file = BytesIO(uploaded_file.read())
+        storage.name = os.path.basename(uploaded_file.name)
+        # This prevents FormEncode validator from thinking we are providing
+        # more than one file for the upload, which crashes the tests
+        storage.list = None
+        return storage
+
+    @pytest.fixture
+    def root(self):
+        root = OpenWorkouts()
+        root['_bulk_files'] = BulkFiles()
+        john = User(firstname='John', lastname='Doe',
+                    email='john.doe@example.net')
+        john.password = 's3cr3t'
+        root.add_user(john)
+
+        """
+        uid = str(john.uid)
+        bulk_file = BulkFile(uid=uid)
+        bulk_file_path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'fixtures/bulk-fit.zip')
+        with open(bulk_file_path, 'rb') as _bulk_file:
+            bulk_file.compressed_file = create_blob(
+                _bulk_file.read(), file_extension='zip', binary=True)
+        bulk_file.file_name = 'bulk-fit.zip'
+        bulk_file.file_type = 'zip'
+        root['_bulk_files'].add_bulk_file(bulk_file)
+        """
+
+        return root
+
+    @pytest.fixture
+    def dummy_request(self, root):
+        request = DummyRequest()
+        request.root = root
+        return request
+
+    def test_add_bulk_file_get(self, dummy_request):
+        """
+        Test the view that renders the "add bulk file" form
+        """
+        request = dummy_request
+        user = request.root.users[0]
+        assert len(request.root['_bulk_files']) == 0
+        response = bulk_views.add_bulk_file(user, request)
+        assert 'form' in response
+        assert len(response['form'].form.errors) == 0
+        assert isinstance(response['form'].form.schema, BulkFileSchema)
+        # GET request, no bulk files have been saved
+        assert len(request.root['_bulk_files']) == 0
+
+    def test_add_bulk_file_post_invalid(self, dummy_request):
+        """
+        POST request to add a bulk file, without providing the required form
+        data (actually, any bulk file at all).
+        """
+        request = dummy_request
+        user = request.root.users[0]
+        request.method = 'POST'
+        request.POST = MultiDict({'submit': True})
+        assert len(request.root['_bulk_files']) == 0
+        response = bulk_views.add_bulk_file(user, request)
+        assert 'form' in response
+        # Only one required field in this case, the bulk file
+        assert len(response['form'].form.errors) == 1
+        # no bulk files have been saved
+        assert len(request.root['_bulk_files']) == 0
+
+    def test_add_bulk_file_post_invalid_bytes(self, dummy_request):
+        """
+        POST request to add a workout, without uploading a compressed file,
+        which sends an empty bytes object (b'')
+        """
+        request = dummy_request
+        user = request.root.users[0]
+        request.method = 'POST'
+        request.POST = MultiDict({
+            'compressed_file': b'',
+            'submit': True,
+            })
+        assert len(request.root['_bulk_files']) == 0
+        response = bulk_views.add_bulk_file(user, request)
+        assert 'form' in response
+        # Only one required field in this case, the tracking file
+        assert len(response['form'].form.errors) == 1
+        # no bulk files have been saved
+        assert len(request.root['_bulk_files']) == 0
+
+    @pytest.mark.parametrize('filename', bulk_filenames)
+    def test_add_workout_post_valid(self, filename, dummy_request):
+        """
+        POST request to add a bulk file
+        """
+        request = dummy_request
+        uploaded_file = self.open_uploaded_file(filename)
+        filestorage = self.create_filestorage(uploaded_file)
+        user = request.root.users[0]
+        request.method = 'POST'
+        request.POST = MultiDict({
+            'compressed_file': filestorage,
+            'submit': True,
+            })
+        assert len(request.root['_bulk_files']) == 0
+        response = bulk_views.add_bulk_file(user, request)
+        assert isinstance(response, HTTPFound)
+        assert response.location == request.resource_url(user, 'bulk-files')
+        assert len(request.root['_bulk_files']) == 1
+        bulk_file = request.root['_bulk_files'].values()[0]
+        assert isinstance(bulk_file, BulkFile)
+        assert bulk_file.uid == str(user.uid)
+        assert bulk_file.file_name == os.path.basename(filename)
+        assert bulk_file.workout_ids == []
+        assert bulk_file.loaded_info == {}
+        assert not bulk_file.loaded
+        self.close_uploaded_file(uploaded_file)
+
+    def test_bulk_files_get(self, dummy_request):
+        """
+        Test the view that renders the "list of all bulk files for a user" page
+        """
+        request = dummy_request
+        user = request.root.users[0]
+        # empty _bulk_files
+        assert len(request.root['_bulk_files']) == 0
+        response = bulk_views.bulk_files(user, request)
+        assert response == {'bulk_files': []}
+        # let's add one bulk file
+        uid = str(user.uid)
+        bulk_file = BulkFile(uid=uid)
+        bulk_file_path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'fixtures/bulk-fit.zip')
+        with open(bulk_file_path, 'rb') as _bulk_file:
+            bulk_file.compressed_file = create_blob(
+                _bulk_file.read(), file_extension='zip', binary=True)
+        bulk_file.file_name = 'bulk-fit.zip'
+        bulk_file.file_type = 'zip'
+        request.root['_bulk_files'].add_bulk_file(bulk_file)
+        assert len(request.root['_bulk_files']) == 1
+        response = bulk_views.bulk_files(user, request)
+        assert response == {'bulk_files': [bulk_file]}
+        # now, add another one for another (faked) user
+        another_bulk_file = BulkFile(uid='faked-user-uid')
+        another_bulk_file_path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'fixtures/bulk-gpx.zip')
+        with open(another_bulk_file_path, 'rb') as _bulk_file:
+            bulk_file.compressed_file = create_blob(
+                _bulk_file.read(), file_extension='zip', binary=True)
+        another_bulk_file.file_name = 'bulk-gpx.zip'
+        another_bulk_file.file_type = 'zip'
+        request.root['_bulk_files'].add_bulk_file(another_bulk_file)
+        # there are 2 bulk files, 1 per user, so asking for one user
+        # returns only one
+        assert len(request.root['_bulk_files']) == 2
+        response = bulk_views.bulk_files(user, request)
+        assert response == {'bulk_files': [bulk_file]}
+        assert response['bulk_files'][0].uid == str(user.uid)
Index: ow/views/bulk.py
===================================================================
--- ow/views/bulk.py	(revision fcf0ef3d4ad92cfddebb4201d049a17183adb13f)
+++ ow/views/bulk.py	(revision 93b23a6c29f17a89c4028e4ec2c04952a99773f7)
@@ -1,5 +1,4 @@
-from pyramid.httpexceptions import HTTPFound, HTTPNotFound
+from pyramid.httpexceptions import HTTPFound
 from pyramid.view import view_config
-from pyramid.response import Response
 from pyramid_simpleform import Form
 from pyramid_simpleform.renderers import FormRenderer
@@ -28,6 +27,6 @@
     # 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'] = ''
+    if isinstance(request.POST.get('compressed_file', None), bytes):
+        request.POST['compressed_file'] = ''
 
     form = Form(request, schema=BulkFileSchema())
@@ -54,5 +53,4 @@
 
 
-
 @view_config(
     context=User,
