diff --git a/django/website/dashboard/templatetags/render_widget.py b/django/website/dashboard/templatetags/render_widget.py index e9de818e4e26ce5f3f65760b9103bdc03bf3ac60..175ea4a9dbe41215b0aa2f749d70d0a4917c79e2 100644 --- a/django/website/dashboard/templatetags/render_widget.py +++ b/django/website/dashboard/templatetags/render_widget.py @@ -4,7 +4,7 @@ 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, MissingWidgetType +from dashboard.widget_pool import get_widget, MissingWidgetType, WidgetError logger = logging.getLogger(__name__) @@ -53,6 +53,10 @@ def render_widget(widget_instance): context = widget.get_context_data(**settings) except AttributeError: context = {} + except WidgetError as e: + template_name = 'dashboard/widget-error.html' + context = {} + context['error'] = str(e) except Exception as e: logger.exception('Error while fetching widget context data: %s', e) template_name = 'dashboard/widget-error.html' diff --git a/django/website/dashboard/tests/widget_tests.py b/django/website/dashboard/tests/widget_tests.py index 2b69bc3326e97cee9a4961087bde2f175c569fcf..0719de5c97acb8e27e8f37436db2bd25208a86a0 100644 --- a/django/website/dashboard/tests/widget_tests.py +++ b/django/website/dashboard/tests/widget_tests.py @@ -3,7 +3,7 @@ from mock import patch from django.test import TestCase from dashboard.templatetags.render_widget import render_widget -from dashboard.widget_pool import register_widget, get_widget +from dashboard.widget_pool import register_widget, get_widget, WidgetError class TestWidget(object): @@ -38,6 +38,26 @@ class TestWidgetNoTemplateName(object): return {} +class TestWidgetRaisesException(object): + """ A test widget which raises a generic exception in + get_context_data + """ + template_name = 'something.html' + + def get_context_data(self, **kwargs): + raise Exception('message raised from get_context_data') + + +class TestWidgetRaisesWidgetError(object): + """ A test widget which raises a WidgetError in + get_context_data + """ + template_name = 'something.html' + + def get_context_data(self, **kwargs): + raise WidgetError('message raised from get_context_data') + + class MockWidgetInstance(object): """ A Mock class to represent a widget instance @@ -152,3 +172,48 @@ class WidgetPoolTestCase(TestCase): self.assertEqual( template_name, 'dashboard/widget-error.html' ) + + def test_render_widget_exception_includes_generic_message(self): + """ Test that a widget which raises a generic exception will + display a generic error message, not the content of the + exception + """ + test_widget = TestWidgetRaisesException() + register_widget('test-widget', test_widget) + widget_instance = MockWidgetInstance('test-widget') + with patch(self.render_to_string_method) as mock: + render_widget(widget_instance) + template_name = self.get_mock_render_to_string_parameter( + mock, 'template_name' + ) + context = self.get_mock_render_to_string_parameter( + mock, 'context' + ) + self.assertEqual( + template_name, 'dashboard/widget-error.html' + ) + self.assertEqual( + context['error'], 'Widget error. See error logs.' + ) + + def test_render_widget_widgeterror_exception_includes_error_message(self): + """ Test that a widget which raises a WidgetError exception will + display the error message provided in the exception. + """ + test_widget = TestWidgetRaisesWidgetError() + register_widget('test-widget', test_widget) + widget_instance = MockWidgetInstance('test-widget') + with patch(self.render_to_string_method) as mock: + render_widget(widget_instance) + template_name = self.get_mock_render_to_string_parameter( + mock, 'template_name' + ) + context = self.get_mock_render_to_string_parameter( + mock, 'context' + ) + self.assertEqual( + template_name, 'dashboard/widget-error.html' + ) + self.assertEqual( + str(context['error']), 'message raised from get_context_data' + ) diff --git a/django/website/dashboard/widget_pool.py b/django/website/dashboard/widget_pool.py index 98cc08bc6c92c660e979490e8a1074b516db8f9e..83bc41577f503bddd040d69e6ac87429a80bfa75 100644 --- a/django/website/dashboard/widget_pool.py +++ b/django/website/dashboard/widget_pool.py @@ -6,6 +6,17 @@ class MissingWidgetType(Exception): pass +class WidgetError(Exception): + """ Exception that can be raised from widget types + in get_context_data. + + The error message will be displayed to the end + user, so should not contain debug or sensisitve + information + """ + pass + + def register_widget(name, widget): """ Register a new widget type diff --git a/django/website/data_layer/migrations/0005_message_last_modified.py b/django/website/data_layer/migrations/0005_message_last_modified.py new file mode 100644 index 0000000000000000000000000000000000000000..4570f9d33c61a323c23e5707fb3bb98d32de42c6 --- /dev/null +++ b/django/website/data_layer/migrations/0005_message_last_modified.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import datetime +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_layer', '0004_auto_20150720_1723'), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='last_modified', + field=models.DateTimeField(default=datetime.datetime(2015, 7, 29, 11, 25, 40, 915975, tzinfo=utc), auto_now=True), + preserve_default=False, + ), + ] diff --git a/django/website/data_layer/models.py b/django/website/data_layer/models.py index fa04cfe55ba847dac221853f1f24ea3953ad2e7d..97fbebfad810f236561d0ad8cdb82da7288227ea 100644 --- a/django/website/data_layer/models.py +++ b/django/website/data_layer/models.py @@ -1,13 +1,21 @@ from django.db import models +from django.dispatch.dispatcher import receiver +from django.utils import timezone + from taxonomies.models import Term class DataLayerModel(models.Model): created = models.DateTimeField(auto_now_add=True) + last_modified = models.DateTimeField(auto_now=True) class Meta: abstract = True + def note_external_modification(self): + # This will set the last_modified field + self.save() + class Message(DataLayerModel): body = models.TextField() @@ -42,3 +50,18 @@ class Message(DataLayerModel): # TODO: rename this class Item = Message + + +@receiver(models.signals.m2m_changed, sender=Item.terms.through, + dispatch_uid="data_layer.models.terms_signal_handler") +def terms_signal_handler(sender, **kwargs): + if kwargs.get('action') not in ('post_add', 'post_remove'): + return + + if kwargs.get('reverse'): + items = Item.objects.filter(pk__in=kwargs.get('pk_set')) + else: + items = [kwargs.get('instance')] + + for item in items: + item.note_external_modification() diff --git a/django/website/data_layer/tests/item_tests.py b/django/website/data_layer/tests/item_tests.py new file mode 100644 index 0000000000000000000000000000000000000000..404aa938106715f743f32da04a70393d9cac4797 --- /dev/null +++ b/django/website/data_layer/tests/item_tests.py @@ -0,0 +1,147 @@ +import pytest +import datetime +from mock import patch, MagicMock + +from django.utils import timezone + +from factories import ItemFactory +from ..models import Item +from taxonomies.tests.factories import TermFactory, TaxonomyFactory + + +def last_modified(item): + return Item.objects.get(id=item.id).last_modified + + +# Ensure value of "now" always increases by amount sufficient +# to show up as a change, even if db resolution for datetime +# is one second. +def time_granularity(): + return datetime.timedelta(hours=1) + + +def now_iter(start): + t = start + while True: + t += time_granularity() + yield t + + +def num_updates(old_time, new_time): + elapsed_time = new_time - old_time + + return elapsed_time.seconds / time_granularity().seconds + + +@pytest.fixture +def item(): + return ItemFactory() + + +@pytest.fixture +def mock_time_now(): + return MagicMock(wraps=timezone.now, + side_effect=now_iter(timezone.now())) + + +@pytest.mark.django_db +def test_last_modified_date_updates_on_body_change(item, mock_time_now): + with patch('django.utils.timezone.now', new=mock_time_now): + orig_last_modified = last_modified(item) + item.body = 'replacement text' + item.save() + + assert num_updates(orig_last_modified, last_modified(item)) == 1 + + +@pytest.mark.django_db +def test_last_modified_date_updates_on_item_category_add( + item, mock_time_now): + with patch('django.utils.timezone.now', new=mock_time_now): + orig_last_modified = last_modified(item) + term = TermFactory() + item.terms.add(term) + + assert num_updates(orig_last_modified, last_modified(item)) == 1 + + +@pytest.mark.django_db +def test_last_modified_date_updates_on_item_category_delete( + item, mock_time_now): + with patch('django.utils.timezone.now', new=mock_time_now): + term = TermFactory() + item.terms.add(term) + orig_last_modified = last_modified(item) + item.terms.remove(term) + + assert num_updates(orig_last_modified, last_modified(item)) == 1 + + +@pytest.mark.django_db +def test_last_modified_date_updates_on_category_item_add( + item, mock_time_now): + with patch('django.utils.timezone.now', new=mock_time_now): + orig_last_modified = last_modified(item) + term = TermFactory() + term.items.add(item) + + assert num_updates(orig_last_modified, last_modified(item)) == 1 + + +@pytest.mark.django_db +def test_last_modified_date_updates_on_category_item_delete( + item, mock_time_now): + with patch('django.utils.timezone.now', new=mock_time_now): + term = TermFactory() + term.items.add(item) + orig_last_modified = last_modified(item) + term.items.remove(item) + + assert num_updates(orig_last_modified, last_modified(item)) == 1 + + +@pytest.mark.django_db +def test_last_modified_date_on_other_item_not_updated( + item, mock_time_now): + with patch('django.utils.timezone.now', new=mock_time_now): + term = TermFactory() + term.items.add(item) + + other_item = ItemFactory() + term.items.add(other_item) + + orig_last_modified = last_modified(other_item) + term.items.remove(item) + + assert num_updates(orig_last_modified, last_modified(other_item)) == 0 + + +@pytest.mark.django_db +def test_apply_term_replaces_term_for_categories(): + item = ItemFactory() + taxonomy = TaxonomyFactory() # Ensure multiplicity = optional + term1 = TermFactory(taxonomy=taxonomy) + term2 = TermFactory(taxonomy=taxonomy) + assert taxonomy.is_optional + + item.apply_term(term1) + assert list(item.terms.all()) == [term1] + + item.apply_term(term2) + assert list(item.terms.all()) == [term2] + +@pytest.mark.xfail +# I'm putting this here to explain some of my thinking. +@pytest.mark.django_db +def test_apply_term_adds_term_for_tags(): + item = ItemFactory() + taxonomy = TaxonomyFactory() # Ensure multiplicity == multiple + term1 = TermFactory(taxonomy=taxonomy) + term2 = TermFactory(taxonomy=taxonomy) + assert taxonomy.is_multiple + + item.apply_term(term1) + assert list(item.terms.all()) == [term1] + + item.apply_term(term2) + assert set(item.terms.all()) == set([term1, term2]) diff --git a/django/website/hid/static/hid/widgets/chart.js b/django/website/hid/static/hid/widgets/chart.js index 54b79baf93dab1b670af46f59ff50b0849856a0c..34bd445937a3cbbbe46058f8517b35c25ba45a37 100644 --- a/django/website/hid/static/hid/widgets/chart.js +++ b/django/website/hid/static/hid/widgets/chart.js @@ -39,11 +39,16 @@ FlotChart = { /* Render the chart */ render: function() { + var options = this.chart.get('options'); + var $legend_container = this.$el.siblings('.flot-legend'); + if ($legend_container.length > 0 && options['legend']) { + options['legend']['container'] = $legend_container; + } this.resize(); $.plot( this.$el, this.chart.get('data'), - this.chart.get('options') + options ); return this; @@ -51,8 +56,14 @@ FlotChart = { /* Resize the chart */ resize: function() { - this.$el.width(this.$container.width()); - this.$el.height(this.$container.height()); + if (this.$container.length == 0) { + return; + } + var new_width = this.$container.width(); + var chart_parent_offset = this.$el.offset().top - this.$container.offset().top; + var new_height = this.$container.height() - chart_parent_offset; + this.$el.width(new_width); + this.$el.height(new_height); }, /* Display tooltip */ diff --git a/django/website/hid/templates/hid/tests/chart.html b/django/website/hid/templates/hid/tests/chart.html index b4e4ca77c639110551fb4fc59570b1abdc5a1879..8176e48febcd8eb8b94c7e0f9c8055ae7c99c642 100644 --- a/django/website/hid/templates/hid/tests/chart.html +++ b/django/website/hid/templates/hid/tests/chart.html @@ -51,9 +51,11 @@ </script> {% endblock %} {% block body_content %} - <div class='flot-chart' - data-data='[[[23, 0], [10, 1], [150, 2], [50, 3]]]' - data-options='{"series": {"bars": {"show": true}}, "bars": {"horizontal": true}, "yaxis": {"ticks": [[0, "q1"], [1, "q2"], [2, "q3"], [3, "q4"]]}}' - style='width:300px; height:300px' - ></div> + <div class='panel-body'> + <div class='flot-chart' + data-data='[[[23, 0], [10, 1], [150, 2], [50, 3]]]' + data-options='{"series": {"bars": {"show": true}}, "bars": {"horizontal": true}, "yaxis": {"ticks": [[0, "q1"], [1, "q2"], [2, "q3"], [3, "q4"]]}}' + style='width:300px; height:300px' + ></div> + </div> {% endblock %} diff --git a/django/website/hid/templates/hid/widgets/chart.html b/django/website/hid/templates/hid/widgets/chart.html index e8a742cf81a81bf92e2297bee375bae66882a5bd..f514ef1d675c4ded785559a98bc501442c83d692 100644 --- a/django/website/hid/templates/hid/widgets/chart.html +++ b/django/website/hid/templates/hid/widgets/chart.html @@ -5,7 +5,9 @@ <h2>{{title}}</h2> </div> <div class='panel-body'> - <div class='flot-chart' + <div class='flot-legend'> + </div> + <div class='flot-chart' data-options='{{ options|json_data }}' data-data='{{ data|json_data }}' > diff --git a/django/website/hid/tests/categorize_items_tests.py b/django/website/hid/tests/categorize_items_tests.py index 1a787e49c6322eb62fcca8905694836b9f0bc4f0..d85a6023facf973b7f486ba6b29438b450162a7a 100644 --- a/django/website/hid/tests/categorize_items_tests.py +++ b/django/website/hid/tests/categorize_items_tests.py @@ -13,6 +13,13 @@ from ..views import add_items_categories ReqFactory = RequestFactory() +@pytest.fixture +def term(): + # TODO rewrite using transport.terms, etc. + taxonomy = TaxonomyFactory(name="Test Ebola Questions") + return TermFactory(taxonomy=taxonomy, name="Vaccine") + + @pytest.fixture def terms(): # TODO rewrite using transport.terms, etc. @@ -31,6 +38,26 @@ def items(): ] +@pytest.fixture +def item(): + return transport.items.create({'body': 'test message one'}) + + +@pytest.mark.django_db +def test_add_categories_adds_term_to_item(term, item): + category_list = [(item['id'], term.taxonomy.slug, term.name), ] + + url = reverse('data-view-process') + request = ReqFactory.post(url, {'a': 'b'}) + request = fix_messages(request) + add_items_categories(request, category_list) + + [item_data] = transport.items.list() + [term_data] = item_data['terms'] + assert term_data['name'] == term.name + assert term_data['taxonomy'] == term.taxonomy.slug + + @pytest.mark.django_db def test_add_items_categories_adds_term_to_items(terms, items): url = reverse('data-view-process') diff --git a/django/website/hid/tests/term_count_widget_tests.py b/django/website/hid/tests/term_count_widget_tests.py index 6d8e2ddec5a1fea598f9ba653a4866d6da735393..632435fa67505752cc6f5359516023d408fcbb54 100644 --- a/django/website/hid/tests/term_count_widget_tests.py +++ b/django/website/hid/tests/term_count_widget_tests.py @@ -1,4 +1,7 @@ +from datetime import datetime, timedelta +from dateutil import parser from mock import patch +from dashboard.widget_pool import WidgetError from django.test import TestCase from hid.widgets.term_count_chart import TermCountChartWidget @@ -50,10 +53,80 @@ class TestTermCountChartWidget(TestCase): title='test-name', taxonomy='tax' ) self.assertEqual( - context_data['data'], - [[[345, 2], [782, 1]]] + context_data['data'][0]['data'], + [[345, 2], [782, 1]] ) + def test_context_data_hides_legend_when_there_is_no_time_period(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['options']['legend']['show'], False) + + def test_context_data_includes_legend_when_there_is_a_time_period(self): + widget = TermCountChartWidget() + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [] + periods = [{ + 'start_time': '2015-01-01', + 'end_time': '2015-02-02' + }] + context_data = widget.get_context_data( + title='test-name', taxonomy='tax', periods=periods + ) + self.assertEqual(context_data['options']['legend']['show'], True) + + def test_context_data_raises_widgeterror_when_more_than_one_period(self): + widget = TermCountChartWidget() + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [] + periods = [{ + 'start_time': '2015-01-01', + 'end_time': '2015-02-02' + }, { + 'start-time': '2015-07-08', + 'end-time': '2015-07-09' + }] + with self.assertRaises(WidgetError): + widget.get_context_data( + title='test-name', taxonomy='tax', periods=periods + ) + + def test_context_data_raises_widgeterror_when_date_is_not_parseable(self): + widget = TermCountChartWidget() + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [] + periods = [{ + 'start_time': '!!!', + 'end_time': '2015-02-02' + }, { + 'start-time': '2015-07-08', + 'end-time': '2015-07-09' + }] + with self.assertRaises(WidgetError): + widget.get_context_data( + title='test-name', taxonomy='tax', periods=periods + ) + + def test_get_context_data_parses_dates(self): + widget = TermCountChartWidget() + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + itemcount.return_value = [] + periods = [{ + 'start_time': '2015-01-01', + 'end_time': '2015-02-02' + }] + widget.get_context_data( + title='test-name', taxonomy='tax', periods=periods + ) + kwargs = itemcount.call_args[1] + + self.assertEqual(kwargs['start_time'], parser.parse('2015-01-01')) + self.assertEqual(kwargs['end_time'], parser.parse('2015-02-02')) + def test_chart_questions_are_set_as_yaxis_value_labels(self): widget = TermCountChartWidget() with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: @@ -93,7 +166,7 @@ class TestTermCountChartWidget(TestCase): 'count': 1000 }, ] - counts = widget._fetch_counts('tax', 0, 'Others') + counts = widget._fetch_counts('tax', 0, None, None, 'Others') self.assertEqual( counts.items(), @@ -127,7 +200,7 @@ class TestTermCountChartWidget(TestCase): }, ] - counts = widget._fetch_counts('tax', 3, 'Others') + counts = widget._fetch_counts('tax', 3, None, None, 'Others') self.assertEqual( counts.items(), @@ -136,3 +209,24 @@ class TestTermCountChartWidget(TestCase): ('Others', 11) ] ) + + def test_fetch_count_ignores_missing_start_and_end_time(self): + widget = TermCountChartWidget() + + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + widget._fetch_counts('tax', 3, None, None, 'Others') + itemcount_kwargs = itemcount.call_args[1] + + self.assertNotIn('start_time', itemcount_kwargs) + self.assertNotIn('end_time', itemcount_kwargs) + + def test_fetch_count_uses_start_and_end_time(self): + widget = TermCountChartWidget() + t1 = datetime.now() + t2 = t1 + timedelta(days=4) + with patch('hid.widgets.term_count_chart.term_itemcount') as itemcount: + widget._fetch_counts('tax', 3, t1, t2, 'Others') + itemcount_kwargs = itemcount.call_args[1] + + self.assertEqual(t1, itemcount_kwargs['start_time']) + self.assertEqual(t2, itemcount_kwargs['end_time']) diff --git a/django/website/hid/widgets/term_count_chart.py b/django/website/hid/widgets/term_count_chart.py index 136650decd0b1317935d007359e79fa3d054e0e3..864e9972b844464d335cb15a82703cde293f0ebe 100644 --- a/django/website/hid/widgets/term_count_chart.py +++ b/django/website/hid/widgets/term_count_chart.py @@ -1,4 +1,9 @@ from collections import OrderedDict +from dashboard.widget_pool import WidgetError +from datetime import timedelta +from dateutil import parser +from django.conf import settings +from django.utils import dateformat from django.utils.translation import ugettext_lazy as _ from transport.taxonomies import term_itemcount @@ -23,17 +28,27 @@ class TermCountChartWidget(object): 'hid/widgets/chart.js' ] - def _fetch_counts(self, taxonomy, count, other_label): + def _fetch_counts(self, taxonomy, count, start, end, 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 + taxonomy (str): Taxonomy slug + count (int): If >0, maximum number of rows to returns. If the data + has more terms, all other terms are aggregated under + an 'others' section + start (datetime or None): If not None, the start of the time period + to get the count for + end (datetime or None): If not None, the start of the time period + to get the count for + other_label (str): Label for the 'Others' section """ - itemcount = term_itemcount(taxonomy) + itemcount = None + if start is not None and end is not None: + itemcount = term_itemcount( + taxonomy, start_time=start, end_time=end + ) + else: + itemcount = term_itemcount(taxonomy) itemcount.sort(key=lambda k: int(k['count']), reverse=True) if count > 0: head = itemcount[0:count-1] @@ -72,13 +87,74 @@ class TermCountChartWidget(object): return values, yticks + def _create_date_range_label(self, start, end): + """ Create a label to display a date range. + + The dates are formatter such that: + - If either start or end include hours/minutes/seconds + that are not 00:00:00 then the full date time is + displayed; + - If both start and end have zero hours/minutes/seconds + then only the day is displayed, and the end day + is set to the previous day (to show an inclusive + range); + + Args: + start (datetime): Start date time + end (datetime): End date time + Returns: + str: Label to use for the date range. + """ + if not start.time() and not end.time(): + start_str = dateformat.format(start, + settings.SHORT_DATE_FORMAT) + end_str = dateformat.format(end - timedelta(days=1), + settings.SHORT_DATE_FORMAT) + else: + start_str = dateformat.format(start, + settings.SHORT_DATETIME_FORMAT), + end_str = dateformat.format(end, + settings.SHORT_DATETIME_FORMAT) + if start_str == end_str: + return _('%(date)s') % {'date': start_str} + else: + return _('%(start)s - %(end)s') % { + 'start': start_str, + 'end': end_str + } + 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') + periods = kwargs.get('periods', []) - counts = self._fetch_counts(taxonomy, count, other_label) + if len(periods) > 1: + raise WidgetError('Only one time period is currently supported') + if len(periods) == 1: + try: + start_time = parser.parse(periods[0]['start_time']) + end_time = parser.parse(periods[0]['end_time']) + except ValueError: + raise WidgetError('Error parsing start/end time') + legend = { + 'show': True, + 'noColumns': 1, + 'position': 'ne', + 'labelBoxBorderColor': 'white', + 'backgroundColor': 'white' + } + label = self._create_date_range_label(start_time, end_time) + else: + start_time = None + end_time = None + legend = {'show': False} + label = '' + + counts = self._fetch_counts( + taxonomy, count, start_time, end_time, other_label + ) (values, yticks) = self._create_axis_values(counts) return { 'title': title, @@ -117,7 +193,12 @@ class TermCountChartWidget(object): 'margin': 10, 'labelMargin': 20, 'backgroundColor': '#fafafa' - } + }, + 'legend': legend }, - 'data': [values] + 'data': [{ + 'label': label, + 'color': '#f29e30', + 'data': values + }] } diff --git a/django/website/media/less/internews.less b/django/website/media/less/internews.less index 4e34fa209b2a0c85f875595169ca2fa89da47e97..9bc352f153b0377deb70dd4c037db4011ae6e5b0 100644 --- a/django/website/media/less/internews.less +++ b/django/website/media/less/internews.less @@ -403,6 +403,11 @@ body { height: 100%; } +.flot-legend { + padding-bottom: @padding-small-vertical; + border-bottom: solid 1px @gray-lighter; +} + // Circle Buttons .btn-circle { diff --git a/django/website/rest_api/tests/categorize_items_tests.py b/django/website/rest_api/tests/categorize_items_tests.py index 91d467b43464fc204cfaedf249eb23263b33b119..d06392f0d3dc23123d0f90ba2290b2e6c3900bcb 100644 --- a/django/website/rest_api/tests/categorize_items_tests.py +++ b/django/website/rest_api/tests/categorize_items_tests.py @@ -70,6 +70,15 @@ def test_item_can_haz_category(term, item): # TODO test for terms with the same name in different taxonomies +@pytest.mark.django_db +def test_categorize_item_returns_the_categorized_item(term, item): + result = categorize_item(item, term).data + + assert result['id'] == item['id'] + terms = result['terms'] + assert term in terms + + @pytest.mark.django_db def test_categorize_item_fails_gracefully_if_term_not_found(item): response = categorize_item( @@ -81,6 +90,15 @@ def test_categorize_item_fails_gracefully_if_term_not_found(item): assert response.data['detail'] == "Term matching query does not exist." +@pytest.mark.django_db +def test_categorize_item_fails_gracefully_if_item_not_found(term, item): + unknown_item_id = 6 # I am not a prisoner + response = categorize_item({'id': unknown_item_id}, term) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.data['detail'] == "Message matching query does not exist." + + @pytest.mark.django_db def test_only_one_category_per_item_per_taxonomy(item, term, second_term): """ diff --git a/django/website/rest_api/views.py b/django/website/rest_api/views.py index 2ea8627f98554e13976f1145a0e8cd31d087a12f..f58b2910d48f15bf149d92168e8fe471ee50fa9c 100644 --- a/django/website/rest_api/views.py +++ b/django/website/rest_api/views.py @@ -37,7 +37,12 @@ class ItemViewSet(viewsets.ModelViewSet, BulkDestroyModelMixin): @detail_route(methods=['post']) def add_term(self, request, item_pk): - item = Item.objects.get(pk=item_pk) + try: + item = Item.objects.get(pk=item_pk) + except Item.DoesNotExist as e: + data = {'detail': e.message} + return Response(data, status=status.HTTP_404_NOT_FOUND) + term_data = request.data try: term = Term.objects.by_taxonomy( @@ -49,8 +54,9 @@ class ItemViewSet(viewsets.ModelViewSet, BulkDestroyModelMixin): return Response(data, status=status.HTTP_400_BAD_REQUEST) item.apply_term(term) - data = {} # TODO should be the item containing the new term - return Response(data, status=status.HTTP_200_OK) + + serializer = ItemSerializer(item) + return Response(serializer.data, status=status.HTTP_200_OK) class TaxonomyViewSet(viewsets.ModelViewSet): diff --git a/django/website/settings.py b/django/website/settings.py index 96f2f289b9e81a2e8dc5e925aec4b31d983ca6fc..286d53cb61cb22b856c28cc2de7bf6b85f16d26b 100644 --- a/django/website/settings.py +++ b/django/website/settings.py @@ -64,6 +64,7 @@ USE_L10N = True USE_TZ = True SHORT_DATETIME_FORMAT = 'd M Y H:i' +SHORT_DATE_FORMAT = 'd M Y' # TODO this is used in hid/tables.py # and should probably use FORMAT_MODULE_PATH instead.? diff --git a/django/website/taxonomies/models.py b/django/website/taxonomies/models.py index 5d50b06936790e18db0bff24cc2e71a2f7e33ff6..da8295e2aca041c95359430ab217f4bf2a191a81 100644 --- a/django/website/taxonomies/models.py +++ b/django/website/taxonomies/models.py @@ -82,7 +82,10 @@ class TermManager(models.Manager): raise ValueError( "taxonomy must be a Taxonomy instance " "or a valid taxonomy slug") - return self.get(taxonomy__slug=taxonomy_slug, name=name) + return self.select_related('taxonomy').get( + taxonomy__slug=taxonomy_slug, + name=name + ) class Term(models.Model): diff --git a/django/website/transport/items.py b/django/website/transport/items.py index 1bf54b00bc2adc0e53be8e573eade7dc6625f6a7..e7691947c442835c2efe4f4689fd77c13d38f8c5 100644 --- a/django/website/transport/items.py +++ b/django/website/transport/items.py @@ -113,4 +113,6 @@ def add_term(item_id, taxonomy_slug, name): return response.data else: response.data['status_code'] = response.status_code + response.data['term'] = term + response.data['item_id'] = item_id raise TransportException(response.data) diff --git a/django/website/transport/tests/item_add_term_tests.py b/django/website/transport/tests/item_add_term_tests.py index 1c189de16477b5ec06be65442bd5040c4f8904b6..0e877ecdd72457e0ad4c9c43a65b34ad51c67035 100644 --- a/django/website/transport/tests/item_add_term_tests.py +++ b/django/website/transport/tests/item_add_term_tests.py @@ -3,9 +3,7 @@ import pytest from data_layer.models import Item -from taxonomies.tests.factories import ( - TaxonomyFactory, - TermFactory) +from taxonomies.tests.factories import TermFactory from transport import items from ..exceptions import TransportException @@ -42,3 +40,19 @@ def test_add_term_fails_if_term_does_not_exist(item_data): assert error['status_code'] == 400 assert error['detail'] == "Term matching query does not exist." + assert error['term']['name'] == "unknown term name" + + +@pytest.mark.django_db +def test_add_term_fails_if_item_does_not_exist(): + with pytest.raises(TransportException) as excinfo: + term = TermFactory() + unknown_item_id = 6 # I am a Free Man + items.add_term(unknown_item_id, term.taxonomy.slug, term.name) + + error = excinfo.value.message + + assert error['status_code'] == 404 + assert error['detail'] == "Message matching query does not exist." + # TODO: assert error['detail'] == "Item matching query does not exist." + assert error['item_id'] == unknown_item_id