diff --git a/django/website/hid/tables.py b/django/website/hid/tables.py
index d270635c6453bff249442c4933ba0775ed9d8b26..b8914a7bb290ee493e4db7851056765dc60db00e 100644
--- a/django/website/hid/tables.py
+++ b/django/website/hid/tables.py
@@ -1,5 +1,6 @@
 import django_tables2 as tables
 from django.conf import settings
+from django.template import loader
 from django.utils.translation import ugettext_lazy as _
 
 
@@ -28,16 +29,25 @@ class ItemTable(tables.Table):
         format=settings.SHORT_DATETIME_FORMAT,
     )
     body = tables.Column(verbose_name=_('Message'))
-    category = tables.Column(
-        verbose_name=_('Category'),
+    category = tables.TemplateColumn(
+        template_name='hid/categories_column.html',
         accessor='terms.0.name',
-        default=_('Uncategorized')
     )
 
     def __init__(self, *args, **kwargs):
         self.categories = kwargs.pop('categories')
         super(ItemTable, self).__init__(*args, **kwargs)
 
+    def render_category(self, record, value):
+        Template = loader.get_template('hid/categories_column.html')
+        ctx = {
+            'categories': self.categories,
+            'category': value,
+            'record': record
+        }
+
+        return Template.render(ctx)
+
     @staticmethod
     def get_selected(params):
         """ Given a request parameter list, return the items that were
@@ -49,3 +59,24 @@ class ItemTable(tables.Table):
                 List of selected record ids as integers
         """
         return [int(x) for x in params.getlist("select_item_id", [])]
+
+    @staticmethod
+    def get_row_select_values(params, input_prefix):
+        """ Given a request parameter list, return the values that were
+            set on each of the given rows using the given drop down or
+            input field.
+
+            Args:
+                - params: GET/POST parameter list
+                - input_prefix: Prefix used for each rows' input field,
+                                such that the input field's name is
+                                <input_prefix>-<row id>
+            Returns:
+                List of tupples (row id, field value)
+        """
+        values = []
+        for name, value in params.items():
+            if value and name.startswith(input_prefix + '-'):
+                row_id = int(name[len(input_prefix)+1:])
+                values.append((row_id, value))
+        return values
diff --git a/django/website/hid/templates/hid/categories_column.html b/django/website/hid/templates/hid/categories_column.html
new file mode 100644
index 0000000000000000000000000000000000000000..709ce07df606e695170a18c3da945e6e1ecfad27
--- /dev/null
+++ b/django/website/hid/templates/hid/categories_column.html
@@ -0,0 +1,10 @@
+{% if categories %}
+<select name="category-{{ record.id }}" class="form-control">
+        {% if not category %}
+            <option value="" selected="selected">---------</option>
+        {% endif %}
+        {% for cat in categories %}
+        <option value="{{ cat.0 }}"{% if category == cat.0 %} selected="selected"{% endif %}>{{ cat.0 }}</option>
+        {% endfor %}
+    </select>
+{% endif %}
diff --git a/django/website/hid/templates/hid/view.html b/django/website/hid/templates/hid/view.html
index 7e4164bdae05019b6bfb18bc9c9ff5ca114df284..ce47656af3a2c5b3cf3a5ea9d9c627379d0cd176 100644
--- a/django/website/hid/templates/hid/view.html
+++ b/django/website/hid/templates/hid/view.html
@@ -26,24 +26,15 @@
                       method="post"
                       class="view-items-form">
                     {% csrf_token %}
-                    <div class="form-group form-header-group">
-                        <select name='action' class='form-control' required>
-                            <option value="" default selected">{% trans "Actions" %}</option>
-                            {% for group in actions %}
-                                <optgroup label="{{ group.label }}">
-                                    {% for action, action_label in group.items.items %}
-                                        <option value="{{ action }}">
-                                            {{ action_label }}
-                                        </option>
-                                    {% endfor %}
-                                </optgroup>
-                            {% endfor %}
-                        </select>
-                        {% bootstrap_button "Update" button_type="submit" value="Update" button_class="btn btn-success btn-sm table-submit" %}
-                    </div>
+                    {% with button_placement="top" %}
+                        {% include "hid/view_and_edit_buttons.html" %}
+                    {% endwith %}
                     {% with pagination_class="pagination-circle-nav" %}
                         {% render_table table %}
                     {% endwith %}
+                    {% with button_placement="bottom" %}
+                        {% include "hid/view_and_edit_buttons.html" %}
+                    {% endwith %}
                 </form>
             </div>
         </div>
diff --git a/django/website/hid/templates/hid/view_and_edit_buttons.html b/django/website/hid/templates/hid/view_and_edit_buttons.html
new file mode 100644
index 0000000000000000000000000000000000000000..da98ae4809ee6985999a609627df62ed36c676fd
--- /dev/null
+++ b/django/website/hid/templates/hid/view_and_edit_buttons.html
@@ -0,0 +1,19 @@
+{% load i18n %}
+{% load bootstrap3 %}
+<div class="form-group table-button-group">
+    <select name='batchaction-{{button_placement}}' class='form-control'>
+        {% for group in actions %}
+            <optgroup label="{{ group.label }}">
+                {% for action, action_label in group.items.items %}
+                    <option value="{{ action }}">
+                        {{ action_label }}
+                    </option>
+                {% endfor %}
+            </optgroup>
+        {% endfor %}
+    </select>
+    {% bootstrap_button "Apply action" button_type="submit" name="action" value="batchupdate-"|add:button_placement button_class="btn btn-default btn-sm table-submit" %}
+    <div class="pull-right">
+        {% bootstrap_button "Save changes" button_type="submit" name="action" value="save-"|add:button_placement button_class="btn btn-success btn-sm table-submit" %}
+    </div>
+</div>
diff --git a/django/website/hid/tests/categorize_items_tests.py b/django/website/hid/tests/categorize_items_tests.py
index 75c1ad6c332133a5e318ae4f5e1bf46da7f1adb8..1a787e49c6322eb62fcca8905694836b9f0bc4f0 100644
--- a/django/website/hid/tests/categorize_items_tests.py
+++ b/django/website/hid/tests/categorize_items_tests.py
@@ -14,27 +14,48 @@ ReqFactory = RequestFactory()
 
 
 @pytest.fixture
-def term():
+def terms():
     # TODO rewrite using transport.terms, etc.
-    taxonomy = TaxonomyFactory(name="Ebola Questions")
-    return TermFactory(taxonomy=taxonomy, name="Vacciene")
+    taxonomy = TaxonomyFactory(name="Test Ebola Questions")
+    return [
+        TermFactory(taxonomy=taxonomy, name="Vacciene"),
+        TermFactory(taxonomy=taxonomy, name="Origin")
+    ]
 
 
 @pytest.fixture
-def item():
-    data = {'body': 'test message'}
-    return transport.items.create(data)
+def items():
+    return [
+        transport.items.create({'body': 'test message one'}),
+        transport.items.create({'body': 'test message two'})
+    ]
 
 
 @pytest.mark.django_db
-def test_add_items_categories_adds_term_to_item(term, item):
+def test_add_items_categories_adds_term_to_items(terms, items):
     url = reverse('data-view-process')
     request = ReqFactory.post(url, {'a': 'b'})
     request = fix_messages(request)
 
-    add_items_categories(request, [item['id']], term.name)
-
-    [item_data] = transport.items.list()
-    [term_data] = item_data['terms']
-    assert term_data['name'] == term.name
-    assert term_data['taxonomy'] == term.taxonomy.slug
+    expected = {
+        items[0]['id']: terms[0],
+        items[1]['id']: terms[1]
+    }
+
+    category_map = [
+        (item_id, term.taxonomy.slug, term.name)
+        for item_id, term in expected.items()
+    ]
+    add_items_categories(request, category_map)
+
+    fetched_items = transport.items.list()
+    found = 0
+    for item in fetched_items:
+        if item['id'] in expected:
+            found += 1
+            assert len(item['terms']) == 1
+            [term_data] = item['terms']
+            assert term_data['name'] == expected[item['id']].name
+            assert term_data['taxonomy'] == expected[item['id']].taxonomy.slug
+
+    assert found == 2
diff --git a/django/website/hid/tests/itemtable_tests.py b/django/website/hid/tests/itemtable_tests.py
index 7ffc61fb4c3f61b84cda4f5a4ace17878d715ade..43eaaf9235fc8ac987ecd6e668cb4538080e8c1e 100644
--- a/django/website/hid/tests/itemtable_tests.py
+++ b/django/website/hid/tests/itemtable_tests.py
@@ -16,3 +16,48 @@ def test_get_selected_returns_submitted_values_as_ints():
     params.getlist.return_value = ["201", "199", "3"]
 
     assert ItemTable.get_selected(params) == [201, 199, 3]
+
+
+def test_get_row_select_values_returns_id_value_pairs():
+    post_params = {
+        'category-123': "second",
+        'category-99': "third",
+        'category-56': "first",
+        'category-1': "second",
+    }
+    expected = [
+        (123, "second"),
+        (99, "third"),
+        (56, "first"),
+        (1, "second")
+    ]
+    actual = ItemTable.get_row_select_values(post_params, 'category')
+    assert sorted(expected) == sorted(actual)  # Order is not important
+
+
+def test_get_row_select_values_reads_params_from_prefix():
+    post_params = {
+        'prefix-123': "second",
+        'prefix-99': "third",
+        'other-1': "second",
+    }
+    expected = [
+        (123, "second"),
+        (99, "third"),
+    ]
+    actual = ItemTable.get_row_select_values(post_params, 'prefix')
+    assert sorted(expected) == sorted(actual)  # Order is not important
+
+
+def test_get_row_select_values_removes_empty():
+    post_params = {
+        'category-123': "second",
+        'category-99': "third",
+        'category-56': "",
+    }
+    expected = [
+        (123, "second"),
+        (99, "third"),
+    ]
+    actual = ItemTable.get_row_select_values(post_params, 'category')
+    assert sorted(expected) == sorted(actual)  # Order is not important
diff --git a/django/website/hid/tests/login_tests.py b/django/website/hid/tests/login_tests.py
new file mode 100644
index 0000000000000000000000000000000000000000..09195b61c888da7a837d56fe87223e70253aa9e9
--- /dev/null
+++ b/django/website/hid/tests/login_tests.py
@@ -0,0 +1,37 @@
+from __future__ import unicode_literals, absolute_import
+
+import pytest
+
+from django.core.urlresolvers import reverse
+from django.test import Client
+from django.utils.six.moves.urllib.parse import urlsplit
+
+from users.models import User
+
+@pytest.mark.django_db
+def test_user_directed_to_login_page_when_csrf_error():
+    username = 'william'
+    password = 'passw0rd'
+
+    User.objects.create_user(username, 'william@example.com', password)
+
+    client = Client(enforce_csrf_checks=True)
+    data = {'username': username,
+            'password': password,
+            'csrfmiddlewaretoken': 'notavalidtoken'}
+    response = client.post(reverse('login'),
+                           data=data, follow=True)
+
+    assert hasattr(response, 'redirect_chain')
+    assert len(response.redirect_chain) > 0, "Response didn't redirect"
+
+    assert response.redirect_chain[0][1] == 302
+    url, _ = response.redirect_chain[-1]
+    scheme, netloc, path, query, fragment = urlsplit(url)
+    assert path == reverse('login')
+
+    url, _ = response.redirect_chain[-2]
+    scheme, netloc, path, query, fragment = urlsplit(url)
+    assert path == reverse('dashboard')
+
+    assert response.status_code == 200
diff --git a/django/website/hid/tests/views_tests.py b/django/website/hid/tests/views_tests.py
index 75c285f49e7de6d6dd46539f251f2e6952791d4f..9ce9cd1399d6e3f3db448ad387049d98572b856e 100644
--- a/django/website/hid/tests/views_tests.py
+++ b/django/website/hid/tests/views_tests.py
@@ -2,7 +2,7 @@ import pytest
 
 from django.contrib.messages.storage.fallback import FallbackStorage
 from django.core.urlresolvers import reverse
-from django.http import HttpResponseRedirect
+from django.http import HttpResponseRedirect, QueryDict
 from django.test import RequestFactory
 
 from ..views import (
@@ -54,7 +54,8 @@ def request_item():
 
     url = reverse('data-view-process')
     request = ReqFactory.post(url, {
-        'action': DELETE_COMMAND,
+        'action': 'batchupdate-top',
+        'batchaction-top': DELETE_COMMAND,
         'select_item_id': [item['id']]}
     )
     request = fix_messages(request)
@@ -63,7 +64,7 @@ def request_item():
 
 
 def check_item_was_deleted(request):
-    assert check_message(request, u"Successfully deleted 1 item.") is True
+    assert check_message(request, u"1 item deleted.") is True
 
     items = list(transport.items.list())
     assert len(list(items)) == 0
@@ -155,3 +156,68 @@ def test_get_category_options_with_no_taxonomy_returns_all():
 
     assert (type_1.name, type_1.long_name) in options
     assert (other_term.name, other_term.long_name) in options
+
+
+@pytest.mark.django_db
+def test_get_category_options_orders_by_lowercase_name():
+    # TODO: Rewrite tests to use transport layer
+    ebola_questions = TaxonomyFactory(name="Ebola Questions")
+    test_term_values = [
+        ('test a1', '1'), ('test b1', '2'),
+        ('test A2', '3'), ('test B2', '4')
+    ]
+    for test_value in test_term_values:
+        TermFactory(
+            name=test_value[0],
+            long_name=test_value[1],
+            taxonomy=ebola_questions
+        )
+
+    view = ViewItems()
+    options = view.get_category_options(ebola_questions.id)
+    # Make sure we are only comparing with out test values!
+    options = [o for o in options if o in test_term_values]
+
+    # Expected is the list ordered by lowercase short name.
+    expected = sorted(test_term_values, key=lambda e: e[0].lower())
+
+    assert options == expected
+
+
+def test_views_item_get_request_parameters_renames_items_of_active_location():
+    query = QueryDict(
+        'action=something-bottom&item-top=top-value&item-bottom=bottom-value'
+    )
+    expected = {
+        'action': 'something',
+        'item': 'bottom-value',
+        'item-top': 'top-value'
+    }
+    actual = ViewItems.get_request_parameters(query)
+    assert actual.dict() == expected
+
+
+def test_views_item_get_request_parameters_sets_default_location():
+    query = QueryDict(
+        'action=something&item-top=top-value&item-bottom=bottom-value'
+    )
+    expected = {
+        'action': 'something',
+        'item': 'top-value',
+        'item-bottom': 'bottom-value'
+    }
+    actual = ViewItems.get_request_parameters(query)
+    assert actual.dict() == expected
+
+
+def test_views_item_get_request_parameters_sets_default_action_and_location():
+    query = QueryDict(
+        'item-top=top-value&item-bottom=bottom-value'
+    )
+    expected = {
+        'action': 'none',
+        'item': 'top-value',
+        'item-bottom': 'bottom-value'
+    }
+    actual = ViewItems.get_request_parameters(query)
+    assert actual.dict() == expected
diff --git a/django/website/hid/views.py b/django/website/hid/views.py
index 8afaffe68c05a85e39862bd47514dfc0ab9b083a..1c1ea9575f57b50505be9692716fed36aa0e7584 100644
--- a/django/website/hid/views.py
+++ b/django/website/hid/views.py
@@ -1,6 +1,11 @@
+import re
+
+from collections import OrderedDict
+
 from django.contrib import messages
+from django.contrib.auth.views import login
 from django.core.urlresolvers import reverse
-from django.http import HttpResponseRedirect
+from django.http import HttpResponseRedirect, QueryDict
 from django.utils.translation import ugettext as _
 from django.utils.translation import ungettext
 from django.views.generic import FormView
@@ -20,6 +25,7 @@ from .tables import ItemTable
 QUESTION_TYPE_TAXONOMY = 'ebola-questions'
 ADD_CATEGORY_PREFIX = 'add-category-'
 DELETE_COMMAND = 'delete'
+NONE_COMMAND = 'none'
 
 
 class ListSources(TemplateView):
@@ -87,14 +93,19 @@ class ViewItems(SingleTableView):
     def get_category_options(self, categories_id=None):
         # TODO: Use data layer
         terms = self.get_matching_terms(categories_id)
-
         return tuple((t.name, t.long_name) for t in terms)
 
     def get_matching_terms(self, categories_id):
         if categories_id is None:
-            return Term.objects.all()
+            return (Term.objects
+                    .extra(select={'name_lower': 'lower(name)'})
+                    .order_by('name_lower')
+                    .all())
 
-        return Term.objects.filter(taxonomy__id=categories_id)
+        return (Term.objects
+                .extra(select={'name_lower': 'lower(name)'})
+                .order_by('name_lower')
+                .filter(taxonomy__id=categories_id))
 
     def get_table(self, **kwargs):
         # TODO: Filter on taxonomy
@@ -107,11 +118,17 @@ class ViewItems(SingleTableView):
         context['upload_form'] = UploadForm(initial={'source': 'geopoll'})
         context['actions'] = [
             self._build_action_dropdown_group(
-                items=[(DELETE_COMMAND, _('Delete Selected'))]
+                label=_('Actions'),
+                items=[
+                    (NONE_COMMAND, '---------'),
+                    (DELETE_COMMAND, _('Delete Selected'))
+                ]
             ),
             self._build_action_dropdown_group(
                 label=_('Set question type'),
-                items=self.get_category_options(),
+                items=[(short_name, short_name)
+                       for short_name, long_name
+                       in self.get_category_options()],
                 prefix=ADD_CATEGORY_PREFIX
             )
         ]
@@ -136,11 +153,53 @@ class ViewItems(SingleTableView):
         """
         return {
             'label': label,
-            'items': dict(
-                [(prefix + k, v) for k, v in items]
+            'items': OrderedDict(
+                [(prefix + entry_cmd, entry_label)
+                    for entry_cmd, entry_label in items]
             )
         }
 
+    @staticmethod
+    def get_request_parameters(params):
+        """ Return the parameters of the given request.
+
+        The form has mirrored inputs as the top and the
+        bottom of the form. This detects which one was used
+        to submit the form, and returns the parameters
+        associated with that one.
+
+        It is expected that:
+            - All mirrored form elements are named as
+              <name>-<placement>
+            - The busmit button is called 'action',
+              and it's value is <action>-<placement>
+
+        Args:
+            - params: GET or POST request parameters
+
+        Returns:
+            The list of invoked parameters renamed such
+            that the active parameters match the submit
+            button that was invoked. If no 'action' exists
+            it is defaulted to 'none' and placement to 'top'.
+        """
+        new_params = QueryDict('', mutable=True)
+        action = params.get('action', 'none-top')
+        if '-' in action:
+            placement = re.sub('^[^-]+-', '', action)
+            action = action[0:len(action) - len(placement) - 1]
+        else:
+            placement = 'top'
+        for name, value in params.iterlists():
+            if name == 'action':
+                value = [action]
+            elif name.endswith(placement):
+                name = name[0:len(name)-len(placement)-1]
+            new_params.setlist(name, value)
+        if 'action' not in new_params:
+            new_params['action'] = 'none'
+        return new_params
+
 
 class ViewSingleItem(TemplateView):
     template_name = "hid/item.html"
@@ -161,8 +220,8 @@ def delete_items(request, deleted):
     try:
         transport.items.bulk_delete(deleted)
         num_deleted = len(deleted)
-        msg = ungettext("Successfully deleted %d item.",
-                        "Successfully deleted %d items.",
+        msg = ungettext("%d item deleted.",
+                        "%d items deleted.",
                         num_deleted) % num_deleted
         messages.success(request, msg)
     except:
@@ -170,30 +229,30 @@ def delete_items(request, deleted):
         messages.error(request, msg)
 
 
-def add_items_categories(request, items, category):
+def add_items_categories(request, items):
     """ Add the given category to the given items,
         and set a success/failure on the request
 
         Args:
             - request: Current request object
-            - items: List of item ids to add the category too
-            - category: Category name to add
+            - items: List of (item id, taxonomy_slug, term_name) tupples to
+                     update.
     """
     success = 0
     failed = 0
-    for item_id in items:
+    for item_id, taxonomy_slug, term_name in items:
         try:
             transport.items.add_term(
                 item_id,
-                QUESTION_TYPE_TAXONOMY,
-                category,
+                taxonomy_slug,
+                term_name
             )
             success += 1
         except TransportException:
             failed += 1
     if success > 0:
-        msg = ungettext("Successfully updated %d item.",
-                        "Successfully updated %d items.",
+        msg = ungettext("Updated %d item.",
+                        "Updated %d items.",
                         len(items)) % len(items)
         messages.success(request, msg)
     if failed > 0:
@@ -217,14 +276,41 @@ def process_items(request):
     redirect_url = reverse("data-view")
     # Just redirect back to items view on GET
     if request.method == "POST":
-        selected = ItemTable.get_selected(request.POST)
-        action = request.POST.get('action')
-        if action == DELETE_COMMAND:
-            delete_items(request, selected)
-        elif action and action.startswith(ADD_CATEGORY_PREFIX):
-            category = action[len(ADD_CATEGORY_PREFIX):]
-            add_items_categories(request, selected, category)
-        else:
+        params = ViewItems.get_request_parameters(request.POST)
+        if params['action'] == 'batchupdate':
+            selected = ItemTable.get_selected(params)
+            batch_action = params['batchaction']
+            if batch_action == DELETE_COMMAND:
+                delete_items(request, selected)
+            elif batch_action and batch_action.startswith(ADD_CATEGORY_PREFIX):
+                category = batch_action[len(ADD_CATEGORY_PREFIX):]
+                add_items_categories(
+                    request,
+                    [(item, QUESTION_TYPE_TAXONOMY, category)
+                     for item in selected]
+                )
+            elif batch_action == NONE_COMMAND:
+                pass
+            else:
+                messages.error(request, _('Unknown batch action'))
+        elif params['action'] == 'save':
+            changes = ItemTable.get_row_select_values(params, 'category')
+            add_items_categories(
+                request,
+                [(item, QUESTION_TYPE_TAXONOMY, category)
+                 for item, category in changes]
+            )
+        elif params['action'] != 'none':
             messages.error(request, _('Unknown action'))
 
     return HttpResponseRedirect(redirect_url)
+
+
+def csrf_failure(request, reason=''):
+    # If the user presses the back button in the browser to go back to the
+    # login page and logs in again, they will get a CSRF error page because
+    # the token will be wrong.
+    # We override this with a redirect to the dashboard, which if not already
+    # logged in, will redirect to the login page (with a fresh token).
+
+    return HttpResponseRedirect(reverse('dashboard'))
diff --git a/django/website/media/less/view-edit.less b/django/website/media/less/view-edit.less
index 34905f812ed95ba0ee0c10cfd5bba91509fdd873..43ea1e04b5f6470cc69b5372da5ac34e8f2ef5cf 100644
--- a/django/website/media/less/view-edit.less
+++ b/django/website/media/less/view-edit.less
@@ -81,12 +81,12 @@ td.created, td.timestamp {
   float:inherit;
 }
 
-// Form header actions
-.panel-body form .form-header-group {
+// Button header (and footer) for tables
+.panel-body form .table-button-group {
     margin-bottom: @padding-base-vertical;
 }
 
-.panel-body form .form-header-group .form-control {
+.panel-body form .table-button-group .form-control {
     float: left;
     width: auto;
     height: auto;
@@ -94,7 +94,7 @@ td.created, td.timestamp {
     margin-right: @padding-small-horizontal;
 }
 
-.panel-body .form-header-group select option[default] {
+.panel-body .table-button-group select option[default] {
     font-weight: bold;
 }
 
diff --git a/django/website/settings.py b/django/website/settings.py
index 3493ef3b7112ddbdcfb56c864ce5e7ffad5cf557..96f2f289b9e81a2e8dc5e925aec4b31d983ca6fc 100644
--- a/django/website/settings.py
+++ b/django/website/settings.py
@@ -390,5 +390,6 @@ else:
     )
 ########## END TEMPLATE CONFIGURATION
 
+CSRF_FAILURE_VIEW = 'hid.views.csrf_failure'
 
 ########## Your stuff: Below this line define 3rd party libary settings