diff --git a/casepro/pods/base.py b/casepro/pods/base.py
index 548259ed0..8718c0997 100644
--- a/casepro/pods/base.py
+++ b/casepro/pods/base.py
@@ -32,12 +32,59 @@ def config_json(self):
return json.dumps(self.config._config_data)
def read_data(self, params):
- """Should return the data that should be used to create the display for the pod."""
+ """
+ Should return the data that should be used to create the display for the pod.
+
+ For the base implementation, the data should be an object with 'items' and 'actions' keys.
+
+ The items key should be a list of objects, that have 'name' and 'value' keys, with the value of the keys being
+ what will be displayed.
+
+ The 'actions' key should be a list of objects, that have 'type', 'name' and 'payload' keys, where type and
+ payload is what is sent to the 'perform_action' function to determine which button has been pressed, and 'name'
+ is the text that is displayed on the button.
+
+ Each action may include the following optional fields:
+ - ``busy_text``: used as the action's corresponding
+ button's text while waiting on a response from the pod's api side
+ when the action is triggered. Defaults to the value of the ``name``
+ field.
+ - ``confirm``: whether a confirmation modal should be shown to
+ confirm whether the user would like to perform the action. Defaults
+ to ``false``.
+
+ Example:
+ {
+ 'items': [
+ {
+ 'name': 'EDD',
+ 'value': '2015-07-18',
+ },
+ ],
+ 'actions': [
+ {
+ 'type': 'remove_edd',
+ 'name': 'Remove EDD',
+ 'payload': {},
+ 'busy_text': 'Removing EDD',
+ 'confirm': True
+ },
+ ],
+ }
+ """
return {}
- def perform_action(self, params):
- """Should perform the action specified by params."""
- return {}
+ def perform_action(self, type_, params):
+ """
+ Should perform the action specified by the type and params (which are specified in the read function).
+
+ Returns a tuple (success, payload), where 'success' is a boolean value indicating whether the action was
+ successful or not. If true, a case action note will be created.
+
+ For the base implementation, payload is an object with a 'message' key, which is the error message if success
+ is false, or the message to place in the case action note if success is true.
+ """
+ return (False, {'message': ''})
class PodPlugin(AppConfig):
diff --git a/casepro/pods/tests/test_views.py b/casepro/pods/tests/test_views.py
index 51827926c..4eb4290f8 100644
--- a/casepro/pods/tests/test_views.py
+++ b/casepro/pods/tests/test_views.py
@@ -3,6 +3,7 @@
from django.core.urlresolvers import reverse
from django.test import modify_settings
+from casepro.cases.models import CaseAction
from casepro.test import BaseCasesTest
@@ -13,6 +14,17 @@ class ViewPodDataView(BaseCasesTest):
"""
Tests relating to the view_pod_data view.
"""
+ def setUp(self):
+ super(ViewPodDataView, self).setUp()
+ contact = self.create_contact(self.unicef, 'contact-uuid', 'contact_name')
+ msg = self.create_message(self.unicef, 0, contact, 'Test message')
+ self.case = self.create_case(self.unicef, contact, self.moh, msg)
+ self.login(self.admin)
+
+ with self.settings(PODS=[{'label': 'base_pod'}]):
+ from casepro.pods import registry
+ reload(registry)
+
def test_invalid_method(self):
"""
If the request method is not GET, an appropriate error should be
@@ -30,11 +42,9 @@ def test_pod_doesnt_exist(self):
If the requested pod id is invalid, an appropriate 404 error should be
returned.
"""
- with self.settings(PODS=[]):
- from casepro.pods import registry
- reload(registry)
response = self.url_get(
- 'unicef', reverse('read_pod_data', args=('0',)))
+ 'unicef', reverse('read_pod_data', args=('1',)), params={'case_id': self.case.id})
+
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json, {
'reason': 'Pod does not exist'}
@@ -44,14 +54,53 @@ def test_pod_valid_request(self):
"""
If it is a valid get request, the data from the pod should be returned.
"""
- with self.settings(PODS=[{'label': 'base_pod'}]):
- from casepro.pods import registry
- reload(registry)
response = self.url_get(
- 'unicef', reverse('read_pod_data', args=('0',)))
+ 'unicef', reverse('read_pod_data', args=('0',)), params={'case_id': self.case.id})
+
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {})
+ def test_case_id_required(self):
+ '''
+ If the case id is not present in the request, an error response should be returned.
+ '''
+ response = self.url_get(
+ 'unicef', reverse('read_pod_data', args=('0',)))
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json, {
+ 'reason': 'Request needs "case_id" query parameter'
+ })
+
+ def test_case_not_found(self):
+ '''
+ If the case is not found, an error response should be returned.
+ '''
+ response = self.url_get(
+ 'unicef', reverse('read_pod_data', args=('0',)),
+ params={'case_id': 23})
+
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response.json, {
+ 'reason': 'Case with id 23 not found'
+ })
+
+ def test_unauthorized(self):
+ '''
+ If the user does not have read permission, the request should be denied.
+ '''
+ self.login(self.user4)
+
+ response = self.url_get(
+ 'unicef', reverse('read_pod_data', args=('0',)),
+ params={'case_id': self.case.id})
+
+ self.assertEqual(response.status_code, 403)
+ self.assertEqual(response.json, {
+ 'reason': (
+ "The request's authentication details do not corresond "
+ "to the required access level for accessing this resource")
+ })
+
@modify_settings(INSTALLED_APPS={
'append': 'casepro.pods.PodPlugin',
@@ -60,6 +109,19 @@ class PerformPodActionView(BaseCasesTest):
"""
Tests relating to the perform_pod_action view.
"""
+ def setUp(self):
+ super(PerformPodActionView, self).setUp()
+ contact = self.create_contact(self.unicef, 'contact-uuid', 'contact_name')
+ msg = self.create_message(self.unicef, 0, contact, 'Test message')
+ self.case = self.create_case(self.unicef, contact, self.moh, msg)
+ self.login(self.admin)
+
+ CaseAction.objects.all().delete()
+
+ with self.settings(PODS=[{'label': 'base_pod'}]):
+ from casepro.pods import registry
+ reload(registry)
+
def test_invalid_method(self):
"""
If the request method is not POST, an appropriate error should be
@@ -77,11 +139,8 @@ def test_pod_doesnt_exist(self):
If the requested pod id is invalid, an appropriate 404 error should be
returned.
"""
- with self.settings(PODS=[]):
- from casepro.pods import registry
- reload(registry)
response = self.url_post_json(
- 'unicef', reverse('perform_pod_action', args=('0',)), {})
+ 'unicef', reverse('perform_pod_action', args=('23',)), {'case_id': self.case.id})
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json, {
'reason': 'Pod does not exist'}
@@ -100,14 +159,103 @@ def test_invalid_json(self):
'details': 'No JSON object could be decoded'
})
+ def test_case_id_required(self):
+ '''
+ If the case id is not present in the request, an error response should be returned.
+ '''
+ response = self.url_post_json(
+ 'unicef', reverse('perform_pod_action', args=('0',)), {})
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json, {
+ 'reason': 'Request object needs to have a "case_id" field'
+ })
+
def test_pod_valid_request(self):
"""
If it is a valid post request, the action should be performed.
"""
- with self.settings(PODS=[{'label': 'base_pod'}]):
+ response = self.url_post_json(
+ 'unicef', reverse('perform_pod_action', args=('0',)), {'case_id': self.case.id})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json, {
+ 'success': False,
+ 'payload': {
+ 'message': '',
+ }
+ })
+
+ @modify_settings(INSTALLED_APPS={
+ 'append': 'casepro.pods.tests.utils.SuccessActionPlugin',
+ })
+ def test_case_action_note_created_on_successful_action(self):
+ '''
+ If the action is successful, a case action note should be created.
+ '''
+ with self.settings(PODS=[{'label': 'success_pod'}]):
from casepro.pods import registry
reload(registry)
+
response = self.url_post_json(
- 'unicef', reverse('perform_pod_action', args=('0',)), {})
+ 'unicef', reverse('perform_pod_action', args=('0',)), {
+ 'case_id': self.case.id,
+ 'action': {
+ 'type': 'foo',
+ 'payload': {'foo': 'bar'},
+ },
+ })
+
self.assertEqual(response.status_code, 200)
- self.assertEqual(response.json, {})
+
+ message = "Type foo Params {u'foo': u'bar'}"
+ self.assertEqual(response.json, {
+ 'success': True,
+ 'payload': {
+ 'message': message
+ }
+ })
+
+ [caseaction] = CaseAction.objects.all()
+ self.assertEqual(
+ caseaction.note,
+ "%s %s" % (self.admin.username, message))
+
+ def test_case_not_found(self):
+ '''
+ If the case is not found, an error response should be returned.
+ '''
+
+ response = self.url_post_json(
+ 'unicef', reverse('perform_pod_action', args=('0',)), {
+ 'case_id': 23,
+ 'action': {
+ 'type': 'foo',
+ 'payload': {'foo': 'bar'},
+ },
+ })
+
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response.json, {
+ 'reason': 'Case with id 23 not found'
+ })
+
+ def test_unauthorized(self):
+ '''
+ If the user does not have update permission, the request should be denied.
+ '''
+ self.login(self.user4)
+
+ response = self.url_post_json(
+ 'unicef', reverse('perform_pod_action', args=('0',)), {
+ 'case_id': self.case.id,
+ 'action': {
+ 'type': 'foo',
+ 'payload': {'foo': 'bar'},
+ },
+ })
+
+ self.assertEqual(response.status_code, 403)
+ self.assertEqual(response.json, {
+ 'reason': (
+ "The request's authentication details do not corresond "
+ "to the required access level for accessing this resource")
+ })
diff --git a/casepro/pods/tests/utils.py b/casepro/pods/tests/utils.py
index a2faefaa0..faac5e783 100644
--- a/casepro/pods/tests/utils.py
+++ b/casepro/pods/tests/utils.py
@@ -16,3 +16,16 @@ class DummyPodPlugin(PodPlugin):
directive = 'dummy-pod'
scripts = ('dummy-script.js',)
styles = ('dummy-style.css',)
+
+
+class SuccessActionPod(Pod):
+ def perform_action(self, type_, params):
+ '''
+ Returns a successful action result with a message containing the type and params.
+ '''
+ return (True, {'message': 'Type %s Params %r' % (type_, params)})
+
+
+class SuccessActionPlugin(DummyPodPlugin):
+ pod_class = SuccessActionPod
+ label = 'success_pod'
diff --git a/casepro/pods/views.py b/casepro/pods/views.py
index aa16dab86..a4902595d 100644
--- a/casepro/pods/views.py
+++ b/casepro/pods/views.py
@@ -3,14 +3,30 @@
import json
from django.http import JsonResponse
+from casepro.cases.models import Case, CaseAction, AccessLevel
from casepro.pods import registry
+ACTION_NOTE_CONTENT = "%(username)s %(message)s"
+
def read_pod_data(request, index):
"""Delegates to the `read_data` function of the correct pod."""
if request.method != 'GET':
return JsonResponse({'reason': 'Method not allowed'}, status=405)
+ case_id = request.GET.get('case_id')
+ if case_id is None:
+ return JsonResponse(status=400, data={
+ 'reason': 'Request needs "case_id" query parameter'
+ })
+
+ case = get_case(case_id)
+ if case is None:
+ return case_not_found_response(case_id)
+
+ if not has_case_access(request.user, case, AccessLevel.read):
+ return authorization_failure_response()
+
try:
pod = registry.pods[int(index)]
except IndexError:
@@ -20,7 +36,10 @@ def read_pod_data(request, index):
def perform_pod_action(request, index):
- """Deletegates to the `perform_action` function of the correct pod."""
+ """
+ Delegates to the `perform_action` function of the correct pod. If the action completes successfully, a new case
+ action note is created with the success message.
+ """
if request.method != 'POST':
return JsonResponse({'reason': 'Method not allowed'}, status=405)
@@ -34,4 +53,47 @@ def perform_pod_action(request, index):
except ValueError as e:
return JsonResponse({'reason': 'JSON decode error', 'details': e.message}, status=400)
- return JsonResponse(pod.perform_action(data))
+ case_id = data.get('case_id')
+ if case_id is None:
+ return JsonResponse(
+ {'reason': 'Request object needs to have a "case_id" field'}, status=400)
+
+ case = get_case(case_id)
+ if case is None:
+ return case_not_found_response(case_id)
+
+ if not has_case_access(request.user, case, AccessLevel.update):
+ return authorization_failure_response()
+
+ action_data = data.get('action', {})
+ success, payload = pod.perform_action(action_data.get('type'), action_data.get('payload', {}))
+ if success is True:
+ note = ACTION_NOTE_CONTENT % {
+ 'username': request.user.username,
+ 'message': payload.get('message'),
+ }
+ CaseAction.create(case, request.user, CaseAction.ADD_NOTE, note=note)
+
+ return JsonResponse({'success': success, 'payload': payload})
+
+
+def has_case_access(user, case, level):
+ return case.access_level(user) >= level
+
+
+def get_case(case_id):
+ return Case.objects.filter(id=case_id).first()
+
+
+def case_not_found_response(id):
+ return JsonResponse(status=404, data={
+ 'reason': "Case with id %s not found" % id
+ })
+
+
+def authorization_failure_response():
+ return JsonResponse(status=403, data={
+ 'reason': (
+ "The request's authentication details do not corresond "
+ "to the required access level for accessing this resource")
+ })
diff --git a/karma.conf.coffee b/karma.conf.coffee
index f375ce0ec..fe5763ec9 100644
--- a/karma.conf.coffee
+++ b/karma.conf.coffee
@@ -22,6 +22,7 @@ module.exports = (config) ->
# templates
'static/templates/**/*.html',
+ 'karma/templates/**/*.html',
# the code we are testing
'static/coffee/*.coffee',
@@ -47,6 +48,7 @@ module.exports = (config) ->
'**/*.coffee': ['coffee'],
'static/**/*.coffee': ['coverage']
'static/templates/**/*.html': ['ng-html2js']
+ 'karma/templates/**/*.html': ['ng-html2js']
}
# this makes sure that we get coffeescript line numbers instead
@@ -59,8 +61,8 @@ module.exports = (config) ->
path.replace /\.js$/, '.coffee'
ngHtml2JsPreprocessor:
- stripPrefix: 'static'
- prependPrefix: '/sitestatic'
+ stripPrefix: 'static/'
+ prependPrefix: '/sitestatic/'
moduleName: 'templates'
# test results reporter to use
diff --git a/karma/templates/alerts/dummy-alert.html b/karma/templates/alerts/dummy-alert.html
new file mode 100644
index 000000000..047843b9e
--- /dev/null
+++ b/karma/templates/alerts/dummy-alert.html
@@ -0,0 +1,3 @@
+
+ [[ alert.context.message ]]
+
diff --git a/karma/templates/modals/dummy-confirm.html b/karma/templates/modals/dummy-confirm.html
new file mode 100644
index 000000000..384cb1abb
--- /dev/null
+++ b/karma/templates/modals/dummy-confirm.html
@@ -0,0 +1,12 @@
+
diff --git a/static/templates/alerts.html b/static/templates/alerts.html
new file mode 100644
index 000000000..9e75889ca
--- /dev/null
+++ b/static/templates/alerts.html
@@ -0,0 +1,9 @@
+
+
+ [[ alert.message ]]
+
+
+
+
+
+
diff --git a/static/templates/alerts/pod-action-api-failure.html b/static/templates/alerts/pod-action-api-failure.html
new file mode 100644
index 000000000..6f568104c
--- /dev/null
+++ b/static/templates/alerts/pod-action-api-failure.html
@@ -0,0 +1,3 @@
+
+ Sorry, we encountered a problem on our side while completing your action.
+
diff --git a/static/templates/alerts/pod-action-failure.html b/static/templates/alerts/pod-action-failure.html
new file mode 100644
index 000000000..572f88408
--- /dev/null
+++ b/static/templates/alerts/pod-action-failure.html
@@ -0,0 +1,3 @@
+
+ [[ alert.context.message ]]
+
diff --git a/static/templates/alerts/pod-load-api-failure.html b/static/templates/alerts/pod-load-api-failure.html
new file mode 100644
index 000000000..fef164b05
--- /dev/null
+++ b/static/templates/alerts/pod-load-api-failure.html
@@ -0,0 +1,3 @@
+
+ Sorry, a problem on our side prevented some of the information on this page from loading.
+
diff --git a/static/templates/case-confirm-modals.html b/static/templates/case-confirm-modals.html
new file mode 100644
index 000000000..0cbe64a22
--- /dev/null
+++ b/static/templates/case-confirm-modals.html
@@ -0,0 +1,16 @@
+
+
+
+
[[ payload.name ]]
+
+
+
+ Are you sure you want to perform this action?
+
+
+
+
+
diff --git a/static/templates/modals/confirm.html b/static/templates/modals/confirm.html
new file mode 100644
index 000000000..18cc2f10f
--- /dev/null
+++ b/static/templates/modals/confirm.html
@@ -0,0 +1,14 @@
+
+
+
[[ title ]]
+
+
+
+ [[ prompt ]]
+
+
+
+
diff --git a/static/templates/modals/pod-action-confirm.html b/static/templates/modals/pod-action-confirm.html
new file mode 100644
index 000000000..122d5d222
--- /dev/null
+++ b/static/templates/modals/pod-action-confirm.html
@@ -0,0 +1,12 @@
+