Skip to content
Snippets Groups Projects
Commit a1e12bf8 authored by Alice Heaton's avatar Alice Heaton :speech_balloon:
Browse files

Merge branch 'develop' into rest_api_test

parents 52011c9f 40c091f5
No related branches found
No related tags found
No related merge requests found
Showing
with 570 additions and 32 deletions
......@@ -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'
......
......@@ -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'
)
......@@ -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
......
# -*- 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,
),
]
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()
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])
......@@ -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 */
......
......@@ -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 %}
......@@ -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 }}'
>
......
......@@ -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')
......
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'])
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
}]
}
......@@ -403,6 +403,11 @@ body {
height: 100%;
}
.flot-legend {
padding-bottom: @padding-small-vertical;
border-bottom: solid 1px @gray-lighter;
}
// Circle Buttons
.btn-circle {
......
......@@ -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):
"""
......
......@@ -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):
......
......@@ -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.?
......
......@@ -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):
......
......@@ -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)
......@@ -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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment