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