diff --git a/deploy/pip_packages.txt b/deploy/pip_packages.txt index b7a0dc66..77766a27 100644 --- a/deploy/pip_packages.txt +++ b/deploy/pip_packages.txt @@ -27,6 +27,7 @@ django-parsley==0.0.2a0 django-waffle==0.9.1 Markdown==2.3.1 -e git+git://github.com/aptivate/aptivate-monkeypatch.git@ed4f821d8d57ba181c418b8d78dfd8bca70d6817#egg=aptivate_monkeypatch +decorators==0.1 # for search django-haystack==2.1.0 @@ -51,6 +52,6 @@ django-debug-toolbar==0.9.4 # TODO: remove when bug fix release is available # working around bug # https://bitbucket.org/logilab/astroid/issue/3/error-after-upgrade-to-pylint-10 -hg+https://bitbucket.org/logilab/astroid +astroid==1.2.1 # pylint 1.4 does not support python 2.6, so until we upgrade to 2.7 we need pylint<1.3 diff --git a/django/econsensus/publicweb/custom_decorators.py b/django/econsensus/publicweb/custom_decorators.py new file mode 100644 index 00000000..f541ded1 --- /dev/null +++ b/django/econsensus/publicweb/custom_decorators.py @@ -0,0 +1,20 @@ +from django.http import Http404 +from actionitems.models import ActionItem +import decorators + + +class is_org_member(decorators.FuncDecorator): + + def decorate_func(self, func, *args, **kwargs): + def wrapped_func(self, request, *args, **kwargs): + item = self.get_object() + if(isinstance(item, ActionItem)): + org = item.origin.organization + else: + org = item.organization + + if org in request.user.organization_set.all(): + return func(self, request, *args, **kwargs) + else: + raise Http404 + return wrapped_func diff --git a/django/econsensus/publicweb/single_action_views.py b/django/econsensus/publicweb/single_action_views.py new file mode 100644 index 00000000..809c7fd7 --- /dev/null +++ b/django/econsensus/publicweb/single_action_views.py @@ -0,0 +1,80 @@ +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.views.generic.base import View + +from actionitems.models import ActionItem +from guardian.mixins import LoginRequiredMixin +from notification import models as notification +from signals.management import DECISION_CHANGE + +from .custom_decorators import is_org_member +from .models import Decision + + +class BaseSingleActionView(LoginRequiredMixin, View): + """ SingleActionViews are views used to perform a single, simple + action such as marking an item as done. This used with a + redirection to provide quick, one-click actions that do + not require javascript. + + - Users need to provide a URL route to the single action view; + - Descendant classes should implement a single 'do_action' + method to perform the action; + - Requests are expected to contains a GET parameter 'next' then the + user will be redirected to the given URL. + """ + @is_org_member + def get(self, request, *args, **kwargs): + self.do_action() + return HttpResponseRedirect(request.GET['next']) + + +class BaseWatcherView(BaseSingleActionView): + """ Base single action view for add/remove watcher views """ + def get_object(self): + object_id = self.kwargs['decision_id'] + decision = get_object_or_404(Decision, pk=object_id) + return decision + + def get_user(self): + return self.request.user + + +class BaseActionItemView(BaseSingleActionView): + """ Base single action view for set/unset action item done views """ + def get_object(self): + object_id = self.kwargs['actionitem_id'] + actionitem = get_object_or_404(ActionItem, pk=object_id) + return actionitem + + +class AddWatcher(BaseWatcherView): + """ Single action view used to add a new watcher to a decision """ + def do_action(self): + decision = self.get_object() + user = self.get_user() + notification.observe(decision, user, DECISION_CHANGE) + + +class RemoveWatcher(BaseWatcherView): + """ Single action view used to remove a watcher from a decision """ + def do_action(self): + decision = self.get_object() + user = self.get_user() + notification.stop_observing(decision, user) + + +class SetActionItemDone(BaseActionItemView): + """ Single action view used to set an action item as done """ + def do_action(self): + actionitem = self.get_object() + actionitem.done = True + actionitem.save() + + +class UnsetActionItemDone(BaseActionItemView): + """ Single action view used to unset an action item's done status """ + def do_action(self): + actionitem = self.get_object() + actionitem.done = False + actionitem.save() diff --git a/django/econsensus/publicweb/templates/actionitem_detail_snippet.html b/django/econsensus/publicweb/templates/actionitem_detail_snippet.html index be668414..305c6430 100644 --- a/django/econsensus/publicweb/templates/actionitem_detail_snippet.html +++ b/django/econsensus/publicweb/templates/actionitem_detail_snippet.html @@ -26,6 +26,11 @@ {% else %}{% trans "No deadline" %} {% endif %} +
  • + + {% if object.done %}☑{% else %}☐{% endif %} {% trans "Done" %} + +
  • diff --git a/django/econsensus/publicweb/templates/decision_list.html b/django/econsensus/publicweb/templates/decision_list.html index bf0d2f3b..1afce5b8 100644 --- a/django/econsensus/publicweb/templates/decision_list.html +++ b/django/econsensus/publicweb/templates/decision_list.html @@ -63,13 +63,27 @@ {% for object in object_list %} - {% if tab != 'actionitems' %} + {% if tab == 'actionitems' %} + {% switch "actionitems" %} + {{ object.id }} + + + {% if object.done %}☑{% else %}☐{% endif %} + + + {{ object.description|get_excerpt }} + {{ object.responsible }} + {{ object.deadline }} + + {{ object.origin.id }} + {% endswitch %} + {% else %} {{ object.id }} - + {{ object.excerpt }} {% if tab == 'decision' %} {{ object.decided_date }} @@ -83,16 +97,6 @@ {{ object.last_modified|timesince }} ago {% endif %} {% endif %} - {% if tab == 'actionitems' %} - {% switch "actionitems" %} - {{ object.id }} - {{ object.description|get_excerpt }} - {{ object.responsible }} - {{ object.deadline }} - - {{ object.origin.id }} - {% endswitch %} - {% endif %} {% endfor %} diff --git a/django/econsensus/publicweb/tests/actionitems_view_test.py b/django/econsensus/publicweb/tests/actionitems_view_test.py index a3d618a2..bbfea6d4 100644 --- a/django/econsensus/publicweb/tests/actionitems_view_test.py +++ b/django/econsensus/publicweb/tests/actionitems_view_test.py @@ -1,7 +1,9 @@ -from django.utils.unittest import TestCase -from django.test.client import RequestFactory +from django.contrib.auth.models import User, AnonymousUser from django.core.urlresolvers import reverse, resolve -from django.contrib.auth.models import User +from django.http import Http404 +from django.test.client import RequestFactory +from django.utils.unittest import TestCase + from BeautifulSoup import BeautifulSoup from waffle import Switch @@ -11,9 +13,12 @@ from publicweb.forms import (EconsensusActionItemCreateForm, EconsensusActionItemUpdateForm) +from publicweb.single_action_views import SetActionItemDone, UnsetActionItemDone from publicweb.views import (EconsensusActionitemCreateView, EconsensusActionitemUpdateView, EconsensusActionitemListView) +from django_dynamic_fixture import G +from publicweb.models import Decision class ActionitemsViewTestFast(TestCase): @@ -221,3 +226,109 @@ def test_list_wrongorg(self): response.render() soup = BeautifulSoup(str(response.content)) assert soup.find("p", {"class": "wrongorg"}) + + def test_set_done_view_requires_user_to_be_logged_in(self): + actionitem = ActionItem.objects.create() + user = AnonymousUser() + + view = SetActionItemDone() + view.get_object = lambda: actionitem + + request = RequestFactory().get('/', {'next': reverse('actionitem_list', args=[self.bettysorg.slug])}) + request.user = user + + response = view.dispatch(request) + + self.assertEqual('/accounts/login/?next=/%3Fnext%3D%252Fcountry-critters%252Factionitem%252Flist%252F', response['Location']) + + def test_remove_watcher_view_requires_user_to_be_logged_in(self): + actionitem = ActionItem.objects.create() + user = AnonymousUser() + + view = UnsetActionItemDone() + view.get_object = lambda: actionitem + + request = RequestFactory().get('/', {'next': reverse('actionitem_list', args=[self.bettysorg.slug])}) + request.user = user + + response = view.dispatch(request) + + self.assertEqual('/accounts/login/?next=/%3Fnext%3D%252Fcountry-critters%252Factionitem%252Flist%252F', response['Location']) + + def test_action_item_set_done_sets_item_done(self): + decision = G(Decision, organization=self.bettysorg) + actionitem = ActionItem.objects.create(origin=decision) + user = self.betty + + view = SetActionItemDone() + view.get_object = lambda: actionitem + + request = RequestFactory().get('/', {'next': reverse('actionitem_list', args=[self.bettysorg.slug])}) + request.user = user + + view.dispatch(request) + + self.assertTrue(actionitem.done) + + def test_action_item_unset_done_sets_item_not_done(self): + decision = G(Decision, organization=self.bettysorg) + actionitem = ActionItem.objects.create(done=True, origin=decision) + user = self.betty + + view = UnsetActionItemDone() + view.get_object = lambda: actionitem + + request = RequestFactory().get('/', {'next': reverse('actionitem_list', args=[self.bettysorg.slug])}) + request.user = user + + view.dispatch(request, actionitem_id=actionitem.id) + + self.assertFalse(actionitem.done) + + def test_action_item_set_done_for_non_existant_item_returns_404(self): + decision = G(Decision, organization=self.bettysorg) + actionitem = ActionItem.objects.create(origin=decision) + user = self.betty + + view = SetActionItemDone() + + request = RequestFactory().get('/', {'next': reverse('actionitem_list', args=[self.bettysorg.slug])}) + request.user = user + + self.assertRaises(Http404, view.dispatch, request, actionitem_id=actionitem.id + 1) + + def test_action_item_unset_done_for_non_existant_item_returns_404(self): + decision = G(Decision, organization=self.bettysorg) + actionitem = ActionItem.objects.create(origin=decision) + user = self.betty + + view = UnsetActionItemDone() + + request = RequestFactory().get('/', {'next': reverse('actionitem_list', args=[self.bettysorg.slug])}) + request.user = user + + self.assertRaises(Http404, view.dispatch, request, actionitem_id=actionitem.id + 1) + + def test_action_item_set_done_for_item_for_different_org_returns_404(self): + decision = G(Decision) + actionitem = ActionItem.objects.create(origin=decision) + user = self.betty + + view = SetActionItemDone() + + request = RequestFactory().get('/', {'next': reverse('actionitem_list', args=[self.bettysorg.slug])}) + request.user = user + + self.assertRaises(Http404, view.dispatch, request, actionitem_id=actionitem.id + 1) + + def test_action_item_unset_done_for_item_for_different_org_returns_404(self): + decision = G(Decision) + actionitem = ActionItem.objects.create(origin=decision) + user = self.betty + + view = UnsetActionItemDone() + + request = RequestFactory().get('/', {'next': reverse('actionitem_list', args=[self.bettysorg.slug])}) + request.user = user + + self.assertRaises(Http404, view.dispatch, request, actionitem_id=actionitem.id + 1) diff --git a/django/econsensus/publicweb/tests/watcher_view_test.py b/django/econsensus/publicweb/tests/watcher_view_test.py index 785a064a..07ac1aec 100644 --- a/django/econsensus/publicweb/tests/watcher_view_test.py +++ b/django/econsensus/publicweb/tests/watcher_view_test.py @@ -1,37 +1,45 @@ -from django.contrib.auth.models import User +from django.contrib.auth.models import User, AnonymousUser +from django.http import Http404 from django.test.client import RequestFactory from django.test.testcases import SimpleTestCase + from django_dynamic_fixture import N -from mock import patch, MagicMock +from mock import patch, Mock +from organizations.models import Organization from signals.management import DECISION_CHANGE +# For some reason using relative imports throws an error from publicweb.models import Decision -from publicweb.views import AddWatcher, RemoveWatcher +from publicweb.single_action_views import AddWatcher, RemoveWatcher -class WatcherViewTests(SimpleTestCase): +class WatcherViewTest(SimpleTestCase): - @patch('publicweb.views.notification') + @patch('publicweb.single_action_views.notification') def test_add_watcher_view_adds_observer_to_item(self, notifications): - decision = N(Decision) - user = N(User) + organization = N(Organization) + decision = N(Decision, organization=organization) + user = Mock(spec=User, id=1, organization_set=Mock(all=lambda: [organization])) + + view = AddWatcher() + view.get_object = lambda: decision + + request = RequestFactory().get('/', {'next': '/'}) + request.user = user - mock_view = MagicMock(spec=AddWatcher) - mock_view.get_object = lambda: decision - mock_view.get_user = lambda: user - mock_view.get = AddWatcher.get + view.request = request + view.get(request) - mock_view.get(mock_view, RequestFactory().get('/', {'next': '/'})) notifications.observe.assert_called_with( decision, user, DECISION_CHANGE) - @patch("publicweb.views.Decision.objects") + @patch("publicweb.single_action_views.get_object_or_404") def test_get_object_tries_to_get_decision(self, decisions): view = AddWatcher() view.args = [] view.kwargs = {'decision_id': 1} view.get_object() - decisions.get.assert_called_with(pk=1) + decisions.assert_called_with(Decision, pk=1) def test_get_user_looks_for_user_in_request(self): user = N(User) @@ -42,15 +50,85 @@ def test_get_user_looks_for_user_in_request(self): self.assertEqual(user, view.get_user()) - @patch('publicweb.views.notification') + @patch('publicweb.single_action_views.notification') def test_remove_watcher_view_removes_observer_from_item(self, notifications): + organization = N(Organization) + decision = N(Decision, organization=organization) + user = Mock(spec=User, id=1, organization_set=Mock(all=lambda: [organization])) + + view = RemoveWatcher() + view.get_object = lambda: decision + + request = RequestFactory().get('/', {'next': '/'}) + request.user = user + + view.request = request + view.get(request) + notifications.stop_observing.assert_called_with(decision, user) + + def test_add_watcher_view_requires_user_to_be_logged_in(self): + decision = N(Decision) + user = AnonymousUser() + + view = AddWatcher() + view.get_object = lambda: decision + + request = RequestFactory().get('/', {'next': '/'}) + request.user = user + + response = view.dispatch(request) + + self.assertEqual('/accounts/login/?next=/%3Fnext%3D%252F', response['Location']) + + def test_remove_watcher_view_requires_user_to_be_logged_in(self): decision = N(Decision) + user = AnonymousUser() + + view = RemoveWatcher() + view.get_object = lambda: decision + + request = RequestFactory().get('/', {'next': '/'}) + request.user = user + + response = view.dispatch(request) + + self.assertEqual('/accounts/login/?next=/%3Fnext%3D%252F', response['Location']) + + def test_add_watcher_for_non_existant_item_returns_404(self): + Decision.objects.all().delete() user = N(User) - mock_view = MagicMock(spec=RemoveWatcher) - mock_view.get_object = lambda: decision - mock_view.get_user = lambda: user - mock_view.get = RemoveWatcher.get + view = AddWatcher() + + request = RequestFactory().get('/', {'next': '/'}) + request.user = user + + self.assertRaises(Http404, view.dispatch, request, decision_id=1) + + def test_add_watcher_for_item_from_different_org_returns_404(self): + Decision.objects.all().delete() + organization = N(Organization) + user = Mock(spec=User, id=1, organization_set=Mock(all=lambda: [organization])) + decision = N(Decision) + + view = AddWatcher() + view.get_object = lambda: decision + + request = RequestFactory().get('/', {'next': '/'}) + request.user = user + + self.assertRaises(Http404, view.dispatch, request, decision_id=1) + + def test_remove_watcher_for_item_from_different_org_returns_404(self): + organization = N(Organization) + user = Mock(spec=User, id=1, organization_set=Mock(all=lambda: [organization])) + decision = N(Decision) + + view = RemoveWatcher() + view.get_object = lambda: decision + + request = RequestFactory().get('/', {'next': '/'}) + request.user = user + + self.assertRaises(Http404, view.dispatch, request, decision_id=1) - mock_view.get(mock_view, RequestFactory().get('/', {'next': '/'})) - notifications.stop_observing.assert_called_with(decision, user) diff --git a/django/econsensus/publicweb/urls.py b/django/econsensus/publicweb/urls.py index e960a3be..09d34e17 100644 --- a/django/econsensus/publicweb/urls.py +++ b/django/econsensus/publicweb/urls.py @@ -11,7 +11,9 @@ EconsensusActionitemDetailView, DecisionSearchView) from models import Feedback -from publicweb.views import AddWatcher, RemoveWatcher +from publicweb.single_action_views import (AddWatcher, RemoveWatcher, + SetActionItemDone, + UnsetActionItemDone) urlpatterns = patterns('econsensus.publicweb.views', @@ -20,10 +22,16 @@ name='your_details'), url(r'^user_settings/notification_settings/(?P[-\w]+)/$', UserNotificationSettings.as_view(), name='notification_settings'), + + # Single action urls url(r'^add_watcher/(?P\d+)/$', AddWatcher.as_view(), name="add_watcher"), url(r'^remove_watcher/(?P\d+)/$', RemoveWatcher.as_view(), - name="remove_watcher"), + name="remove_watcher"), + url(r'^set_actionitem_done/(?P\d+)/$', SetActionItemDone.as_view(), + name="set_actionitem_done"), + url(r'^unset_actionitem_done/(?P\d+)/$', UnsetActionItemDone.as_view(), + name="unset_actionitem_done"), url(r'^(?P[-\w]+)/export_csv/$', ExportCSV.as_view(), diff --git a/django/econsensus/publicweb/views.py b/django/econsensus/publicweb/views.py index c4d493ce..71e17c1a 100644 --- a/django/econsensus/publicweb/views.py +++ b/django/econsensus/publicweb/views.py @@ -944,29 +944,3 @@ def make(cls): def search_view(request, *args, **kwargs): return cls()(request, *args, **kwargs) return login_required(search_view) - - -class BaseWatcherView(View): - def get_object(self): - object_id = self.kwargs['decision_id'] - decision = Decision.objects.get(pk=object_id) - return decision - - def get_user(self): - return self.request.user - - -class AddWatcher(BaseWatcherView): - def get(self, request, *args, **kwargs): - decision = self.get_object() - user = self.get_user() - notification.observe(decision, user, DECISION_CHANGE) - return HttpResponseRedirect(request.GET['next']) - - -class RemoveWatcher(BaseWatcherView): - def get(self, request, *args, **kwargs): - decision = self.get_object() - user = self.get_user() - notification.stop_observing(decision, user) - return HttpResponseRedirect(request.GET['next'])