diff --git a/django/website/dashboard/templates/dashboard/widget-error.html b/django/website/dashboard/templates/dashboard/widget-error.html new file mode 100644 index 0000000000000000000000000000000000000000..38d75f8dc4c387627734c312d64064064ed2e00f --- /dev/null +++ b/django/website/dashboard/templates/dashboard/widget-error.html @@ -0,0 +1,10 @@ +{% load i18n %} +<div class='panel panel-default'> + <div class='panel-heading'> + <span class='fa fa-warning fa-fw'></span> + <h2>{% trans "Error" %}</h2> + </div> + <div class='panel-body'> + {{ error }} + </div> +</div> diff --git a/django/website/dashboard/templates/dashboard/widget-missing-template.html b/django/website/dashboard/templates/dashboard/widget-missing-template.html deleted file mode 100644 index 3f976032f5542e73a9d294e177c6b49c2f763c5c..0000000000000000000000000000000000000000 --- a/django/website/dashboard/templates/dashboard/widget-missing-template.html +++ /dev/null @@ -1 +0,0 @@ -Missing template_name for widget type {{empty_type}} diff --git a/django/website/dashboard/templatetags/render_widget.py b/django/website/dashboard/templatetags/render_widget.py index d090438b51cd8816e0753490785cd3bfec9e0c7f..e9de818e4e26ce5f3f65760b9103bdc03bf3ac60 100644 --- a/django/website/dashboard/templatetags/render_widget.py +++ b/django/website/dashboard/templatetags/render_widget.py @@ -1,9 +1,13 @@ +import logging + from django import template from django.template.loader import render_to_string +from django.utils.translation import ugettext_lazy as _ -from dashboard.widget_pool import get_widget +from dashboard.widget_pool import get_widget, MissingWidgetType +logger = logging.getLogger(__name__) register = template.Library() @@ -16,18 +20,43 @@ def render_widget(widget_instance): get_context_data then that is used to generate the template context. """ - widget = get_widget(widget_instance.widget_type) + widget = None + # Get settings, if any if widget_instance.settings: settings = widget_instance.settings else: settings = {} + # Get widget try: - context = widget.get_context_data(**settings) - except AttributeError: + widget = get_widget(widget_instance.widget_type) + except MissingWidgetType: + template_name = 'dashboard/widget-error.html' context = {} - try: - template_name = widget.template_name - except AttributeError: - template_name = 'dashboard/widget-missing-template.html' + context['error'] = _('Unknown widget type %(widget_type)s') % { + 'widget_type': widget_instance.widget_type + } context['empty_type'] = widget_instance.widget_type + if widget: + # Get template + try: + template_name = widget.template_name + except AttributeError: + template_name = 'dashboard/widget-error.html' + context = {} + context['error'] = _('Missing template for %(widget_type)s') % { + 'widget_type': widget_instance.widget_type + } + widget = None + if widget: + # Get context + try: + context = widget.get_context_data(**settings) + except AttributeError: + context = {} + except Exception as e: + logger.exception('Error while fetching widget context data: %s', e) + template_name = 'dashboard/widget-error.html' + context = {} + context['error'] = _('Widget error. See error logs.') + return render_to_string(template_name, context) diff --git a/django/website/dashboard/tests/widget_tests.py b/django/website/dashboard/tests/widget_tests.py index 10c8026752780e9d5523af7973202b1c2f70b630..2b69bc3326e97cee9a4961087bde2f175c569fcf 100644 --- a/django/website/dashboard/tests/widget_tests.py +++ b/django/website/dashboard/tests/widget_tests.py @@ -150,5 +150,5 @@ class WidgetPoolTestCase(TestCase): mock, 'template_name' ) self.assertEqual( - template_name, 'dashboard/widget-missing-template.html' + template_name, 'dashboard/widget-error.html' ) diff --git a/django/website/dashboard/views.py b/django/website/dashboard/views.py index 73f4a3b61eb2844a04d7ae25e9ca26e655b2eb7a..b20136dce9475c8ae26cbc59ff3ff10d075123f0 100644 --- a/django/website/dashboard/views.py +++ b/django/website/dashboard/views.py @@ -3,7 +3,7 @@ from django.views.generic import TemplateView from hid.assets import require_assets from dashboard.models import Dashboard -from dashboard.widget_pool import get_widget +from dashboard.widget_pool import get_widget, MissingWidgetType class DashboardView(TemplateView): @@ -43,7 +43,10 @@ class DashboardView(TemplateView): # Ensure we have all the javascript & css dependencies require_assets('dashboard/dashboard.css') for widget in widgets: - widget_type = get_widget(widget.widget_type) + try: + widget_type = get_widget(widget.widget_type) + except MissingWidgetType: + continue if hasattr(widget_type, 'javascript'): require_assets(*widget_type.javascript) if hasattr(widget_type, 'css'): diff --git a/django/website/dashboard/widget_pool.py b/django/website/dashboard/widget_pool.py index 7615b0bb5cb4fbccddd2acc08db5208ba15f47e4..98cc08bc6c92c660e979490e8a1074b516db8f9e 100644 --- a/django/website/dashboard/widget_pool.py +++ b/django/website/dashboard/widget_pool.py @@ -1,6 +1,11 @@ _pool = {} +class MissingWidgetType(Exception): + """ Exception raised when a widget type is missing """ + pass + + def register_widget(name, widget): """ Register a new widget type @@ -24,10 +29,13 @@ def get_widget(name): Returns: The widget object as registered with register_widget Raises: - KeyError: If the widget type does not exist + MissingWidgetType: If the widget type does not exist """ global _pool - return _pool[name] + try: + return _pool[name] + except KeyError: + raise MissingWidgetType() class BasicTextWidget(object): diff --git a/django/website/hid/__init__.py b/django/website/hid/__init__.py index ec07e109f57010e2b83ee066c51f721e960e164b..9948ca8850f47ac83269031849f020bb65cd17fd 100644 --- a/django/website/hid/__init__.py +++ b/django/website/hid/__init__.py @@ -1,7 +1,7 @@ from dashboard.widget_pool import register_widget -from hid.widgets.chart import QuestionChartWidget +from hid.widgets.term_count_chart import TermCountChartWidget from hid.widgets.table import TableWidget -register_widget('question-chart-widget', QuestionChartWidget()) +register_widget('term-count-chart', TermCountChartWidget()) register_widget('table-widget', TableWidget()) diff --git a/django/website/hid/tests/chart_widget_tests.py b/django/website/hid/tests/chart_widget_tests.py deleted file mode 100644 index 3cc5ad03b2982aa6ec5dd24f405ed964240c0b2c..0000000000000000000000000000000000000000 --- a/django/website/hid/tests/chart_widget_tests.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.test import TestCase -from hid.widgets.chart import QuestionChartWidget - - -class TestQuestionChartWidget(TestCase): - def test_context_data_includes_widget_name(self): - widget = QuestionChartWidget() - context_data = widget.get_context_data(name='test-name', questions={}) - self.assertEqual(context_data['name'], 'test-name') - - def test_context_data_includes_flot_options(self): - widget = QuestionChartWidget() - context_data = widget.get_context_data(name='test-name', questions={}) - self.assertTrue('options' in context_data) - - def test_context_data_includes_flot_data(self): - widget = QuestionChartWidget() - context_data = widget.get_context_data(name='test-name', questions={}) - self.assertTrue('data' in context_data) - - def test_context_data_includes_correct_data(self): - widget = QuestionChartWidget() - context_data = widget.get_context_data(name='test-name', questions={ - 'question one': 345, - 'question two': 782 - }) - self.assertEqual( - context_data['data'], - [[[345, 0], [782, 1]]] - ) - - def test_chart_questions_are_set_as_yaxis_value_labels(self): - widget = QuestionChartWidget() - context_data = widget.get_context_data(name='test-name', questions={ - 'question one': 345, - 'question two': 782 - }) - self.assertEqual( - context_data['options']['yaxis']['ticks'], - [[0, 'question one'], [1, 'question two']] - ) 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/term_count_widget_tests.py b/django/website/hid/tests/term_count_widget_tests.py new file mode 100644 index 0000000000000000000000000000000000000000..6d8e2ddec5a1fea598f9ba653a4866d6da735393 --- /dev/null +++ b/django/website/hid/tests/term_count_widget_tests.py @@ -0,0 +1,138 @@ +from mock import patch +from django.test import TestCase +from hid.widgets.term_count_chart import TermCountChartWidget + + +class TestTermCountChartWidget(TestCase): + def test_context_data_includes_widget_title(self): + widget = TermCountChartWidget() + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [] + context_data = widget.get_context_data( + title='test-name', taxonomy='tax' + ) + self.assertEqual(context_data['title'], 'test-name') + + def test_context_data_includes_flot_options(self): + widget = TermCountChartWidget() + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [] + context_data = widget.get_context_data( + title='test-name', taxonomy='tax' + ) + self.assertTrue('options' in context_data) + + def test_context_data_includes_flot_data(self): + widget = TermCountChartWidget() + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [] + context_data = widget.get_context_data( + title='test-name', taxonomy='tax' + ) + self.assertTrue('data' in context_data) + + def test_context_data_includes_correct_data(self): + widget = TermCountChartWidget() + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [ + { + 'name': 'name one', + 'long_name': 'long name one', + 'count': 345 + }, + { + 'name': 'name two', + 'long_name': 'long name two', + 'count': 782 + }, + ] + context_data = widget.get_context_data( + title='test-name', taxonomy='tax' + ) + self.assertEqual( + context_data['data'], + [[[345, 2], [782, 1]]] + ) + + def test_chart_questions_are_set_as_yaxis_value_labels(self): + widget = TermCountChartWidget() + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [ + { + 'name': 'name one', + 'long_name': 'long name one', + 'count': 345 + }, + { + 'name': 'name two', + 'long_name': 'long name two', + 'count': 782 + }, + ] + context_data = widget.get_context_data( + title='test-name', taxonomy='tax' + ) + self.assertEqual( + context_data['options']['yaxis']['ticks'], + [[2, 'long name one'], [1, 'long name two']] + ) + + def test_fetch_counts_orders_by_long_name(self): + widget = TermCountChartWidget() + + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [ + { + 'name': 'aaa-name', + 'long_name': 'zzz-long-name', + 'count': 0 + }, + { + 'name': 'zzz-name', + 'long_name': 'aaa-long-name', + 'count': 1000 + }, + ] + counts = widget._fetch_counts('tax', 0, 'Others') + + self.assertEqual( + counts.items(), + [('aaa-long-name', 1000), ('zzz-long-name', 0)] + ) + + def test_fetch_counts_gets_n_larger_and_aggregates_others_items(self): + widget = TermCountChartWidget() + + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [ + { + 'name': 'name one', + 'long_name': 'long-name one', + 'count': 1 + }, + { + 'name': 'name two', + 'long_name': 'long-name two', + 'count': 10 + }, + { + 'name': 'name three', + 'long_name': 'long-name three', + 'count': 20 + }, + { + 'name': 'name four', + 'long_name': 'long-name four', + 'count': 30 + }, + + ] + counts = widget._fetch_counts('tax', 3, 'Others') + + self.assertEqual( + counts.items(), + [ + ('long-name four', 30), ('long-name three', 20), + ('Others', 11) + ] + ) diff --git a/django/website/hid/views.py b/django/website/hid/views.py index 3dffd8269d33c23e3cc4997b1c4c3496d070aac8..17d824badcb692c315d5e17226164c7a062756bf 100644 --- a/django/website/hid/views.py +++ b/django/website/hid/views.py @@ -3,6 +3,7 @@ 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, QueryDict from django.utils.translation import ugettext as _ @@ -295,3 +296,13 @@ def process_items(request): 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/hid/widgets/chart.py b/django/website/hid/widgets/chart.py deleted file mode 100644 index 7e28ab4ad73e3a85e0d212292b88ff41c337829c..0000000000000000000000000000000000000000 --- a/django/website/hid/widgets/chart.py +++ /dev/null @@ -1,68 +0,0 @@ -class QuestionChartWidget(object): - """ A horizontal bar chart used to display quantitative answers to - specific questions. - - Eventually this should pull the questions and values from the - data API. For now we pass these in via the settings. - - Settings: - name: Name of the chart - questions: Dictionary of question to value - """ - template_name = 'hid/widgets/chart.html' - javascript = [ - 'flot/jquery.flot.js', - 'flot/jquery.flot.resize.js', - 'hid/widgets/chart.js' - ] - - def get_context_data(self, **kwargs): - index = 0 - yticks = [] - values = [] - for question, answer in kwargs['questions'].items(): - yticks.append([index, question]) - values.append([answer, index]) - index += 1 - - return { - 'name': kwargs['name'], - 'options': { - 'series': { - 'bars': { - 'show': True, - 'fillColor': '#f29e30' - }, - 'color': 'transparent' - }, - 'bars': { - 'horizontal': True, - 'barWidth': 0.6, - 'align': 'center', - 'fill': True, - 'lineWidth': 0, - }, - 'yaxis': { - 'ticks': yticks, - 'tickLength': 0, - 'color': '#333333', - 'font': { - 'size': 12, - 'style': 'normal', - 'weight': 'normal', - 'family': 'sans-serif' - } - }, - 'xaxis': { - 'autoscaleMargin': 0.1 - }, - 'grid': { - 'hoverable': True, - 'borderWidth': 0, - 'margin': 10, - 'labelMargin': 20, - 'backgroundColor': '#fafafa' - } - }, - 'data': [values] - } diff --git a/django/website/hid/widgets/term_count_chart.py b/django/website/hid/widgets/term_count_chart.py new file mode 100644 index 0000000000000000000000000000000000000000..136650decd0b1317935d007359e79fa3d054e0e3 --- /dev/null +++ b/django/website/hid/widgets/term_count_chart.py @@ -0,0 +1,123 @@ +from collections import OrderedDict +from django.utils.translation import ugettext_lazy as _ +from transport.taxonomies import term_itemcount + + +class TermCountChartWidget(object): + """ A horizontal bar chart used to display number of entries for each + term of a taxonomy. + + Settings: + title: Name of the widget + taxonomy: Slug of the taxonomy + count: Maximum number of terms to display. + If this is >0, then only the countth most + used terms are displayed, and all others + are aggregated under 'Others' + other_label: Optional label to use instead of 'Others' + """ + template_name = 'hid/widgets/chart.html' + javascript = [ + 'flot/jquery.flot.js', + 'flot/jquery.flot.resize.js', + 'hid/widgets/chart.js' + ] + + def _fetch_counts(self, taxonomy, count, other_label): + """ Given a taxonomy, fetch the count per term. + + Args: + - taxonomy: Taxonomy slug + - count: If >0, maximum number of rows to returns. If the data + has more terms, all other terms are aggregated under + an 'others' section + - other_label: Label for the 'Others' section + """ + itemcount = term_itemcount(taxonomy) + itemcount.sort(key=lambda k: int(k['count']), reverse=True) + if count > 0: + head = itemcount[0:count-1] + tail = itemcount[count-1:] + else: + head = itemcount + tail = [] + head.sort(key=lambda k: k['long_name']) + counts = OrderedDict() + for item in head: + counts[item['long_name']] = item['count'] + if len(tail) > 0: + agg = 0 + for item in tail: + agg = agg + item['count'] + counts[other_label] = agg + return counts + + def _create_axis_values(self, counts): + """ Given a dictionary of label to value, create the Y and X axis values + to be used in flot. + + Args: + - chart: A dictionary of label to value + + Returns: + A tuple containing (X Axis data, Y Axis data) + """ + yticks = [] + values = [] + index = len(counts) + for label, value in counts.items(): + yticks.append([index, label]) + values.append([value, index]) + index -= 1 + + return values, yticks + + def get_context_data(self, **kwargs): + title = kwargs.get('title', _('(missing title)')) + taxonomy = kwargs.get('taxonomy') + count = kwargs.get('count', 0) + other_label = kwargs.get('other_label', 'Others') + + counts = self._fetch_counts(taxonomy, count, other_label) + (values, yticks) = self._create_axis_values(counts) + return { + 'title': title, + 'options': { + 'series': { + 'bars': { + 'show': True, + 'fillColor': '#f29e30' + }, + 'color': 'transparent' + }, + 'bars': { + 'horizontal': True, + 'barWidth': 0.6, + 'align': 'center', + 'fill': True, + 'lineWidth': 0, + }, + 'yaxis': { + 'ticks': yticks, + 'tickLength': 0, + 'color': '#333333', + 'font': { + 'size': 12, + 'style': 'normal', + 'weight': 'normal', + 'family': 'sans-serif' + } + }, + 'xaxis': { + 'autoscaleMargin': 0.1 + }, + 'grid': { + 'hoverable': True, + 'borderWidth': 0, + 'margin': 10, + 'labelMargin': 20, + 'backgroundColor': '#fafafa' + } + }, + 'data': [values] + } 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