From f67ea61de6b028db41b0432dd221e1023d551e7c Mon Sep 17 00:00:00 2001 From: Mike Cooper Date: Thu, 27 Dec 2012 17:09:31 -0800 Subject: [PATCH] [Bug 790798] Front end for locale announcements. Users that have permission can create locale announcements from the locale dashboard. --- apps/announcements/forms.py | 22 +++++ apps/announcements/tests/test_views.py | 98 +++++++++++++++++++ apps/announcements/urls.py | 9 ++ apps/announcements/views.py | 55 +++++++++++ .../templates/dashboards/localization.html | 33 ++++++- apps/dashboards/tests/test_views.py | 44 ++++++++- apps/dashboards/utils.py | 2 + apps/dashboards/views.py | 14 ++- media/js/dashboards.js | 64 +++++++++++- media/less/kbdashboards.less | 80 +++++++++++++++ urls.py | 1 + 11 files changed, 416 insertions(+), 6 deletions(-) create mode 100644 apps/announcements/forms.py create mode 100644 apps/announcements/tests/test_views.py create mode 100644 apps/announcements/urls.py create mode 100644 apps/announcements/views.py diff --git a/apps/announcements/forms.py b/apps/announcements/forms.py new file mode 100644 index 00000000000..40ba04b5e15 --- /dev/null +++ b/apps/announcements/forms.py @@ -0,0 +1,22 @@ +from datetime import date + +from django import forms + +from tower import ugettext as _ + + +class AnnouncementForm(forms.Form): + """Form for collecting information about an announcement. + + This is not a ModelForm, and does not include the group or locale fields, + because it should only be used in a context where the group or locale is + implicit, and should not be user controllable. If you need a user + controllable locale or group, use the admin interface. + + """ + content = forms.CharField(label=_('Content'), max_length=10000, + widget=forms.Textarea) + show_after = forms.DateField(label=_('Show after'), initial=date.today, + input_formats=['%Y-%m-%d']) + show_until = forms.DateField(label=_('Show until'), required=False, + input_formats=['%Y-%m-%d']) diff --git a/apps/announcements/tests/test_views.py b/apps/announcements/tests/test_views.py new file mode 100644 index 00000000000..2af2c8fd63e --- /dev/null +++ b/apps/announcements/tests/test_views.py @@ -0,0 +1,98 @@ +from datetime import datetime + +from nose.tools import eq_ + +from announcements.models import Announcement +from announcements.tests import announcement +from sumo.tests import TestCase +from sumo.urlresolvers import reverse +from users.tests import user, add_permission +from wiki.tests import locale + + +class TestCreateLocaleAnnouncement(TestCase): + + def setUp(self): + self.locale = locale(save=True, locale='es') + + def _create_test(self, status, count): + """Login, or other setup, then call this.""" + url = reverse('announcements.create_for_locale', locale='es') + resp = self.client.post(url, { + 'content': 'Look at me!', + 'show_after': '2012-01-01', + }) + eq_(resp.status_code, status) + eq_(Announcement.objects.count(), count) + + def test_create(self): + u = user(save=True, is_superuser=1) + self.client.login(username=u.username, password='testpass') + self._create_test(200, 1) + + def test_leader(self): + u = user(save=True) + self.locale.leaders.add(u) + self.locale.save() + self.client.login(username=u.username, password='testpass') + self._create_test(200, 1) + + def test_has_permission(self): + u = user(save=True) + add_permission(u, Announcement, 'add_announcement') + self.client.login(username=u.username, password='testpass') + self._create_test(200, 1) + + def test_no_perms(self): + u = user(save=True) + self.client.login(username=u.username, password='testpass') + self._create_test(403, 0) + + def test_anon(self): + self._create_test(302, 0) + + +class TestDeleteAnnouncement(TestCase): + + def setUp(self): + self.locale = locale(save=True, locale='es') + + self.u = user(save=True) + + self.locale.leaders.add(self.u) + self.locale.save() + + self.announcement = announcement(save=True, creator=self.u, + locale=self.locale, content="Look at me!", + show_after=datetime(2012, 01, 01, 0, 0, 0)) + + def _delete_test(self, id, status, count): + """Login, or other setup, then call this.""" + url = reverse('announcements.delete', locale='es', args=(id,)) + resp = self.client.post(url) + eq_(resp.status_code, status) + eq_(Announcement.objects.count(), count) + + def test_delete(self): + u = user(save=True, is_superuser=1) + self.client.login(username=u.username, password='testpass') + self._delete_test(self.announcement.id, 204, 0) + + def test_leader(self): + # Use the user that was created in setUp. + self.client.login(username=self.u.username, password='testpass') + self._delete_test(self.announcement.id, 204, 0) + + def test_has_permission(self): + u = user(save=True) + add_permission(u, Announcement, 'add_announcement') + self.client.login(username=u.username, password='testpass') + self._delete_test(self.announcement.id, 204, 0) + + def test_no_perms(self): + u = user(save=True) + self.client.login(username=u.username, password='testpass') + self._delete_test(self.announcement.id, 403, 1) + + def test_anon(self): + self._delete_test(self.announcement.id, 302, 1) diff --git a/apps/announcements/urls.py b/apps/announcements/urls.py new file mode 100644 index 00000000000..327ef186b14 --- /dev/null +++ b/apps/announcements/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls.defaults import patterns, url + + +urlpatterns = patterns('announcements.views', + url(r'^/create/locale$', 'create_for_locale', + name='announcements.create_for_locale'), + url(r'^/(?P\d+)/delete$', 'delete', + name='announcements.delete'), +) diff --git a/apps/announcements/views.py b/apps/announcements/views.py new file mode 100644 index 00000000000..b2e168fbaef --- /dev/null +++ b/apps/announcements/views.py @@ -0,0 +1,55 @@ +import json + +from django.http import HttpResponse, HttpResponseForbidden +from django.shortcuts import get_object_or_404 +from django.views.decorators.http import require_POST +from access.decorators import login_required + +from announcements.forms import AnnouncementForm +from announcements.models import Announcement +from wiki.models import Locale + + +@require_POST +@login_required +def create_for_locale(request): + """An ajax view to create a new announcement for the current locale.""" + user = request.user + locale = Locale.objects.get(locale=request.locale) + + if not user_can_announce(user, locale): + return HttpResponseForbidden() + + form = AnnouncementForm(request.POST) + + if form.is_valid(): + a = Announcement(creator=user, locale=locale, **form.cleaned_data) + a.save() + return HttpResponse(json.dumps({'id': a.id}), + content_type="application/json") + else: + return HttpResponse(json.dumps(form.errors), status=400, + content_type="application/json") + + +@require_POST +@login_required +def delete(request, announcement_id): + """An ajax view to delete an announcement.""" + user = request.user + locale = Locale.objects.get(locale=request.locale) + + if not user_can_announce(user, locale): + return HttpResponseForbidden() + + a = get_object_or_404(Announcement, id=announcement_id) + a.delete() + + return HttpResponse("", status=204) + + +def user_can_announce(user, locale): + if user.is_anonymous(): + return False + return (user.has_perm('announcements.add_announcement') or + user in locale.leaders.all()) diff --git a/apps/dashboards/templates/dashboards/localization.html b/apps/dashboards/templates/dashboards/localization.html index 6c05c327890..e5f10e68475 100644 --- a/apps/dashboards/templates/dashboards/localization.html +++ b/apps/dashboards/templates/dashboards/localization.html @@ -18,12 +18,43 @@

{{ title }}

{% for a in announcements %}
  • {{ a.content|wiki_to_html }} -

    {{ datetimeformat(a.show_after) }}

    +

    + {{ datetimeformat(a.show_after, 'date') }} + {% if user_can_announce %} + + {{ _('Delete') }} + + {% endif %} +

  • {% endfor %} {% endif %} + {% if user_can_announce %} +
    + + {{ _('Created successfully') }} +
    +
    + {{ csrf() }} +
      + {{ announce_form.as_ul() }} +
    • + + + +
    +
    +
    +
    + {% endif %} +
      diff --git a/apps/dashboards/tests/test_views.py b/apps/dashboards/tests/test_views.py index 4eb803ba4e9..5d51a36451f 100644 --- a/apps/dashboards/tests/test_views.py +++ b/apps/dashboards/tests/test_views.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import timedelta, datetime import json from django.conf import settings @@ -6,6 +6,7 @@ from nose import SkipTest from nose.tools import eq_ +from announcements.tests import announcement from dashboards.cron import cache_most_unhelpful_kb_articles from dashboards.readouts import CONTRIBUTOR_READOUTS from sumo.tests import TestCase @@ -13,7 +14,7 @@ from sumo.redis_utils import redis_client, RedisError from users.tests import user, group from wiki.models import HelpfulVote -from wiki.tests import revision +from wiki.tests import revision, locale class LocalizationDashTests(TestCase): @@ -26,6 +27,45 @@ def test_redirect_to_contributor_dash(self): locale='en-US')) +def LocalizationDashAnnouncementsTests(TestCase): + + def setUp(self): + self.locale1 = locale(save=True, locale='es') + + self.u1 = user(save=True) + self.u2 = user(save=True) + self.u3 = user(save=True) + + self.u1.is_superuser = 1 + self.u1.save() + + self.locale1.leaders.add(self.u2) + self.locale1.save() + + self.announcement = announcement(save=True, creator=self.u2, + locale=self.locale1, content="Look at me!", + show_after=datetime(2012, 01, 01, 0, 0, 0)) + + def test_show_create(self): + self.client.login(username=self.u1.username, password='testpass') + resp = self.client.get(reverse('dashboards.localization')) + self.assertContains(resp, 'id="create-announcement"') + + def test_show_for_authed(self): + self.client.login(username=self.u2.username, password='testpass') + resp = self.client.get(reverse('dashboards.localization')) + self.assertContains(resp, 'id="create-announcement"') + + def test_hide_for_not_authed(self): + self.client.login(username=self.u3.username, password='testpass') + resp = self.client.get(reverse('dashboards.localization')) + self.assertNotContains(resp, 'id="create-announcement"') + + def test_hide_for_anon(self): + resp = self.client.get(reverse('dashboards.localization')) + self.assertNotContains(resp, 'id="create-announcement"') + + class ContributorDashTests(TestCase): def test_main_view(self): """Assert the top page of the contributor dash resolves, renders.""" diff --git a/apps/dashboards/utils.py b/apps/dashboards/utils.py index a8537aae333..c37ff60ebc0 100644 --- a/apps/dashboards/utils.py +++ b/apps/dashboards/utils.py @@ -5,6 +5,7 @@ import jingo from announcements.models import Announcement +from announcements.forms import AnnouncementForm from dashboards import ACTIONS_PER_PAGE from sumo_locales import LOCALES from sumo.utils import paginate @@ -55,6 +56,7 @@ def render_readouts(request, readouts, template, locale=None, extra_data=None): 'is_watching_default_ready': ReadyRevisionEvent.is_notifying(request.user), 'on_default_locale': on_default_locale, + 'announce_form': AnnouncementForm(), 'announcements': Announcement.get_for_locale_name(current_locale), } if extra_data: diff --git a/apps/dashboards/views.py b/apps/dashboards/views.py index bc569f4110d..09e4ff64af8 100644 --- a/apps/dashboards/views.py +++ b/apps/dashboards/views.py @@ -1,5 +1,4 @@ import colorsys -from functools import partial import json import logging import math @@ -13,6 +12,7 @@ from tower import ugettext as _ from access.decorators import login_required +from announcements.views import user_can_announce from announcements.models import Announcement from dashboards.personal import GROUP_DASHBOARDS from dashboards.readouts import (overview_rows, READOUTS, L10N_READOUTS, @@ -23,6 +23,7 @@ from sumo.redis_utils import redis_client, RedisError from sumo.urlresolvers import reverse from sumo.utils import paginate, smart_int +from wiki.models import Locale log = logging.getLogger('k.dashboards') @@ -69,7 +70,16 @@ def localization(request): """Render aggregate data about articles in a non-default locale.""" if request.locale == settings.WIKI_DEFAULT_LANGUAGE: return HttpResponseRedirect(reverse('dashboards.contributors')) - data = {'overview_rows': overview_rows(request.locale)} + locales = Locale.objects.filter(locale=request.locale) + if locales: + permission = user_can_announce(request.user, locales[0]) + else: + permission = False + + data = { + 'overview_rows': overview_rows(request.locale), + 'user_can_announce': permission + } return render_readouts(request, L10N_READOUTS, 'localization.html', extra_data=data) diff --git a/media/js/dashboards.js b/media/js/dashboards.js index 4627a8c5178..cb12d56722f 100644 --- a/media/js/dashboards.js +++ b/media/js/dashboards.js @@ -3,6 +3,7 @@ initReadoutModes(); initWatchMenu(); initNeedsChange(); + initAnnouncements(); } // Hook up readout mode links (like "This Week" and "All Time") to swap @@ -78,7 +79,68 @@ if(!$(e.target).is('a')) { $(this).toggleClass('active'); } - }) + }); + } + + function initAnnouncements() { + var $form = $('#create-announcement form'); + $form.find('button.btn-submit').on('click', function(ev) { + ev.preventDefault(); + + var $kbox = $('#create-announcement .kbox'); + + $form.addClass('wait'); + $form.find('.error').remove(); + + $.ajax({ + type: 'POST', + url: $form.prop('action'), + data: $form.serialize(), + statusCode: { + 200: function(data) { + $form.removeClass('wait'); + $kbox.hide(200, function() { + $kbox.data('kbox').close(); + $kbox.show(); + }); + var $success = $('#create-announcement').children('.success') + .show().css({ opacity: 1}); + + setTimeout(function() { + $success.animate({opacity: 0}, 1000); + }, 4000); + }, + 400: function(jxr) { + var data, field; + $form.removeClass('wait'); + try { + data = JSON.parse(jxr.responseText); + } catch(e) { + data = {}; + } + for (field in data) { + $form.find('[name=' + field + ']').parent() + .after('
    • ' + data[field] + '
    • '); + } + } + } + }); + }); + + $('.announcements li a.delete').on('click', function(ev) { + ev.preventDefault(); + var $this = $(this); + $.ajax({ + type: 'POST', + url: $this.prop('href'), + data: { + 'csrfmiddlewaretoken': $("input[name=csrfmiddlewaretoken]").val() + }, + success: function() { + $this.closest('li').remove(); + } + }); + }); } $(document).ready(init); diff --git a/media/less/kbdashboards.less b/media/less/kbdashboards.less index 047ed5e5c5a..ffb9d103299 100644 --- a/media/less/kbdashboards.less +++ b/media/less/kbdashboards.less @@ -480,3 +480,83 @@ } } } + +#create-announcement { + .btn { + img { + background: transparent url("../img/icons-sprite.png") no-repeat -50px -978px; + height: 14px; + width: 14px; + display: inline-block; + position: relative; + left: -2px; + top: 2px; + } + } + + .success { + display: none; + padding: 3px; + border-radius: 5px; + background: #c1e58f; + } + + .kbox-container { + position: relative; + top: 4px; + + &:before { + background: #fff; + content: ''; + display: block; + height: 15px; + left: 58px; + margin-bottom: -13px; + position: relative; + top: -8px; + .box-shadow(-1px -1px 1px rgba(0, 0, 0, 0.2)); + .transform(rotate(45deg)); + width: 15px; + z-index: 1; + } + + form { + ul { + padding: 0; + li { + list-style: none; + margin-bottom: 10px; + + label { + width: 100px; + text-align: right; + display: inline-block; + margin-right: 5px; + } + + textarea { + width: 335px; + } + } + } + + .btn-submit { + margin-left: 105px; + } + + .error { + color: #f00; + margin-bottom: 25px; + } + + .spinner { + opacity: 0; + vertical-align: bottom; + transition: opacity 0.1s; + } + &.wait .spinner { + opacity: 1; + } + } + } +} diff --git a/urls.py b/urls.py index ebdc7227b77..919afdc4d0b 100644 --- a/urls.py +++ b/urls.py @@ -31,6 +31,7 @@ (r'^kpi/', include('kpi.urls')), (r'^products', include('products.urls')), (r'^topics', include('topics.urls')), + (r'^announcements', include('announcements.urls')), # Kitsune admin (not Django admin). (r'^admin/', include(admin.site.urls)),