From e807115e819de622ec99e015aa2b2232c8db08d9 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 13 Jan 2016 10:15:38 +0000 Subject: [PATCH 01/14] Fix community list track/untrack to use POST rather than GET - Legacy-Id: 10660 --- ietf/community/tests.py | 30 +++++ ietf/community/urls.py | 7 +- ietf/community/views.py | 120 ++++++++---------- ietf/static/ietf/js/ietf.js | 16 +-- ietf/templates/community/track_document.html | 16 +++ .../templates/community/untrack_document.html | 16 +++ ietf/templates/doc/document_draft.html | 4 +- .../doc/search/search_result_row.html | 4 +- 8 files changed, 133 insertions(+), 80 deletions(-) create mode 100644 ietf/community/tests.py create mode 100644 ietf/templates/community/track_document.html create mode 100644 ietf/templates/community/untrack_document.html diff --git a/ietf/community/tests.py b/ietf/community/tests.py new file mode 100644 index 000000000..e5fae2c21 --- /dev/null +++ b/ietf/community/tests.py @@ -0,0 +1,30 @@ +import json + +from django.core.urlresolvers import reverse as urlreverse + +from ietf.community.models import CommunityList +from ietf.utils.test_data import make_test_data +from ietf.utils.test_utils import login_testing_unauthorized, TestCase + +class CommunityListTests(TestCase): + def test_track_untrack_document(self): + draft = make_test_data() + + url = urlreverse("community_track_document", kwargs={ "name": draft.name }) + login_testing_unauthorized(self, "plain", url) + + # track + r = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(r.status_code, 200) + self.assertEqual(json.loads(r.content)["success"], True) + clist = CommunityList.objects.get(user__username="plain") + self.assertEqual(list(clist.added_ids.all()), [draft]) + + # untrack + url = urlreverse("community_untrack_document", kwargs={ "name": draft.name }) + r = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(r.status_code, 200) + self.assertEqual(json.loads(r.content)["success"], True) + clist = CommunityList.objects.get(user__username="plain") + self.assertEqual(list(clist.added_ids.all()), []) + diff --git a/ietf/community/urls.py b/ietf/community/urls.py index 0f60ba63c..7c499d4f7 100644 --- a/ietf/community/urls.py +++ b/ietf/community/urls.py @@ -23,9 +23,10 @@ urlpatterns = patterns('ietf.community.views', url(r'^group/(?P[\w.@+-]+)/unsubscribe/$', 'unsubscribe_group_list', {'significant': False}, name='unsubscribe_group_list'), url(r'^group/(?P[\w.@+-]+)/unsubscribe/significant/$', 'unsubscribe_group_list', {'significant': True}, name='unsubscribe_significant_group_list'), - url(r'^add_track_document/(?P[^/]+)/$', 'add_track_document', name='community_add_track_document'), - url(r'^remove_track_document/(?P[^/]+)/$', 'remove_track_document', name='community_remove_track_document'), - url(r'^(?P[\d]+)/remove_document/(?P[^/]+)/$', 'remove_document', name='community_remove_document'), + url(r'^trackdocument/(?P[^/]+)/$', 'track_document', name='community_track_document'), + url(r'^untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_untrack_document'), + + url(r'^(?P[\d]+)/remove_document/(?P[^/]+)/$', 'remove_document', name='community_remove_document'), url(r'^(?P[\d]+)/remove_rule/(?P[^/]+)/$', 'remove_rule', name='community_remove_rule'), url(r'^(?P[\d]+)/subscribe/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[a-f0-9]+)/$', 'confirm_subscription', name='confirm_subscription'), url(r'^(?P[\d]+)/subscribe/significant/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[a-f0-9]+)/$', 'confirm_significant_subscription', name='confirm_significant_subscription'), diff --git a/ietf/community/views.py b/ietf/community/views.py index 87c2a9702..0b2f41a1c 100644 --- a/ietf/community/views.py +++ b/ietf/community/views.py @@ -7,15 +7,15 @@ import json from django.db import IntegrityError from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME -from django.http import HttpResponse, Http404, HttpResponseRedirect -from django.shortcuts import get_object_or_404, render_to_response -from django.template import RequestContext +from django.http import HttpResponse, HttpResponseForbidden, Http404, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render, redirect from django.utils.http import urlquote +from django.contrib.auth.decorators import login_required from ietf.community.models import CommunityList, Rule, EmailSubscription from ietf.community.forms import RuleForm, DisplayForm, SubscribeForm, UnSubscribeForm from ietf.group.models import Group -from ietf.doc.models import DocEvent, DocAlias +from ietf.doc.models import DocEvent, Document def _manage_list(request, clist): @@ -41,12 +41,11 @@ def _manage_list(request, clist): rule_form = RuleForm(clist=clist) display_form = DisplayForm(instance=display_config) clist = CommunityList.objects.get(id=clist.id) - return render_to_response('community/manage_clist.html', + return render(request, 'community/manage_clist.html', {'cl': clist, 'dc': display_config, 'display_form': display_form, - 'rule_form': rule_form}, - context_instance=RequestContext(request)) + 'rule_form': rule_form}) def manage_personal_list(request): @@ -74,52 +73,49 @@ def manage_group_list(request, acronym): return HttpResponseRedirect('%s?%s=%s' % tup) return _manage_list(request, clist) +@login_required +def track_document(request, name): + doc = get_object_or_404(Document, docalias__name=name) -def add_track_document(request, document_name): - """supports the "Track this document" functionality - - This is exposed in the document view and in document search results.""" - if not request.user.is_authenticated(): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) - doc = get_object_or_404(DocAlias, name=document_name).document - clist = CommunityList.objects.get_or_create(user=request.user)[0] - clist.update() - return add_document_to_list(request, clist, doc) + if request.method == "POST": + clist = CommunityList.objects.get_or_create(user=request.user)[0] + clist.added_ids.add(doc) + if request.is_ajax(): + return HttpResponse(json.dumps({ 'success': True }), content_type='text/plain') + else: + return redirect("manage_personal_list") -def remove_track_document(request, document_name): - """supports the "Untrack this document" functionality - - This is exposed in the document view and in document search results.""" - clist = CommunityList.objects.get_or_create(user=request.user)[0] - if not clist.check_manager(request.user): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) - doc = get_object_or_404(DocAlias, name=document_name).document - clist.added_ids.remove(doc) - clist.update() - return HttpResponse(json.dumps({'success': True}), content_type='text/plain') + return render(request, "community/track_document.html", { + "name": doc.name, + }) -def remove_document(request, list_id, document_name): +@login_required +def untrack_document(request, name): + doc = get_object_or_404(Document, docalias__name=name) + clist = get_object_or_404(CommunityList, user=request.user) + + if request.method == "POST": + clist = CommunityList.objects.get_or_create(user=request.user)[0] + clist.added_ids.remove(doc) + if request.is_ajax(): + return HttpResponse(json.dumps({ 'success': True }), content_type='text/plain') + else: + return redirect("manage_personal_list") + + return render(request, "community/untrack_document.html", { + "name": doc.name, + }) + +@login_required +def remove_document(request, list_id, name): clist = get_object_or_404(CommunityList, pk=list_id) if not clist.check_manager(request.user): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) - doc = get_object_or_404(DocAlias, name=document_name).document - clist.added_ids.remove(doc) - clist.update() - return HttpResponseRedirect(clist.get_manage_url()) + return HttpResponseForbidden("You do not have permission to access this view") -def add_document_to_list(request, clist, doc): - if not clist.check_manager(request.user): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) - clist.added_ids.add(doc) - return HttpResponse(json.dumps({'success': True}), content_type='text/plain') + doc = get_object_or_404(Document, docalias__name=name) + clist.added_ids.remove(doc) + + return HttpResponseRedirect(clist.get_manage_url()) def remove_rule(request, list_id, rule_id): @@ -135,11 +131,10 @@ def remove_rule(request, list_id, rule_id): def _view_list(request, clist): display_config = clist.get_display_config() - return render_to_response('community/public/view_list.html', + return render(request, 'community/public/view_list.html', {'cl': clist, 'dc': display_config, - }, - context_instance=RequestContext(request)) + }) def view_personal_list(request, secret): @@ -172,7 +167,7 @@ def _atom_view(request, clist, significant=False): else: subtitle = 'Document changes' - return render_to_response('community/public/atom.xml', + return render(request, 'community/public/atom.xml', {'cl': clist, 'entries': notifications, 'title': title, @@ -180,8 +175,7 @@ def _atom_view(request, clist, significant=False): 'id': feed_id.get_urn(), 'updated': datetime.datetime.today(), }, - content_type='text/xml', - context_instance=RequestContext(request)) + content_type='text/xml') def changes_personal_list(request, secret): @@ -264,12 +258,11 @@ def _subscribe_list(request, clist, significant): success = True else: form = SubscribeForm(clist=clist, significant=significant) - return render_to_response('community/public/subscribe.html', + return render(request, 'community/public/subscribe.html', {'cl': clist, 'form': form, 'success': success, - }, - context_instance=RequestContext(request)) + }) def _unsubscribe_list(request, clist, significant): @@ -281,13 +274,12 @@ def _unsubscribe_list(request, clist, significant): success = True else: form = UnSubscribeForm(clist=clist, significant=significant) - return render_to_response('community/public/unsubscribe.html', + return render(request, 'community/public/unsubscribe.html', {'cl': clist, 'form': form, 'success': success, 'significant': significant, - }, - context_instance=RequestContext(request)) + }) def subscribe_personal_list(request, secret, significant=False): @@ -321,11 +313,10 @@ def confirm_subscription(request, list_id, email, date, confirm_hash, significan community_list=clist, email=email, significant=significant) - return render_to_response('community/public/subscription_confirm.html', + return render(request, 'community/public/subscription_confirm.html', {'cl': clist, 'significant': significant, - }, - context_instance=RequestContext(request)) + }) def confirm_significant_subscription(request, list_id, email, date, confirm_hash): @@ -341,11 +332,10 @@ def confirm_unsubscription(request, list_id, email, date, confirm_hash, signific community_list=clist, email=email, significant=significant).delete() - return render_to_response('community/public/unsubscription_confirm.html', + return render(request, 'community/public/unsubscription_confirm.html', {'cl': clist, 'significant': significant, - }, - context_instance=RequestContext(request)) + }) def confirm_significant_unsubscription(request, list_id, email, date, confirm_hash): diff --git a/ietf/static/ietf/js/ietf.js b/ietf/static/ietf/js/ietf.js index b40e1a24f..5ab14e5c0 100644 --- a/ietf/static/ietf/js/ietf.js +++ b/ietf/static/ietf/js/ietf.js @@ -105,19 +105,19 @@ $(document).ready(function () { var trigger = $(this); $.ajax({ url: trigger.attr('href'), - type: 'GET', + type: 'POST', cache: false, dataType: 'json', success: function(response){ if (response.success) { - trigger.parent().find(".tooltip").remove(); - trigger.find("span.fa").toggleClass("fa-bookmark fa-bookmark-o"); - if (trigger.hasClass('btn')) { - trigger.attr('disabled', true).blur(); - } else { - trigger.contents().unwrap().blur(); + trigger.parent().find(".tooltip").remove(); + trigger.find("span.fa").toggleClass("fa-bookmark fa-bookmark-o"); + if (trigger.hasClass('btn')) { + trigger.attr('disabled', true).blur(); + } else { + trigger.contents().unwrap().blur(); + } } - } } }); }); diff --git a/ietf/templates/community/track_document.html b/ietf/templates/community/track_document.html new file mode 100644 index 000000000..b592495f2 --- /dev/null +++ b/ietf/templates/community/track_document.html @@ -0,0 +1,16 @@ +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} +{% load bootstrap3 %} + +{% block title %}Track document {{ name }}{% endblock %} + +{% bootstrap_messages %} + +
+ {% csrf_token %} +

Add {{ name }} to the list?

+ + {% buttons %} + + {% endbuttons %} +
diff --git a/ietf/templates/community/untrack_document.html b/ietf/templates/community/untrack_document.html new file mode 100644 index 000000000..ef5156363 --- /dev/null +++ b/ietf/templates/community/untrack_document.html @@ -0,0 +1,16 @@ +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %}{% origin %} +{% load bootstrap3 %} + +{% block title %}Remove tracking of document {{ name }}{% endblock %} + +{% bootstrap_messages %} + +
+ {% csrf_token %} +

Remove {{ name }} from the list?

+ + {% buttons %} + + {% endbuttons %} +
diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 7188cb557..85d26bb4e 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -435,9 +435,9 @@ {% if user.is_authenticated %} {% if tracking_document %} - Untrack + Untrack {% else %} - Track + Track {% endif %} {% endif %} diff --git a/ietf/templates/doc/search/search_result_row.html b/ietf/templates/doc/search/search_result_row.html index 893251326..343c3da14 100644 --- a/ietf/templates/doc/search/search_result_row.html +++ b/ietf/templates/doc/search/search_result_row.html @@ -14,11 +14,11 @@ {% if user.is_authenticated %} {% if doc.name in doc_is_tracked %} - + {% else %} - + {% endif %} From 1e75ee3d11d480a0b576c4576d3821f13235d3ef Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 13 Jan 2016 10:47:36 +0000 Subject: [PATCH 02/14] Do not create CommunityLists when rendering the side bar, only render the links - Legacy-Id: 10668 --- ietf/community/models.py | 10 +------ ietf/community/templatetags/community_tags.py | 6 ++--- ietf/doc/templatetags/managed_groups.py | 26 +++++++++++++++++++ ietf/templates/base/menu.html | 14 +++++----- 4 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 ietf/doc/templatetags/managed_groups.py diff --git a/ietf/community/models.py b/ietf/community/models.py index a644a18fc..38de908cb 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -39,12 +39,6 @@ class CommunityList(models.Model): return bool(Role.objects.filter(name__slug='chair', email__in=person.email_set.all(), group=self.group).count()) return False - def short_name(self): - if self.user: - return 'mine' - else: - return '%s' % self.group.acronym - def long_name(self): if self.user: return 'Personal ID list of %s' % self.user.username @@ -124,13 +118,11 @@ class Rule(models.Model): cached_ids = models.ManyToManyField(Document) rule_type = models.CharField(max_length=30, choices=TYPES_OF_RULES) value = models.CharField(max_length=255) + last_updated = models.DateTimeField(auto_now=True) class Meta: unique_together= ("community_list", "rule_type", "value") - last_updated = models.DateTimeField( - auto_now=True) - def get_callable_rule(self): for i in RuleManager.__subclasses__(): if i.codename == self.rule_type: diff --git a/ietf/community/templatetags/community_tags.py b/ietf/community/templatetags/community_tags.py index c88e6b289..ee1ab0099 100644 --- a/ietf/community/templatetags/community_tags.py +++ b/ietf/community/templatetags/community_tags.py @@ -9,10 +9,10 @@ from ietf.group.models import Role register = template.Library() @register.assignment_tag -def get_user_managed_lists(user): +def community_lists_for_user(user): if not (user and hasattr(user, "is_authenticated") and user.is_authenticated()): - return '' - lists = {'personal': CommunityList.objects.get_or_create(user=user)[0]} + return {} + try: person = user.person groups = [] diff --git a/ietf/doc/templatetags/managed_groups.py b/ietf/doc/templatetags/managed_groups.py new file mode 100644 index 000000000..ecc673c4c --- /dev/null +++ b/ietf/doc/templatetags/managed_groups.py @@ -0,0 +1,26 @@ +from django import template + +from ietf.group.models import Group + +register = template.Library() + +@register.filter +def managed_groups(user): + if not (user and hasattr(user, "is_authenticated") and user.is_authenticated()): + return [] + + groups = [] + groups.extend(Group.objects.filter( + role__name__slug='ad', + role__person__user=user, + type__slug='area', + state__slug='active').select_related("type")) + + groups.extend(Group.objects.filter( + role__name__slug='chair', + role__person__user=user, + type__slug__in=('rg', 'wg'), + state__slug__in=('active', 'bof')).select_related("type")) + + return groups + diff --git a/ietf/templates/base/menu.html b/ietf/templates/base/menu.html index d79f92a26..856114b25 100644 --- a/ietf/templates/base/menu.html +++ b/ietf/templates/base/menu.html @@ -1,5 +1,5 @@ {# Copyright The IETF Trust 2015, All Rights Reserved #}{% load origin %}{% origin %} -{% load ietf_filters community_tags wg_menu streams_menu active_groups_menu %} +{% load ietf_filters managed_groups wg_menu streams_menu active_groups_menu %} {% if flavor != "top" %} {% include "base/menu_user.html" %} @@ -51,14 +51,14 @@
  • Approve a draft
  • {% endif %} - {% get_user_managed_lists user as community_lists %} - {% if community_lists %} -
  • My tracked docs
  • - {% for cl in community_lists.group %} -
  • {{ cl.short_name }} {{cl.group.type.slug}} docs
  • + {% if user and user.is_authenticated %} +
  • My tracked docs
  • + + {% for g in user|managed_groups %} +
  • {{ g.acronym }} {{ g.type.slug }} docs
  • {% endfor %} {% else %} -
  • Sign in to track docs
  • +
  • Sign in to track docs
  • {% endif %} {% if user|has_role:"Area Director,Secretariat" %} From 4696dad5363d53dd3fd52b77e8c36885d72adf4d Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 13 Jan 2016 10:53:28 +0000 Subject: [PATCH 03/14] Delete some unused code - Legacy-Id: 10669 --- ietf/community/templatetags/community_tags.py | 23 ------------------- ietf/community/views.py | 1 - 2 files changed, 24 deletions(-) diff --git a/ietf/community/templatetags/community_tags.py b/ietf/community/templatetags/community_tags.py index ee1ab0099..8cbfe3061 100644 --- a/ietf/community/templatetags/community_tags.py +++ b/ietf/community/templatetags/community_tags.py @@ -2,31 +2,8 @@ from django import template from django.template.loader import render_to_string from django.conf import settings -from ietf.community.models import CommunityList -from ietf.group.models import Role - - register = template.Library() -@register.assignment_tag -def community_lists_for_user(user): - if not (user and hasattr(user, "is_authenticated") and user.is_authenticated()): - return {} - - try: - person = user.person - groups = [] - managed_areas = [i.group for i in Role.objects.filter(name__slug='ad', group__type__slug='area', group__state__slug='active', email__in=person.email_set.all())] - for area in managed_areas: - groups.append(CommunityList.objects.get_or_create(group=area)[0]) - managed_wg = [i.group for i in Role.objects.filter(name__slug='chair', group__type__slug='wg', group__state__slug__in=('active','bof'), email__in=person.email_set.all())] - for wg in managed_wg: - groups.append(CommunityList.objects.get_or_create(group=wg)[0]) - lists['group'] = groups - except: - pass - return lists - @register.inclusion_tag('community/display_field.html', takes_context=False) def show_field(field, doc): return {'field': field, diff --git a/ietf/community/views.py b/ietf/community/views.py index 0b2f41a1c..88bf4a7e9 100644 --- a/ietf/community/views.py +++ b/ietf/community/views.py @@ -95,7 +95,6 @@ def untrack_document(request, name): clist = get_object_or_404(CommunityList, user=request.user) if request.method == "POST": - clist = CommunityList.objects.get_or_create(user=request.user)[0] clist.added_ids.remove(doc) if request.is_ajax(): return HttpResponse(json.dumps({ 'success': True }), content_type='text/plain') From cd5c60ccf1ad2d71d45ea78610465dbc696f5ba0 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 13 Jan 2016 17:37:22 +0000 Subject: [PATCH 04/14] Clean up permission checking in community lists, fix split in track/untrack between personal/group lists, get rid of remove_document - Legacy-Id: 10680 --- ietf/community/models.py | 17 +--- ietf/community/tests.py | 31 ++++++- ietf/community/urls.py | 7 +- ietf/community/utils.py | 13 +++ ietf/community/views.py | 89 ++++++++----------- ietf/templates/community/manage_clist.html | 4 +- ietf/templates/doc/document_draft.html | 4 +- .../doc/search/search_result_row.html | 4 +- 8 files changed, 88 insertions(+), 81 deletions(-) create mode 100644 ietf/community/utils.py diff --git a/ietf/community/models.py b/ietf/community/models.py index 38de908cb..fe1ce2fd6 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -8,7 +8,7 @@ from django.db.models import signals, Q from ietf.utils.mail import send_mail from ietf.doc.models import Document, DocEvent -from ietf.group.models import Group, Role +from ietf.group.models import Group from ietf.community.rules import TYPES_OF_RULES, RuleManager from ietf.community.display import (TYPES_OF_SORT, DisplayField, @@ -24,21 +24,6 @@ class CommunityList(models.Model): secret = models.CharField(max_length=255, null=True, blank=True) cached = models.TextField(null=True, blank=True) - def check_manager(self, user): - if user == self.user: - return True - if not self.group or self.group.type.slug not in ('area', 'wg'): - return False - try: - person = user.person - except: - return False - if self.group.type.slug == 'area': - return bool(Role.objects.filter(name__slug='ad', email__in=person.email_set.all(), group=self.group).count()) - elif self.group.type.slug == 'wg': - return bool(Role.objects.filter(name__slug='chair', email__in=person.email_set.all(), group=self.group).count()) - return False - def long_name(self): if self.user: return 'Personal ID list of %s' % self.user.username diff --git a/ietf/community/tests.py b/ietf/community/tests.py index e5fae2c21..71e27d952 100644 --- a/ietf/community/tests.py +++ b/ietf/community/tests.py @@ -7,10 +7,10 @@ from ietf.utils.test_data import make_test_data from ietf.utils.test_utils import login_testing_unauthorized, TestCase class CommunityListTests(TestCase): - def test_track_untrack_document(self): + def test_track_untrack_document_for_personal_list_through_ajax(self): draft = make_test_data() - url = urlreverse("community_track_document", kwargs={ "name": draft.name }) + url = urlreverse("community_personal_track_document", kwargs={ "name": draft.name }) login_testing_unauthorized(self, "plain", url) # track @@ -21,10 +21,35 @@ class CommunityListTests(TestCase): self.assertEqual(list(clist.added_ids.all()), [draft]) # untrack - url = urlreverse("community_untrack_document", kwargs={ "name": draft.name }) + url = urlreverse("community_personal_untrack_document", kwargs={ "name": draft.name }) r = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertEqual(r.status_code, 200) self.assertEqual(json.loads(r.content)["success"], True) clist = CommunityList.objects.get(user__username="plain") self.assertEqual(list(clist.added_ids.all()), []) + + def test_track_untrack_document_for_group_list(self): + draft = make_test_data() + + url = urlreverse("community_group_track_document", kwargs={ "name": draft.name, "acronym": draft.group.acronym }) + login_testing_unauthorized(self, "marschairman", url) + + # track + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post(url) + self.assertEqual(r.status_code, 302) + clist = CommunityList.objects.get(group__acronym=draft.group.acronym) + self.assertEqual(list(clist.added_ids.all()), [draft]) + + # untrack + url = urlreverse("community_group_untrack_document", kwargs={ "name": draft.name, "acronym": draft.group.acronym }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post(url) + self.assertEqual(r.status_code, 302) + clist = CommunityList.objects.get(group__acronym=draft.group.acronym) + self.assertEqual(list(clist.added_ids.all()), []) diff --git a/ietf/community/urls.py b/ietf/community/urls.py index 7c499d4f7..99458eacf 100644 --- a/ietf/community/urls.py +++ b/ietf/community/urls.py @@ -12,6 +12,8 @@ urlpatterns = patterns('ietf.community.views', url(r'^personal/(?P[a-f0-9]+)/subscribe/significant/$', 'subscribe_personal_list', {'significant': True}, name='subscribe_significant_personal_list'), url(r'^personal/(?P[a-f0-9]+)/unsubscribe/$', 'unsubscribe_personal_list', {'significant': False}, name='unsubscribe_personal_list'), url(r'^personal/(?P[a-f0-9]+)/unsubscribe/significant/$', 'unsubscribe_personal_list', {'significant': True}, name='unsubscribe_significant_personal_list'), + url(r'^personal/trackdocument/(?P[^/]+)/$', 'track_document', name='community_personal_track_document'), + url(r'^personal/untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_personal_untrack_document'), url(r'^group/(?P[\w.@+-]+)/$', 'manage_group_list', name='manage_group_list'), url(r'^group/(?P[\w.@+-]+)/view/$', 'view_group_list', name='view_group_list'), @@ -22,11 +24,10 @@ urlpatterns = patterns('ietf.community.views', url(r'^group/(?P[\w.@+-]+)/subscribe/significant/$', 'subscribe_group_list', {'significant': True}, name='subscribe_significant_group_list'), url(r'^group/(?P[\w.@+-]+)/unsubscribe/$', 'unsubscribe_group_list', {'significant': False}, name='unsubscribe_group_list'), url(r'^group/(?P[\w.@+-]+)/unsubscribe/significant/$', 'unsubscribe_group_list', {'significant': True}, name='unsubscribe_significant_group_list'), + url(r'^group/(?P[\w.@+-]+)/trackdocument/(?P[^/]+)/$', 'track_document', name='community_group_track_document'), + url(r'^group/(?P[\w.@+-]+)/untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_group_untrack_document'), - url(r'^trackdocument/(?P[^/]+)/$', 'track_document', name='community_track_document'), - url(r'^untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_untrack_document'), - url(r'^(?P[\d]+)/remove_document/(?P[^/]+)/$', 'remove_document', name='community_remove_document'), url(r'^(?P[\d]+)/remove_rule/(?P[^/]+)/$', 'remove_rule', name='community_remove_rule'), url(r'^(?P[\d]+)/subscribe/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[a-f0-9]+)/$', 'confirm_subscription', name='confirm_subscription'), url(r'^(?P[\d]+)/subscribe/significant/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[a-f0-9]+)/$', 'confirm_significant_subscription', name='confirm_significant_subscription'), diff --git a/ietf/community/utils.py b/ietf/community/utils.py new file mode 100644 index 000000000..e120a2f6c --- /dev/null +++ b/ietf/community/utils.py @@ -0,0 +1,13 @@ +from ietf.group.models import Role + +def can_manage_community_list_for_group(user, group): + if not user or not user.is_authenticated() or not group: + return False + + if group.type_id == 'area': + return Role.objects.filter(name__slug='ad', person__user=user, group=group).exists() + elif group.type_id in ('wg', 'rg'): + return Role.objects.filter(name__slug='chair', person__user=user, group=group).exists() + else: + return False + diff --git a/ietf/community/views.py b/ietf/community/views.py index 88bf4a7e9..fcc0a6d10 100644 --- a/ietf/community/views.py +++ b/ietf/community/views.py @@ -6,14 +6,13 @@ import json from django.db import IntegrityError from django.conf import settings -from django.contrib.auth import REDIRECT_FIELD_NAME from django.http import HttpResponse, HttpResponseForbidden, Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, render, redirect -from django.utils.http import urlquote from django.contrib.auth.decorators import login_required from ietf.community.models import CommunityList, Rule, EmailSubscription from ietf.community.forms import RuleForm, DisplayForm, SubscribeForm, UnSubscribeForm +from ietf.community.utils import can_manage_community_list_for_group from ietf.group.models import Group from ietf.doc.models import DocEvent, Document @@ -48,38 +47,37 @@ def _manage_list(request, clist): 'rule_form': rule_form}) +@login_required def manage_personal_list(request): - user = request.user - if not user.is_authenticated(): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) clist = CommunityList.objects.get_or_create(user=request.user)[0] - if not clist.check_manager(request.user): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) return _manage_list(request, clist) +@login_required def manage_group_list(request, acronym): group = get_object_or_404(Group, acronym=acronym) - if group.type.slug not in ('area', 'wg'): - raise Http404 + if not can_manage_community_list_for_group(request.user, group): + return HttpResponseForbidden("You do not have permission to access this view") + clist = CommunityList.objects.get_or_create(group=group)[0] - if not clist.check_manager(request.user): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) return _manage_list(request, clist) @login_required -def track_document(request, name): +def track_document(request, name, acronym=None): doc = get_object_or_404(Document, docalias__name=name) if request.method == "POST": - clist = CommunityList.objects.get_or_create(user=request.user)[0] + if acronym: + group = get_object_or_404(Group, acronym=acronym) + if not can_manage_community_list_for_group(request.user, group): + return HttpResponseForbidden("You do not have permission to access this view") + + clist = CommunityList.objects.get_or_create(group=group)[0] + else: + clist = CommunityList.objects.get_or_create(user=request.user)[0] + clist.added_ids.add(doc) + if request.is_ajax(): return HttpResponse(json.dumps({ 'success': True }), content_type='text/plain') else: @@ -90,9 +88,15 @@ def track_document(request, name): }) @login_required -def untrack_document(request, name): +def untrack_document(request, name, acronym=None): doc = get_object_or_404(Document, docalias__name=name) - clist = get_object_or_404(CommunityList, user=request.user) + if acronym: + group = get_object_or_404(Group, acronym=acronym) + if not can_manage_community_list_for_group(request.user, group): + return HttpResponseForbidden("You do not have permission to access this view") + clist = get_object_or_404(CommunityList, group=group) + else: + clist = get_object_or_404(CommunityList, user=request.user) if request.method == "POST": clist.added_ids.remove(doc) @@ -106,23 +110,13 @@ def untrack_document(request, name): }) @login_required -def remove_document(request, list_id, name): - clist = get_object_or_404(CommunityList, pk=list_id) - if not clist.check_manager(request.user): - return HttpResponseForbidden("You do not have permission to access this view") - - doc = get_object_or_404(Document, docalias__name=name) - clist.added_ids.remove(doc) - - return HttpResponseRedirect(clist.get_manage_url()) - - def remove_rule(request, list_id, rule_id): clist = get_object_or_404(CommunityList, pk=list_id) - if not clist.check_manager(request.user): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) + + if ((clist.user and clist.user != request.user) + or (clist.group and not can_manage_community_list_for_group(request.user, clist.group))): + return HttpResponseForbidden("You do not have permission to access this view") + rule = get_object_or_404(Rule, pk=rule_id) rule.delete() return HttpResponseRedirect(clist.get_manage_url()) @@ -218,30 +212,19 @@ def _csv_list(request, clist): writer.writerow(row) return response - +@login_required def csv_personal_list(request): - user = request.user - if not user.is_authenticated(): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) - clist = CommunityList.objects.get_or_create(user=user)[0] - if not clist.check_manager(user): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) + clist = CommunityList.objects.get_or_create(user=request.user)[0] return _csv_list(request, clist) +@login_required def csv_group_list(request, acronym): group = get_object_or_404(Group, acronym=acronym) - if group.type.slug not in ('area', 'wg'): - raise Http404 + if not can_manage_community_list_for_group(request.user, group): + return HttpResponseForbidden("You do not have permission to access this view") + clist = CommunityList.objects.get_or_create(group=group)[0] - if not clist.check_manager(request.user): - path = urlquote(request.get_full_path()) - tup = settings.LOGIN_URL, REDIRECT_FIELD_NAME, path - return HttpResponseRedirect('%s?%s=%s' % tup) return _csv_list(request, clist) def view_csv_personal_list(request, secret): diff --git a/ietf/templates/community/manage_clist.html b/ietf/templates/community/manage_clist.html index bd6630359..b39c8f971 100644 --- a/ietf/templates/community/manage_clist.html +++ b/ietf/templates/community/manage_clist.html @@ -47,8 +47,8 @@ {{ doc.display_name }} {{ doc.get_state }} - {{ doc.title }} - Remove + {{ doc.title }} + Remove {% endfor %} diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 85d26bb4e..70b55f621 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -435,9 +435,9 @@ {% if user.is_authenticated %} {% if tracking_document %} - Untrack + Untrack {% else %} - Track + Track {% endif %} {% endif %} diff --git a/ietf/templates/doc/search/search_result_row.html b/ietf/templates/doc/search/search_result_row.html index 343c3da14..1944819ad 100644 --- a/ietf/templates/doc/search/search_result_row.html +++ b/ietf/templates/doc/search/search_result_row.html @@ -14,11 +14,11 @@ {% if user.is_authenticated %} {% if doc.name in doc_is_tracked %} - + {% else %} - + {% endif %} From 1b8e9eb7e41f821c8c9d0c5f9a5bf4df7235e619 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 13 Jan 2016 17:43:05 +0000 Subject: [PATCH 05/14] Get rid of ExpectedChange model in community lists, it's not used - Legacy-Id: 10681 --- ietf/community/models.py | 9 --------- ietf/community/resources.py | 16 ---------------- 2 files changed, 25 deletions(-) diff --git a/ietf/community/models.py b/ietf/community/models.py index fe1ce2fd6..394d084c6 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -172,15 +172,6 @@ class DisplayConfiguration(models.Model): super(DisplayConfiguration, self).delete() -class ExpectedChange(models.Model): - - community_list = models.ForeignKey(CommunityList) - document = models.ForeignKey(Document) - expected_date = models.DateField( - verbose_name='Expected date' - ) - - class EmailSubscription(models.Model): community_list = models.ForeignKey(CommunityList) email = models.CharField(max_length=200) diff --git a/ietf/community/resources.py b/ietf/community/resources.py index 03a73d835..3316fb8a7 100644 --- a/ietf/community/resources.py +++ b/ietf/community/resources.py @@ -29,22 +29,6 @@ class CommunityListResource(ModelResource): } api.community.register(CommunityListResource()) -from ietf.doc.resources import DocumentResource -class ExpectedChangeResource(ModelResource): - community_list = ToOneField(CommunityListResource, 'community_list') - document = ToOneField(DocumentResource, 'document') - class Meta: - queryset = ExpectedChange.objects.all() - serializer = api.Serializer() - #resource_name = 'expectedchange' - filtering = { - "id": ALL, - "expected_date": ALL, - "community_list": ALL_WITH_RELATIONS, - "document": ALL_WITH_RELATIONS, - } -api.community.register(ExpectedChangeResource()) - class DisplayConfigurationResource(ModelResource): community_list = ToOneField(CommunityListResource, 'community_list') class Meta: From 22e2a6a2b8c4c84a7d3f333d460f48bed203d7d9 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 13 Jan 2016 18:11:03 +0000 Subject: [PATCH 06/14] Delete the DocumentChangeDates model from CommunityList, it's being used as a cache, but won't be necessary once the display code is rewritten since it will be able to calculate the data on the fly - Legacy-Id: 10682 --- ietf/community/display.py | 6 +-- .../commands/update_doc_change_dates.py | 38 ------------------- ietf/community/models.py | 14 ------- ietf/community/resources.py | 17 --------- 4 files changed, 3 insertions(+), 72 deletions(-) delete mode 100644 ietf/community/management/commands/update_doc_change_dates.py diff --git a/ietf/community/display.py b/ietf/community/display.py index a6f293a41..142c81d3d 100644 --- a/ietf/community/display.py +++ b/ietf/community/display.py @@ -182,14 +182,14 @@ class PublicationSort(SortMethod): description = 'Date of publication of current version of the document' def get_sort_field(self): - return '-documentchangedates__new_version_date' + return '-time' # FIXME: latest revision date class ChangeSort(SortMethod): codename = 'recent_change' description = 'Date of most recent change of status of any type' def get_sort_field(self): - return '-documentchangedates__normal_change_date' + return '-time' # FIXME: latest doc event class SignificantSort(SortMethod): @@ -197,7 +197,7 @@ class SignificantSort(SortMethod): description = 'Date of most recent significant change of status' def get_sort_field(self): - return '-documentchangedates__significant_change_date' + return '-time' # FIXME: latest significant state change TYPES_OF_SORT = [(i.codename, i.description) for i in SortMethod.__subclasses__()] diff --git a/ietf/community/management/commands/update_doc_change_dates.py b/ietf/community/management/commands/update_doc_change_dates.py deleted file mode 100644 index 5f946cf04..000000000 --- a/ietf/community/management/commands/update_doc_change_dates.py +++ /dev/null @@ -1,38 +0,0 @@ -import sys - -from django.core.management.base import BaseCommand - -from ietf.community.constants import SIGNIFICANT_STATES -from ietf.community.models import DocumentChangeDates -from ietf.doc.models import Document - - -class Command(BaseCommand): - help = (u"Update drafts in community lists by reviewing their rules") - - def handle(self, *args, **options): - documents = Document.objects.filter(type='draft') - index = 1 - total = documents.count() - - for doc in documents.iterator(): - (changes, created) = DocumentChangeDates.objects.get_or_create(document=doc) - new_version = doc.latest_event(type='new_revision') - normal_change = doc.latest_event() - significant_change = None - for event in doc.docevent_set.filter(type='changed_document'): - for state in SIGNIFICANT_STATES: - if ('%s' % state) in event.desc: - significant_change = event - break - - changes.new_version_date = new_version and new_version.time.date() - changes.normal_change_date = normal_change and normal_change.time.date() - changes.significant_change_date = significant_change and significant_change.time.date() - - changes.save() - - sys.stdout.write('Document %s/%s\r' % (index, total)) - sys.stdout.flush() - index += 1 - print diff --git a/ietf/community/models.py b/ietf/community/models.py index 394d084c6..aeed6e9d4 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -203,29 +203,15 @@ def notify_events(sender, instance, **kwargs): return if instance.doc.type.slug != 'draft' or instance.type == 'added_comment': return - (changes, created) = DocumentChangeDates.objects.get_or_create(document=instance.doc) - changes.normal_change_date = instance.time significant = False if instance.type == 'changed_document' and 'tate changed' in instance.desc: for i in SIGNIFICANT_STATES: if ('%s' % i) in instance.desc: significant = True - changes.significant_change_date = instance.time break - elif instance.type == 'new_revision': - changes.new_version_date = instance.time - changes.save() notification = ListNotification.objects.create( event=instance, significant=significant, ) notification.notify_by_email() signals.post_save.connect(notify_events) - - -class DocumentChangeDates(models.Model): - - document = models.ForeignKey(Document) - new_version_date = models.DateTimeField(blank=True, null=True) - normal_change_date = models.DateTimeField(blank=True, null=True) - significant_change_date = models.DateTimeField(blank=True, null=True) diff --git a/ietf/community/resources.py b/ietf/community/resources.py index 3316fb8a7..8446aff67 100644 --- a/ietf/community/resources.py +++ b/ietf/community/resources.py @@ -88,20 +88,3 @@ class EmailSubscriptionResource(ModelResource): "community_list": ALL_WITH_RELATIONS, } api.community.register(EmailSubscriptionResource()) - -from ietf.doc.resources import DocumentResource -class DocumentChangeDatesResource(ModelResource): - document = ToOneField(DocumentResource, 'document') - class Meta: - queryset = DocumentChangeDates.objects.all() - serializer = api.Serializer() - #resource_name = 'documentchangedates' - filtering = { - "id": ALL, - "new_version_date": ALL, - "normal_change_date": ALL, - "significant_change_date": ALL, - "document": ALL_WITH_RELATIONS, - } -api.community.register(DocumentChangeDatesResource()) - From b7232d0ab777d68337ae98b135680269095a5978 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 14 Jan 2016 18:04:05 +0000 Subject: [PATCH 07/14] Revamp tracked/not tracked icons in search results to not do one query per document, and make them work on all search result pages, move the utility function to community/ - Legacy-Id: 10690 --- ietf/community/utils.py | 14 ++++++++ ietf/doc/views_search.py | 34 +++++-------------- ietf/group/info.py | 16 ++++----- ietf/group/views_stream.py | 2 +- .../doc/search/search_result_row.html | 2 +- 5 files changed, 31 insertions(+), 37 deletions(-) diff --git a/ietf/community/utils.py b/ietf/community/utils.py index e120a2f6c..5e6c45358 100644 --- a/ietf/community/utils.py +++ b/ietf/community/utils.py @@ -1,3 +1,4 @@ +from ietf.community.models import CommunityList from ietf.group.models import Role def can_manage_community_list_for_group(user, group): @@ -11,3 +12,16 @@ def can_manage_community_list_for_group(user, group): else: return False +def augment_docs_with_tracking_info(docs, user): + """Add attribute to each document with whether the document is tracked + by the user or not.""" + + tracked = set() + + if user and user.is_authenticated(): + clist = CommunityList.objects.filter(user=user).first() + if clist: + tracked.update(clist.get_documents().filter(pk__in=docs).values_list("pk", flat=True)) + + for d in docs: + d.tracked_in_personal_community_list = d.pk in tracked diff --git a/ietf/doc/views_search.py b/ietf/doc/views_search.py index a4f0c1d40..c2f3386b0 100644 --- a/ietf/doc/views_search.py +++ b/ietf/doc/views_search.py @@ -33,7 +33,6 @@ import datetime, re from django import forms -from django.core.exceptions import ObjectDoesNotExist from django.core.urlresolvers import reverse as urlreverse from django.shortcuts import render from django.db.models import Q @@ -41,7 +40,6 @@ from django.http import Http404, HttpResponseBadRequest, HttpResponse, HttpRespo import debug # pyflakes:ignore -from ietf.community.models import CommunityList from ietf.doc.models import ( Document, DocHistory, DocAlias, State, RelatedDocument, DocEvent, LastCallDocEvent, TelechatDocEvent, IESG_SUBSTATE_TAGS ) from ietf.doc.expire import expirable_draft @@ -51,6 +49,7 @@ from ietf.idindex.index import active_drafts_index_by_group from ietf.name.models import DocTagName, DocTypeName, StreamName from ietf.person.models import Person from ietf.utils.draft_search import normalize_draftname +from ietf.community.utils import augment_docs_with_tracking_info class SearchForm(forms.Form): @@ -201,9 +200,9 @@ def fill_in_search_attributes(docs): l.sort() -def retrieve_search_results(form, all_types=False): - +def retrieve_search_results(form, request, all_types=False): """Takes a validated SearchForm and return the results.""" + if not form.is_valid(): raise ValueError("SearchForm doesn't validate: %s" % form.errors) @@ -322,6 +321,8 @@ def retrieve_search_results(form, all_types=False): results.sort(key=sort_key) + augment_docs_with_tracking_info(results, request.user) + # fill in a meta dict with some information for rendering the result table if len(results) == MAX: meta['max'] = MAX @@ -345,21 +346,6 @@ def retrieve_search_results(form, all_types=False): return (results, meta) -def get_doc_is_tracked(request, results): - # Determine whether each document is being tracked or not, and remember - # that so we can display the proper track/untrack option. - doc_is_tracked = { } - if request.user.is_authenticated(): - try: - clist = CommunityList.objects.get(user=request.user) - clist.update() - except ObjectDoesNotExist: - return doc_is_tracked - for doc in results: - if clist.get_documents().filter(name=doc.name).count() > 0: - doc_is_tracked[doc.name] = True - return doc_is_tracked - def search(request): if request.GET: # backwards compatibility @@ -375,17 +361,15 @@ def search(request): if not form.is_valid(): return HttpResponseBadRequest("form not valid: %s" % form.errors) - results, meta = retrieve_search_results(form) + results, meta = retrieve_search_results(form, request) meta['searching'] = True else: form = SearchForm() results = [] meta = { 'by': None, 'advanced': False, 'searching': False } - doc_is_tracked = get_doc_is_tracked(request, results) - return render(request, 'doc/search/search.html', { - 'form':form, 'docs':results, 'doc_is_tracked':doc_is_tracked, 'meta':meta, }, + 'form':form, 'docs':results, 'meta':meta, }, ) def frontpage(request): @@ -550,7 +534,7 @@ def docs_for_ad(request, name): form = SearchForm({'by':'ad','ad': ad.id, 'rfcs':'on', 'activedrafts':'on', 'olddrafts':'on', 'sort': 'status'}) - results, meta = retrieve_search_results(form, all_types=True) + results, meta = retrieve_search_results(form, request, all_types=True) results.sort(key=ad_dashboard_sort_key) del meta["headers"][-1] # @@ -564,7 +548,7 @@ def docs_for_ad(request, name): def drafts_in_last_call(request): lc_state = State.objects.get(type="draft-iesg", slug="lc").pk form = SearchForm({'by':'state','state': lc_state, 'rfcs':'on', 'activedrafts':'on'}) - results, meta = retrieve_search_results(form) + results, meta = retrieve_search_results(form, request) return render(request, 'doc/drafts_in_last_call.html', { 'form':form, 'docs':results, 'meta':meta diff --git a/ietf/group/info.py b/ietf/group/info.py index 8a9f5c564..69d148a2e 100644 --- a/ietf/group/info.py +++ b/ietf/group/info.py @@ -47,7 +47,7 @@ from django.views.decorators.cache import cache_page from django.db.models import Q from django.utils.safestring import mark_safe -from ietf.doc.views_search import SearchForm, retrieve_search_results, get_doc_is_tracked +from ietf.doc.views_search import SearchForm, retrieve_search_results from ietf.doc.models import Document, State, DocAlias, RelatedDocument from ietf.doc.utils import get_chartering_type from ietf.doc.templatetags.ietf_filters import clean_whitespace @@ -378,13 +378,13 @@ def construct_group_menu_context(request, group, selected, group_type, others): return d -def search_for_group_documents(group): +def search_for_group_documents(group, request): form = SearchForm({ 'by':'group', 'group': group.acronym or "", 'rfcs':'on', 'activedrafts': 'on' }) - docs, meta = retrieve_search_results(form) + docs, meta = retrieve_search_results(form, request) # get the related docs form_related = SearchForm({ 'by':'group', 'name': u'-%s-' % group.acronym, 'activedrafts': 'on' }) - raw_docs_related, meta_related = retrieve_search_results(form_related) + raw_docs_related, meta_related = retrieve_search_results(form_related, request) docs_related = [] for d in raw_docs_related: @@ -423,17 +423,13 @@ def group_documents(request, acronym, group_type=None): if not group.features.has_documents: raise Http404 - docs, meta, docs_related, meta_related = search_for_group_documents(group) - - doc_is_tracked = get_doc_is_tracked(request, docs) - doc_is_tracked.update(get_doc_is_tracked(request, docs_related)) + docs, meta, docs_related, meta_related = search_for_group_documents(group, request) context = construct_group_menu_context(request, group, "documents", group_type, { 'docs': docs, 'meta': meta, 'docs_related': docs_related, 'meta_related': meta_related, - 'doc_is_tracked': doc_is_tracked, }) return render(request, 'group/group_documents.html', context) @@ -444,7 +440,7 @@ def group_documents_txt(request, acronym, group_type=None): if not group.features.has_documents: raise Http404 - docs, meta, docs_related, meta_related = search_for_group_documents(group) + docs, meta, docs_related, meta_related = search_for_group_documents(group, request) for d in docs: d.prefix = d.get_state().name diff --git a/ietf/group/views_stream.py b/ietf/group/views_stream.py index 6ec34702c..f3f95a337 100644 --- a/ietf/group/views_stream.py +++ b/ietf/group/views_stream.py @@ -29,7 +29,7 @@ def stream_documents(request, acronym): stream = StreamName.objects.get(slug=acronym) form = SearchForm({'by':'stream', 'stream':acronym, 'rfcs':'on', 'activedrafts':'on'}) - docs, meta = retrieve_search_results(form) + docs, meta = retrieve_search_results(form, request) return render_to_response('group/stream_documents.html', {'stream':stream, 'docs':docs, 'meta':meta, 'editable':editable }, context_instance=RequestContext(request)) class StreamEditForm(forms.Form): diff --git a/ietf/templates/doc/search/search_result_row.html b/ietf/templates/doc/search/search_result_row.html index 1944819ad..04f630b6f 100644 --- a/ietf/templates/doc/search/search_result_row.html +++ b/ietf/templates/doc/search/search_result_row.html @@ -13,7 +13,7 @@ {% if user.is_authenticated %} - {% if doc.name in doc_is_tracked %} + {% if doc.tracked_in_personal_community_list %} From 540ef748f7f1ea46937a5dde21e17ea3e76ce9fa Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 19 Jan 2016 11:27:47 +0000 Subject: [PATCH 08/14] Fix community list track/untrack on document page, also fix the attached Javascript which had a split personality regarding how to deal with a repeated click - Legacy-Id: 10714 --- ietf/doc/views_doc.py | 14 ++------------ ietf/group/tests_info.py | 2 +- ietf/static/ietf/js/ietf.js | 14 +++++--------- ietf/templates/doc/document_draft.html | 7 ++----- ietf/templates/doc/search/search_result_row.html | 15 ++++++--------- 5 files changed, 16 insertions(+), 36 deletions(-) diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index dffe6e562..d651ac980 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -36,7 +36,6 @@ from django.http import HttpResponse, Http404 , HttpResponseForbidden from django.shortcuts import render, render_to_response, get_object_or_404, redirect from django.template import RequestContext from django.template.loader import render_to_string -from django.core.exceptions import ObjectDoesNotExist from django.core.urlresolvers import reverse as urlreverse from django.conf import settings from django import forms @@ -50,7 +49,7 @@ from ietf.doc.utils import ( add_links_in_new_revision_events, augment_events_wi can_adopt_draft, get_chartering_type, get_document_content, get_tags_for_stream_id, needed_ballot_positions, nice_consensus, prettify_std_name, update_telechat, has_same_ballot, get_initial_notify, make_notify_changed_event, crawl_history) -from ietf.community.models import CommunityList +from ietf.community.utils import augment_docs_with_tracking_info from ietf.group.models import Role from ietf.group.utils import can_manage_group_type, can_manage_materials from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, user_is_person, role_required @@ -341,15 +340,7 @@ def document_main(request, name, rev=None): elif can_edit_stream_info and (not iesg_state or iesg_state.slug == 'watching'): actions.append(("Submit to IESG for Publication", urlreverse('doc_to_iesg', kwargs=dict(name=doc.name)))) - tracking_document = False - if request.user.is_authenticated(): - try: - clist = CommunityList.objects.get(user=request.user) - clist.update() - if clist.get_documents().filter(name=doc.name).count() > 0: - tracking_document = True - except ObjectDoesNotExist: - pass + augment_docs_with_tracking_info([doc], request.user) replaces = [d.name for d in doc.related_that_doc("replaces")] replaced_by = [d.name for d in doc.related_that("replaces")] @@ -416,7 +407,6 @@ def document_main(request, name, rev=None): shepherd_writeup=shepherd_writeup, search_archive=search_archive, actions=actions, - tracking_document=tracking_document, ), context_instance=RequestContext(request)) diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index d7f217c6e..9852925fc 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -197,7 +197,7 @@ class GroupPagesTests(TestCase): self.client.login(username="secretary", password="secretary+password") r = self.client.get(url) q = PyQuery(r.content) - self.assertTrue(any([draft2.name in x.attrib['href'] for x in q('table td a.community-list-add-remove-doc')])) + self.assertTrue(any([draft2.name in x.attrib['href'] for x in q('table td a.track-untrack-doc')])) # test the txt version too while we're at it url = urlreverse('ietf.group.info.group_documents_txt', kwargs=dict(group_type=group.type_id, acronym=group.acronym)) diff --git a/ietf/static/ietf/js/ietf.js b/ietf/static/ietf/js/ietf.js index 5ab14e5c0..6b5fd872a 100644 --- a/ietf/static/ietf/js/ietf.js +++ b/ietf/static/ietf/js/ietf.js @@ -100,10 +100,10 @@ $(document).ready(function () { } // search results - $('.community-list-add-remove-doc').click(function(e) { + $('.track-untrack-doc').click(function(e) { e.preventDefault(); - var trigger = $(this); - $.ajax({ + var trigger = $(this); + $.ajax({ url: trigger.attr('href'), type: 'POST', cache: false, @@ -111,12 +111,8 @@ $(document).ready(function () { success: function(response){ if (response.success) { trigger.parent().find(".tooltip").remove(); - trigger.find("span.fa").toggleClass("fa-bookmark fa-bookmark-o"); - if (trigger.hasClass('btn')) { - trigger.attr('disabled', true).blur(); - } else { - trigger.contents().unwrap().blur(); - } + trigger.addClass("hide"); + trigger.parent().find(".track-untrack-doc").not(trigger).removeClass("hide"); } } }); diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 70b55f621..35406c499 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -434,11 +434,8 @@ {% if user.is_authenticated %} - {% if tracking_document %} - Untrack - {% else %} - Track - {% endif %} + Untrack + Track {% endif %} {% if can_edit and iesg_state %} diff --git a/ietf/templates/doc/search/search_result_row.html b/ietf/templates/doc/search/search_result_row.html index 04f630b6f..f0c65cdb0 100644 --- a/ietf/templates/doc/search/search_result_row.html +++ b/ietf/templates/doc/search/search_result_row.html @@ -13,15 +13,12 @@ {% if user.is_authenticated %} - {% if doc.tracked_in_personal_community_list %} - - - - {% else %} - - - - {% endif %} + + + + + + {% endif %} From d67a96b4f033864ffbaf53365dd0e4de458a97aa Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Wed, 20 Jan 2016 14:19:06 +0000 Subject: [PATCH 09/14] Set daemon = True on the thread in the SMTP test server running while running tests. This fixes the annoying problem of the python process staying alive after certain bugs in the test invocation or after a plain Ctrl + c. - Legacy-Id: 10718 --- ietf/utils/test_smtpserver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ietf/utils/test_smtpserver.py b/ietf/utils/test_smtpserver.py index 1475c9637..73535d010 100644 --- a/ietf/utils/test_smtpserver.py +++ b/ietf/utils/test_smtpserver.py @@ -14,7 +14,8 @@ class AsyncCoreLoopThread(object): """Start the listening service""" self.exit_condition = [] kwargs={'exit_condition':self.exit_condition,'timeout':1.0} - self.thread = threading.Thread(target=self.wrap_loop,kwargs=kwargs ) + self.thread = threading.Thread(target=self.wrap_loop, kwargs=kwargs) + self.thread.daemon = True self.thread.start() def stop(self): From 197bc07771f8d8c74b9e4c983fc85aa9fd2500c9 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 25 Jan 2016 18:11:49 +0000 Subject: [PATCH 10/14] Refactor search-related utilities slightly to make them more independent of the search form, to enable them to be reused in the community lists. Also clean up the related code a bit, use SearchForm to handle other document types, and fix missing ids in the HTML so that one can click the "by" labels to hit the corresponding radio button. - Legacy-Id: 10729 --- ietf/doc/utils_search.py | 180 +++++++++++++++++ ietf/doc/views_search.py | 222 +++------------------ ietf/group/info.py | 10 +- ietf/group/views_stream.py | 9 +- ietf/iesg/views.py | 4 +- ietf/templates/doc/search/search_form.html | 18 +- 6 files changed, 225 insertions(+), 218 deletions(-) create mode 100644 ietf/doc/utils_search.py diff --git a/ietf/doc/utils_search.py b/ietf/doc/utils_search.py new file mode 100644 index 000000000..fffc8bfd6 --- /dev/null +++ b/ietf/doc/utils_search.py @@ -0,0 +1,180 @@ +from ietf.doc.models import Document, DocAlias, RelatedDocument, DocEvent, TelechatDocEvent +from ietf.doc.expire import expirable_draft +from ietf.community.utils import augment_docs_with_tracking_info + +def wrap_value(v): + return lambda: v + +def fill_in_document_table_attributes(docs): + # fill in some attributes for the document table results to save + # some hairy template code and avoid repeated SQL queries + + docs_dict = dict((d.pk, d) for d in docs) + doc_ids = docs_dict.keys() + + rfc_aliases = dict(DocAlias.objects.filter(name__startswith="rfc", document__in=doc_ids).values_list("document_id", "name")) + + # latest event cache + event_types = ("published_rfc", + "changed_ballot_position", + "started_iesg_process", + "new_revision") + for d in docs: + d.latest_event_cache = dict() + for e in event_types: + d.latest_event_cache[e] = None + + for e in DocEvent.objects.filter(doc__in=doc_ids, type__in=event_types).order_by('time'): + docs_dict[e.doc_id].latest_event_cache[e.type] = e + + # telechat date, can't do this with above query as we need to get TelechatDocEvents out + seen = set() + for e in TelechatDocEvent.objects.filter(doc__in=doc_ids, type="scheduled_for_telechat").order_by('-time'): + if e.doc_id not in seen: + d = docs_dict[e.doc_id] + d.telechat_date = wrap_value(d.telechat_date(e)) + seen.add(e.doc_id) + + # misc + for d in docs: + # emulate canonical name which is used by a lot of the utils + d.canonical_name = wrap_value(rfc_aliases[d.pk] if d.pk in rfc_aliases else d.name) + + if d.rfc_number() != None and d.latest_event_cache["published_rfc"]: + d.latest_revision_date = d.latest_event_cache["published_rfc"].time + elif d.latest_event_cache["new_revision"]: + d.latest_revision_date = d.latest_event_cache["new_revision"].time + else: + d.latest_revision_date = d.time + + if d.type_id == "draft": + if d.get_state_slug() == "rfc": + d.search_heading = "RFC" + elif d.get_state_slug() in ("ietf-rm", "auth-rm"): + d.search_heading = "Withdrawn Internet-Draft" + else: + d.search_heading = "%s Internet-Draft" % d.get_state() + else: + d.search_heading = "%s" % (d.type,); + + d.expirable = expirable_draft(d) + + if d.get_state_slug() != "rfc": + d.milestones = d.groupmilestone_set.filter(state="active").order_by("time").select_related("group") + + + + # RFCs + + # errata + erratas = set(Document.objects.filter(tags="errata", name__in=rfc_aliases.keys()).distinct().values_list("name", flat=True)) + for d in docs: + d.has_errata = d.name in erratas + + # obsoleted/updated by + for a in rfc_aliases: + d = docs_dict[a] + d.obsoleted_by_list = [] + d.updated_by_list = [] + + xed_by = RelatedDocument.objects.filter(target__name__in=rfc_aliases.values(), + relationship__in=("obs", "updates")).select_related('target__document_id') + rel_rfc_aliases = dict(DocAlias.objects.filter(name__startswith="rfc", + document__in=[rel.source_id for rel in xed_by]).values_list('document_id', 'name')) + for rel in xed_by: + d = docs_dict[rel.target.document_id] + if rel.relationship_id == "obs": + l = d.obsoleted_by_list + elif rel.relationship_id == "updates": + l = d.updated_by_list + l.append(rel_rfc_aliases[rel.source_id].upper()) + l.sort() + + +def prepare_document_table(request, docs, query=None, max_results=500): + """Take a queryset of documents and a QueryDict with sorting info + and return list of documents with attributes filled in for + displaying a full table of information about the documents, plus + dict with information about the columns.""" + + if not isinstance(docs, list): + # evaluate and fill in attribute results immediately to decrease + # the number of queries + docs = docs.select_related("ad", "ad__person", "std_level", "intended_std_level", "group", "stream") + docs = docs.prefetch_related("states__type", "tags") + + docs = list(docs[:max_results]) + + fill_in_document_table_attributes(docs) + augment_docs_with_tracking_info(docs, request.user) + + meta = {} + + sort_key = query and query.get('sort') + + # sort + def sort_key(d): + res = [] + + rfc_num = d.rfc_number() + + + if d.type_id == "draft": + res.append(["Active", "Expired", "Replaced", "Withdrawn", "RFC"].index(d.search_heading.split()[0])) + else: + res.append(d.type_id); + res.append("-"); + res.append(d.get_state_slug()); + res.append("-"); + + if sort_key == "title": + res.append(d.title) + elif sort_key == "date": + res.append(str(d.latest_revision_date)) + elif sort_key == "status": + if rfc_num != None: + res.append(int(rfc_num)) + else: + res.append(d.get_state().order if d.get_state() else None) + elif sort_key == "ipr": + res.append(len(d.ipr())) + elif sort_key == "ad": + if rfc_num != None: + res.append(int(rfc_num)) + elif d.get_state_slug() == "active": + if d.get_state("draft-iesg"): + res.append(d.get_state("draft-iesg").order) + else: + res.append(0) + else: + if rfc_num != None: + res.append(int(rfc_num)) + else: + res.append(d.canonical_name()) + + return res + + docs.sort(key=sort_key) + + # fill in a meta dict with some information for rendering the table + if len(docs) == max_results: + meta['max'] = max_results + + meta['headers'] = [{'title': 'Document', 'key':'document'}, + {'title': 'Title', 'key':'title'}, + {'title': 'Date', 'key':'date'}, + {'title': 'Status', 'key':'status'}, + {'title': 'IPR', 'key':'ipr'}, + {'title': 'AD / Shepherd', 'key':'ad'}] + + if query and hasattr(query, "urlencode"): # fed a Django QueryDict + d = query.copy() + for h in meta['headers']: + d["sort"] = h["key"] + h["sort_url"] = "?" + d.urlencode() + if h['key'] == sort_key: + h['sorted'] = True + + return (docs, meta) + + diff --git a/ietf/doc/views_search.py b/ietf/doc/views_search.py index c2f3386b0..584596345 100644 --- a/ietf/doc/views_search.py +++ b/ietf/doc/views_search.py @@ -40,16 +40,15 @@ from django.http import Http404, HttpResponseBadRequest, HttpResponse, HttpRespo import debug # pyflakes:ignore -from ietf.doc.models import ( Document, DocHistory, DocAlias, State, RelatedDocument, - DocEvent, LastCallDocEvent, TelechatDocEvent, IESG_SUBSTATE_TAGS ) -from ietf.doc.expire import expirable_draft +from ietf.doc.models import ( Document, DocHistory, DocAlias, State, + LastCallDocEvent, IESG_SUBSTATE_TAGS ) from ietf.doc.fields import select2_id_doc_name_json from ietf.group.models import Group from ietf.idindex.index import active_drafts_index_by_group from ietf.name.models import DocTagName, DocTypeName, StreamName from ietf.person.models import Person from ietf.utils.draft_search import normalize_draftname -from ietf.community.utils import augment_docs_with_tracking_info +from ietf.doc.utils_search import prepare_document_table class SearchForm(forms.Form): @@ -58,7 +57,7 @@ class SearchForm(forms.Form): activedrafts = forms.BooleanField(required=False, initial=True) olddrafts = forms.BooleanField(required=False, initial=False) - by = forms.ChoiceField(choices=[(x,x) for x in ('author','group','area','ad','state','stream')], required=False, initial='wg') + by = forms.ChoiceField(choices=[(x,x) for x in ('author','group','area','ad','state','stream')], required=False, initial='group') author = forms.CharField(required=False) group = forms.CharField(required=False) stream = forms.ModelChoiceField(StreamName.objects.all().order_by('name'), empty_label="any stream", required=False) @@ -69,7 +68,7 @@ class SearchForm(forms.Form): sort = forms.ChoiceField(choices=(("document", "Document"), ("title", "Title"), ("date", "Date"), ("status", "Status"), ("ipr", "Ipr"), ("ad", "AD")), required=False, widget=forms.HiddenInput) - doctypes = DocTypeName.objects.exclude(slug='draft').order_by('name'); + doctypes = forms.ModelMultipleChoiceField(queryset=DocTypeName.objects.exclude(slug='draft').order_by('name'), required=False) def __init__(self, *args, **kwargs): super(SearchForm, self).__init__(*args, **kwargs) @@ -111,96 +110,7 @@ class SearchForm(forms.Form): q['state'] = q['substate'] = None return q -def wrap_value(v): - return lambda: v - -def fill_in_search_attributes(docs): - # fill in some attributes for the search results to save some - # hairy template code and avoid repeated SQL queries - - docs_dict = dict((d.pk, d) for d in docs) - doc_ids = docs_dict.keys() - - rfc_aliases = dict(DocAlias.objects.filter(name__startswith="rfc", document__in=doc_ids).values_list("document_id", "name")) - - # latest event cache - event_types = ("published_rfc", - "changed_ballot_position", - "started_iesg_process", - "new_revision") - for d in docs: - d.latest_event_cache = dict() - for e in event_types: - d.latest_event_cache[e] = None - - for e in DocEvent.objects.filter(doc__in=doc_ids, type__in=event_types).order_by('time'): - docs_dict[e.doc_id].latest_event_cache[e.type] = e - - # telechat date, can't do this with above query as we need to get TelechatDocEvents out - seen = set() - for e in TelechatDocEvent.objects.filter(doc__in=doc_ids, type="scheduled_for_telechat").order_by('-time'): - if e.doc_id not in seen: - d = docs_dict[e.doc_id] - d.telechat_date = wrap_value(d.telechat_date(e)) - seen.add(e.doc_id) - - # misc - for d in docs: - # emulate canonical name which is used by a lot of the utils - d.canonical_name = wrap_value(rfc_aliases[d.pk] if d.pk in rfc_aliases else d.name) - - if d.rfc_number() != None and d.latest_event_cache["published_rfc"]: - d.latest_revision_date = d.latest_event_cache["published_rfc"].time - elif d.latest_event_cache["new_revision"]: - d.latest_revision_date = d.latest_event_cache["new_revision"].time - else: - d.latest_revision_date = d.time - - if d.type_id == "draft": - if d.get_state_slug() == "rfc": - d.search_heading = "RFC" - elif d.get_state_slug() in ("ietf-rm", "auth-rm"): - d.search_heading = "Withdrawn Internet-Draft" - else: - d.search_heading = "%s Internet-Draft" % d.get_state() - else: - d.search_heading = "%s" % (d.type,); - - d.expirable = expirable_draft(d) - - if d.get_state_slug() != "rfc": - d.milestones = d.groupmilestone_set.filter(state="active").order_by("time").select_related("group") - - - - # RFCs - - # errata - erratas = set(Document.objects.filter(tags="errata", name__in=rfc_aliases.keys()).distinct().values_list("name", flat=True)) - for d in docs: - d.has_errata = d.name in erratas - - # obsoleted/updated by - for a in rfc_aliases: - d = docs_dict[a] - d.obsoleted_by_list = [] - d.updated_by_list = [] - - xed_by = RelatedDocument.objects.filter(target__name__in=rfc_aliases.values(), - relationship__in=("obs", "updates")).select_related('target__document_id') - rel_rfc_aliases = dict(DocAlias.objects.filter(name__startswith="rfc", - document__in=[rel.source_id for rel in xed_by]).values_list('document_id', 'name')) - for rel in xed_by: - d = docs_dict[rel.target.document_id] - if rel.relationship_id == "obs": - l = d.obsoleted_by_list - elif rel.relationship_id == "updates": - l = d.updated_by_list - l.append(rel_rfc_aliases[rel.source_id].upper()) - l.sort() - - -def retrieve_search_results(form, request, all_types=False): +def retrieve_search_results(form, all_types=False): """Takes a validated SearchForm and return the results.""" if not form.is_valid(): @@ -208,29 +118,19 @@ def retrieve_search_results(form, request, all_types=False): query = form.cleaned_data - types=[]; - meta = {} - - if (query['activedrafts'] or query['olddrafts'] or query['rfcs']): - types.append('draft') - - # Advanced document types are data-driven, so we need to read them from the - # raw form.data field (and track their checked/unchecked state ourselves) - meta['checked'] = {} - alltypes = DocTypeName.objects.exclude(slug='draft').order_by('name'); - for doctype in alltypes: - if form.data.__contains__('include-' + doctype.slug): - types.append(doctype.slug) - meta['checked'][doctype.slug] = True - - if len(types) == 0 and not all_types: - return ([], {}) - - MAX = 500 - if all_types: docs = Document.objects.all() else: + types = [] + + if query['activedrafts'] or query['olddrafts'] or query['rfcs']: + types.append('draft') + + types.extend(query["doctypes"]) + + if not types: + return [] + docs = Document.objects.filter(type__in=types) # name @@ -269,82 +169,7 @@ def retrieve_search_results(form, request, all_types=False): elif by == "stream": docs = docs.filter(stream=query["stream"]) - # evaluate and fill in attribute results immediately to cut down - # the number of queries - docs = docs.select_related("ad", "ad__person", "std_level", "intended_std_level", "group", "stream") - docs = docs.prefetch_related("states__type", "tags") - results = list(docs[:MAX]) - - fill_in_search_attributes(results) - - # sort - def sort_key(d): - res = [] - - rfc_num = d.rfc_number() - - - if d.type_id == "draft": - res.append(["Active", "Expired", "Replaced", "Withdrawn", "RFC"].index(d.search_heading.split()[0] )) - else: - res.append(d.type_id); - res.append("-"); - res.append(d.get_state_slug()); - res.append("-"); - - if query["sort"] == "title": - res.append(d.title) - elif query["sort"] == "date": - res.append(str(d.latest_revision_date)) - elif query["sort"] == "status": - if rfc_num != None: - res.append(int(rfc_num)) - else: - res.append(d.get_state().order if d.get_state() else None) - elif query["sort"] == "ipr": - res.append(len(d.ipr())) - elif query["sort"] == "ad": - if rfc_num != None: - res.append(int(rfc_num)) - elif d.get_state_slug() == "active": - if d.get_state("draft-iesg"): - res.append(d.get_state("draft-iesg").order) - else: - res.append(0) - else: - if rfc_num != None: - res.append(int(rfc_num)) - else: - res.append(d.canonical_name()) - - return res - - results.sort(key=sort_key) - - augment_docs_with_tracking_info(results, request.user) - - # fill in a meta dict with some information for rendering the result table - if len(results) == MAX: - meta['max'] = MAX - meta['by'] = query['by'] - meta['advanced'] = bool(query['by'] or len(meta['checked'])) - - meta['headers'] = [{'title': 'Document', 'key':'document'}, - {'title': 'Title', 'key':'title'}, - {'title': 'Date', 'key':'date'}, - {'title': 'Status', 'key':'status'}, - {'title': 'IPR', 'key':'ipr'}, - {'title': 'AD / Shepherd', 'key':'ad'}] - - if hasattr(form.data, "urlencode"): # form was fed a Django QueryDict, not local plain dict - d = form.data.copy() - for h in meta['headers']: - d["sort"] = h["key"] - h["sort_url"] = "?" + d.urlencode() - if h['key'] == query.get('sort'): - h['sorted'] = True - return (results, meta) - + return docs def search(request): if request.GET: @@ -361,12 +186,13 @@ def search(request): if not form.is_valid(): return HttpResponseBadRequest("form not valid: %s" % form.errors) - results, meta = retrieve_search_results(form, request) + results = retrieve_search_results(form) + results, meta = prepare_document_table(request, results, get_params) meta['searching'] = True else: form = SearchForm() results = [] - meta = { 'by': None, 'advanced': False, 'searching': False } + meta = { 'by': None, 'searching': False } return render(request, 'doc/search/search.html', { 'form':form, 'docs':results, 'meta':meta, }, @@ -426,7 +252,7 @@ def search_for_name(request, name): else: for t in doctypenames: if n.startswith(t.prefix): - search_args += "&include-%s=on" % t.slug + search_args += "&doctypes=%s" % t.slug break else: search_args += "&rfcs=on&activedrafts=on&olddrafts=on" @@ -533,8 +359,8 @@ def docs_for_ad(request, name): raise Http404 form = SearchForm({'by':'ad','ad': ad.id, 'rfcs':'on', 'activedrafts':'on', 'olddrafts':'on', - 'sort': 'status'}) - results, meta = retrieve_search_results(form, request, all_types=True) + 'sort': 'status', 'doctypes': list(DocTypeName.objects.exclude(slug='draft').values_list("pk", flat=True))}) + results, meta = prepare_document_table(request, retrieve_search_results(form), form.data) results.sort(key=ad_dashboard_sort_key) del meta["headers"][-1] # @@ -548,7 +374,7 @@ def docs_for_ad(request, name): def drafts_in_last_call(request): lc_state = State.objects.get(type="draft-iesg", slug="lc").pk form = SearchForm({'by':'state','state': lc_state, 'rfcs':'on', 'activedrafts':'on'}) - results, meta = retrieve_search_results(form, request) + results, meta = prepare_document_table(request, retrieve_search_results(form), form.data) return render(request, 'doc/drafts_in_last_call.html', { 'form':form, 'docs':results, 'meta':meta diff --git a/ietf/group/info.py b/ietf/group/info.py index 69d148a2e..62797d99e 100644 --- a/ietf/group/info.py +++ b/ietf/group/info.py @@ -47,10 +47,10 @@ from django.views.decorators.cache import cache_page from django.db.models import Q from django.utils.safestring import mark_safe -from ietf.doc.views_search import SearchForm, retrieve_search_results from ietf.doc.models import Document, State, DocAlias, RelatedDocument from ietf.doc.utils import get_chartering_type from ietf.doc.templatetags.ietf_filters import clean_whitespace +from ietf.doc.utils_search import prepare_document_table from ietf.group.models import Group, Role, ChangeStateGroupEvent from ietf.name.models import GroupTypeName from ietf.group.utils import get_charter_text, can_manage_group_type, milestone_reviewer_for_group_type @@ -379,12 +379,12 @@ def construct_group_menu_context(request, group, selected, group_type, others): return d def search_for_group_documents(group, request): - form = SearchForm({ 'by':'group', 'group': group.acronym or "", 'rfcs':'on', 'activedrafts': 'on' }) - docs, meta = retrieve_search_results(form, request) + qs = Document.objects.filter(states__type="draft", states__slug__in=["active", "rfc"], group=group) + docs, meta = prepare_document_table(request, qs) # get the related docs - form_related = SearchForm({ 'by':'group', 'name': u'-%s-' % group.acronym, 'activedrafts': 'on' }) - raw_docs_related, meta_related = retrieve_search_results(form_related, request) + qs_related = Document.objects.filter(states__type="draft", states__slug="active", name__contains="-%s-" % group.acronym) + raw_docs_related, meta_related = prepare_document_table(request, qs_related) docs_related = [] for d in raw_docs_related: diff --git a/ietf/group/views_stream.py b/ietf/group/views_stream.py index f3f95a337..9a8d5887c 100644 --- a/ietf/group/views_stream.py +++ b/ietf/group/views_stream.py @@ -5,7 +5,8 @@ from django.template import RequestContext from django.http import Http404, HttpResponseForbidden from django import forms -from ietf.doc.views_search import SearchForm, retrieve_search_results +from ietf.doc.models import Document +from ietf.doc.utils_search import prepare_document_table from ietf.group.models import Group, GroupEvent, Role from ietf.group.utils import save_group_in_history from ietf.ietfauth.utils import has_role @@ -27,9 +28,9 @@ def stream_documents(request, acronym): group = get_object_or_404(Group, acronym=acronym) editable = has_role(request.user, "Secretariat") or group.has_role(request.user, "chair") stream = StreamName.objects.get(slug=acronym) - form = SearchForm({'by':'stream', 'stream':acronym, - 'rfcs':'on', 'activedrafts':'on'}) - docs, meta = retrieve_search_results(form, request) + + qs = Document.objects.filter(states__type="draft", states__slug__in=["active", "rfc"], stream=acronym) + docs, meta = prepare_document_table(request, qs) return render_to_response('group/stream_documents.html', {'stream':stream, 'docs':docs, 'meta':meta, 'editable':editable }, context_instance=RequestContext(request)) class StreamEditForm(forms.Form): diff --git a/ietf/iesg/views.py b/ietf/iesg/views.py index 6fbd07cc2..5816c88fe 100644 --- a/ietf/iesg/views.py +++ b/ietf/iesg/views.py @@ -57,7 +57,7 @@ from ietf.iesg.agenda import agenda_data, agenda_sections, fill_in_agenda_docs, from ietf.iesg.models import TelechatDate from ietf.ietfauth.utils import has_role, role_required, user_is_person from ietf.person.models import Person -from ietf.doc.views_search import fill_in_search_attributes +from ietf.doc.utils_search import fill_in_document_table_attributes def review_decisions(request, year=None): events = DocEvent.objects.filter(type__in=("iesg_disapproved", "iesg_approved")) @@ -363,7 +363,7 @@ def agenda_documents(request): sections = agenda_sections() # augment the docs with the search attributes, since we're using # the search_result_row view to display them (which expects them) - fill_in_search_attributes(docs_by_date[date]) + fill_in_document_table_attributes(docs_by_date[date]) fill_in_agenda_docs(date, sections, docs_by_date[date]) telechats.append({ diff --git a/ietf/templates/doc/search/search_form.html b/ietf/templates/doc/search/search_form.html index 573a63c09..4133b12d3 100644 --- a/ietf/templates/doc/search/search_form.html +++ b/ietf/templates/doc/search/search_form.html @@ -42,10 +42,10 @@ - {% for doc_type in form.doctypes %} + {% for value, label in form.fields.doctypes.choices %}
    -
    {% endfor %} @@ -54,7 +54,7 @@
    - +
    @@ -64,7 +64,7 @@
    - +
    @@ -75,7 +75,7 @@
    - +
    @@ -85,7 +85,7 @@
    - +
    @@ -95,7 +95,7 @@
    - +
    @@ -108,7 +108,7 @@
    - +
    From 1c3ec64e0373746913d62b60588c0fa1b3a3e315 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 25 Jan 2016 18:26:33 +0000 Subject: [PATCH 11/14] Eliminate odd group_id assignment in submission code, probably left over from a previous generation of the code. It causes trouble with a revamped community rule engine. - Legacy-Id: 10730 --- ietf/submit/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ietf/submit/utils.py b/ietf/submit/utils.py index 24c0c5f8c..0ca57276f 100644 --- a/ietf/submit/utils.py +++ b/ietf/submit/utils.py @@ -144,7 +144,7 @@ def post_submission(request, submission): if not (group.type_id == "individ" and draft.group and draft.group.type_id == "area"): # don't overwrite an assigned area if it's still an individual # submission - draft.group_id = group.pk + draft.group = group draft.rev = submission.rev draft.pages = submission.pages draft.abstract = submission.abstract From 5f4082d595f802fe7f2e50063b4df0066b8fefa2 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 14 Mar 2016 10:44:57 +0000 Subject: [PATCH 12/14] Overhaul of the community list code. From a user perspective: Use friendlier URLs for lists. Reuse the search results table for displaying lists. Simplify the management pages and improve the search rule UI to help fill in the values and validating them, instead of just providing a text field. Fixes #1874. Add an explicit button for adding individual documents. Include all changes in the document change streams, not just some changes. Fix a concurrency issue that allows changed documents to escape the search rules. Don't create an empty list just be logging in. From a code maintenance perspective: Clean up the models. Replace the background caching scheme with direct queries. Get rid of a big chunk of code. Speed up the code that adds track buttons to search results. Add tests of all community views. Fixes #1422. Also fix some minor bugs and oddities here and there. There's still some work to do with respect to integrating the group lists better. - Legacy-Id: 10921 --- ietf/community/constants.py | 23 - ietf/community/display.py | 203 -------- ietf/community/forms.py | 175 ++++--- ietf/community/management/__init__.py | 0 .../community/management/commands/__init__.py | 0 .../commands/update_community_lists.py | 36 -- ietf/community/migrations/0003_cleanup.py | 108 ++++ .../community/migrations/0004_cleanup_data.py | 183 +++++++ ietf/community/models.py | 220 ++------ ietf/community/resources.py | 42 +- ietf/community/rules.py | 292 ----------- ietf/community/templatetags/__init__.py | 0 ietf/community/templatetags/community_tags.py | 22 - ietf/community/tests.py | 281 ++++++++++- ietf/community/urls.py | 42 +- ietf/community/utils.py | 184 ++++++- ietf/community/views.py | 474 +++++++++--------- ietf/doc/views_draft.py | 1 + ietf/static/ietf/css/ietf.css | 7 + ietf/static/ietf/js/manage-community-list.js | 27 + ietf/templates/base/menu.html | 4 +- .../templates/community/{public => }/atom.xml | 0 ietf/templates/community/confirm_email.txt | 14 + .../community/confirm_subscription.html | 19 + .../community/customize_display.html | 26 - ietf/templates/community/display_field.html | 2 - ietf/templates/community/manage_clist.html | 132 ----- ietf/templates/community/manage_list.html | 123 +++++ .../{public => }/notification_email.txt | 8 +- .../templates/community/public/subscribe.html | 32 -- .../community/public/subscribe_email.txt | 12 - .../public/subscription_confirm.html | 16 - .../community/public/unsubscribe.html | 36 -- .../public/unsubscription_confirm.html | 18 - .../templates/community/public/view_list.html | 23 - ietf/templates/community/raw_view.html | 50 -- ietf/templates/community/subscription.html | 43 ++ .../{public => }/unsubscribe_email.txt | 0 ietf/templates/community/view_list.html | 40 +- ietf/templates/doc/document_draft.html | 4 +- .../doc/search/search_result_row.html | 4 +- 41 files changed, 1397 insertions(+), 1529 deletions(-) delete mode 100644 ietf/community/constants.py delete mode 100644 ietf/community/display.py delete mode 100644 ietf/community/management/__init__.py delete mode 100644 ietf/community/management/commands/__init__.py delete mode 100644 ietf/community/management/commands/update_community_lists.py create mode 100644 ietf/community/migrations/0003_cleanup.py create mode 100644 ietf/community/migrations/0004_cleanup_data.py delete mode 100644 ietf/community/rules.py delete mode 100644 ietf/community/templatetags/__init__.py delete mode 100644 ietf/community/templatetags/community_tags.py create mode 100644 ietf/static/ietf/js/manage-community-list.js rename ietf/templates/community/{public => }/atom.xml (100%) create mode 100644 ietf/templates/community/confirm_email.txt create mode 100644 ietf/templates/community/confirm_subscription.html delete mode 100644 ietf/templates/community/customize_display.html delete mode 100644 ietf/templates/community/display_field.html delete mode 100644 ietf/templates/community/manage_clist.html create mode 100644 ietf/templates/community/manage_list.html rename ietf/templates/community/{public => }/notification_email.txt (51%) delete mode 100644 ietf/templates/community/public/subscribe.html delete mode 100644 ietf/templates/community/public/subscribe_email.txt delete mode 100644 ietf/templates/community/public/subscription_confirm.html delete mode 100644 ietf/templates/community/public/unsubscribe.html delete mode 100644 ietf/templates/community/public/unsubscription_confirm.html delete mode 100644 ietf/templates/community/public/view_list.html delete mode 100644 ietf/templates/community/raw_view.html create mode 100644 ietf/templates/community/subscription.html rename ietf/templates/community/{public => }/unsubscribe_email.txt (100%) diff --git a/ietf/community/constants.py b/ietf/community/constants.py deleted file mode 100644 index 9bcad3fb5..000000000 --- a/ietf/community/constants.py +++ /dev/null @@ -1,23 +0,0 @@ -SIGNIFICANT_STATES = [ - 'Adopted by a WG', - 'In WG Last Call', - 'WG Consensus: Waiting for Write-Up', - 'Parked WG Document', - 'Dead WG Document', - 'Active IAB Document', - 'Community Review', - 'Sent to the RFC Editor', - 'Active RG Document', - 'In RG Last Call', - 'Awaiting IRSG Reviews', - 'In IESG Review', - 'Document on Hold Based On IESG Request', - 'Submission Received', - 'In ISE Review', - 'In IESG Review', - 'RFC Published', - 'Dead', - 'IESG Evaluation', - 'Publication Requested', - 'In Last Call', -] diff --git a/ietf/community/display.py b/ietf/community/display.py deleted file mode 100644 index 142c81d3d..000000000 --- a/ietf/community/display.py +++ /dev/null @@ -1,203 +0,0 @@ -import datetime - -from django.core.urlresolvers import reverse as urlreverse - -from ietf.doc.models import DocAlias - - -class DisplayField(object): - - codename = '' - description = '' - rfcDescription = '' - - def get_value(self, document, raw=False): - return None - - -class FilenameField(DisplayField): - codename = 'filename' - description = 'I-D filename' - rfcDescription = 'RFC Number' - - def get_value(self, document, raw=False): - if not raw: - return '%s' % (document.get_absolute_url(), document.canonical_name()) - else: - return document.canonical_name() - - -class TitleField(DisplayField): - codename = 'title' - description = 'I-D title' - rfcDescription = 'RFC Title' - - def get_value(self, document, raw=False): - return document.title - - -class DateField(DisplayField): - codename = 'date' - description = 'Date of current I-D' - rfcDescription = 'Date of RFC' - - def get_value(self, document, raw=False): - date = document.latest_event(type='new_revision') - if date: - return date.time.strftime('%Y-%m-%d') - return document.time.strftime('%Y-%m-%d') - - -class StatusField(DisplayField): - codename = 'status' - description = 'Status in the IETF process' - rfcDescription = description - - def get_value(self, document, raw=False): - draft_state = document.get_state('draft') - stream_state = document.get_state('draft-stream-%s' % (document.stream.slug)) if document.stream else None - iesg_state = document.get_state('draft-iesg') or '' - rfceditor_state = document.get_state('draft-rfceditor') - if draft_state.slug == 'rfc': - state = draft_state.name - else: - state = "" - if stream_state: - state = state + ("%s
    " % stream_state.name) - if iesg_state: - state = state + ("%s
    " % iesg_state.name) - if rfceditor_state: - state = state + ("%s
    " % rfceditor_state.name) - # - if draft_state.slug == 'rfc': - tags = "" - else: - tags = [ tag.name for tag in document.tags.all() ] - if tags: - tags = '[%s]' % ",".join(tags) - else: - tags = '' - return '%s
    %s' % (state, tags) - -class WGField(DisplayField): - codename = 'wg_rg' - description = 'Associated WG or RG' - rfcDescription = description - - def get_value(self, document, raw=False): - if raw or not document.group.type_id in ['wg','rg']: - return document.group.acronym - else: - return '%s' % (urlreverse('group_home', kwargs=dict(group_type=document.group.type_id, acronym=document.group.acronym)), document.group.acronym) if (document.group and document.group.acronym != 'none') else '' - - -class ADField(DisplayField): - codename = 'ad' - description = 'Associated AD, if any' - rfcDescription = description - - def get_value(self, document, raw=False): - return document.ad or '' - - -class OneDayField(DisplayField): - codename = '1_day' - description = 'Changed within the last 1 day' - rfcDescription = description - - def get_value(self, document, raw=False): - now = datetime.datetime.now() - last = now - datetime.timedelta(days=1) - if document.docevent_set.filter(time__gte=last): - return raw and 'YES' or '✔' - return '' - - -class TwoDaysField(DisplayField): - codename = '2_days' - description = 'Changed within the last 2 days' - rfcDescription = description - - def get_value(self, document, raw=False): - now = datetime.datetime.now() - last = now - datetime.timedelta(days=2) - if document.docevent_set.filter(time__gte=last): - return raw and 'YES' or '✔' - return '' - - -class SevenDaysField(DisplayField): - codename = '7_days' - description = 'Changed within the last 7 days' - rfcDescription = description - - def get_value(self, document, raw=False): - now = datetime.datetime.now() - last = now - datetime.timedelta(days=7) - if document.docevent_set.filter(time__gte=last): - return raw and 'YES' or '✔' - return '' - - -TYPES_OF_DISPLAY_FIELDS = [(i.codename, i.description) for i in DisplayField.__subclasses__()] - - -class SortMethod(object): - codename = '' - description = '' - - def get_sort_field(self): - return 'pk' - - -class FilenameSort(SortMethod): - codename = 'by_filename' - description = 'Alphabetical by I-D filename and RFC number' - - def get_sort_field(self): - return 'name' - - def get_full_rfc_sort(self, documents): - return [i.document for i in DocAlias.objects.filter(document__in=documents, name__startswith='rfc').order_by('name')] - - -class TitleSort(SortMethod): - codename = 'by_title' - description = 'Alphabetical by document title' - - def get_sort_field(self): - return 'title' - - -class WGSort(SortMethod): - codename = 'by_wg' - description = 'Alphabetical by associated WG' - - def get_sort_field(self): - return 'group__name' - - -class PublicationSort(SortMethod): - codename = 'date_publication' - description = 'Date of publication of current version of the document' - - def get_sort_field(self): - return '-time' # FIXME: latest revision date - -class ChangeSort(SortMethod): - codename = 'recent_change' - description = 'Date of most recent change of status of any type' - - def get_sort_field(self): - return '-time' # FIXME: latest doc event - - -class SignificantSort(SortMethod): - codename = 'recent_significant' - description = 'Date of most recent significant change of status' - - def get_sort_field(self): - return '-time' # FIXME: latest significant state change - - -TYPES_OF_SORT = [(i.codename, i.description) for i in SortMethod.__subclasses__()] diff --git a/ietf/community/forms.py b/ietf/community/forms.py index 3797b7816..7c48569a7 100644 --- a/ietf/community/forms.py +++ b/ietf/community/forms.py @@ -1,101 +1,112 @@ -import hashlib -import datetime - from django import forms -from django.conf import settings -from django.contrib.sites.models import Site +from django.db.models import Q -from ietf.utils.mail import send_mail -from ietf.community.models import Rule, DisplayConfiguration, RuleManager -from ietf.community.display import DisplayField +from ietf.community.models import SearchRule, EmailSubscription +from ietf.doc.fields import SearchableDocumentsField +from ietf.person.models import Person +from ietf.person.fields import SearchablePersonField +class AddDocumentsForm(forms.Form): + documents = SearchableDocumentsField(label="Add documents to track", doc_type="draft") -class RuleForm(forms.ModelForm): +class SearchRuleTypeForm(forms.Form): + rule_type = forms.ChoiceField(choices=[('', '--------------')] + SearchRule.RULE_TYPES) + +class SearchRuleForm(forms.ModelForm): + person = SearchablePersonField() class Meta: - model = Rule - fields = ('rule_type', 'value') + model = SearchRule + fields = ('state', 'group', 'person', 'text') - def __init__(self, *args, **kwargs): - self.clist = kwargs.pop('clist', None) - super(RuleForm, self).__init__(*args, **kwargs) + def __init__(self, clist, rule_type, *args, **kwargs): + kwargs["prefix"] = rule_type # add prefix to avoid mixups in the Javascript + super(SearchRuleForm, self).__init__(*args, **kwargs) - def save(self): - self.instance.community_list = self.clist - super(RuleForm, self).save() + def restrict_state(state_type, slug=None): + f = self.fields['state'] + f.queryset = f.queryset.filter(used=True).filter(type=state_type) + if slug: + f.queryset = f.queryset.filter(slug=slug) + if len(f.queryset) == 1: + f.initial = f.queryset[0].pk + f.widget = forms.HiddenInput() - def get_all_options(self): - result = [] - for i in RuleManager.__subclasses__(): - options = i(None).options() - if options: - result.append({'type': i.codename, - 'options': options}) - return result - + if rule_type in ['group', 'group_rfc', 'area', 'area_rfc']: + restrict_state("draft", "rfc" if rule_type.endswith("rfc") else "active") -class DisplayForm(forms.ModelForm): + if rule_type.startswith("area"): + self.fields["group"].label = "Area" + self.fields["group"].queryset = self.fields["group"].queryset.filter(Q(type="area") | Q(acronym="irtf")).order_by("acronym") + else: + self.fields["group"].queryset = self.fields["group"].queryset.filter(type__in=("wg", "rg")).order_by("acronym") - class Meta: - model = DisplayConfiguration - fields = ('sort_method', ) + del self.fields["person"] + del self.fields["text"] - def save(self): - data = self.data - fields = [] - for i in DisplayField.__subclasses__(): - if data.get(i.codename, None): - fields.append(i.codename) - self.instance.display_fields = ','.join(fields) - super(DisplayForm, self).save() + elif rule_type.startswith("state_"): + mapping = { + "state_iab": "draft-stream-iab", + "state_iana": "draft-iana-review", + "state_iesg": "draft-iesg", + "state_irtf": "draft-stream-irtf", + "state_ise": "draft-stream-ise", + "state_rfceditor": "draft-rfceditor", + "state_ietf": "draft-stream-ietf", + } + restrict_state(mapping[rule_type]) + + del self.fields["group"] + del self.fields["person"] + del self.fields["text"] + + elif rule_type in ["author", "author_rfc", "shepherd", "ad"]: + restrict_state("draft", "rfc" if rule_type.endswith("rfc") else "active") + + if rule_type.startswith("author"): + self.fields["person"].label = "Author" + elif rule_type.startswith("shepherd"): + self.fields["person"].label = "Shepherd" + elif rule_type.startswith("ad"): + self.fields["person"].label = "Area Director" + self.fields["person"] = forms.ModelChoiceField(queryset=Person.objects.filter(role__name__in=("ad", "pre-ad"), role__group__state="active").distinct().order_by("name")) + + del self.fields["group"] + del self.fields["text"] + + elif rule_type == "name_contains": + restrict_state("draft", "rfc" if rule_type.endswith("rfc") else "active") + + del self.fields["person"] + del self.fields["group"] + + if 'group' in self.fields: + self.fields['group'].queryset = self.fields['group'].queryset.filter(state="active").order_by("acronym") + self.fields['group'].choices = [(g.pk, u"%s - %s" % (g.acronym, g.name)) for g in self.fields['group'].queryset] + + for name, f in self.fields.iteritems(): + f.required = True -class SubscribeForm(forms.Form): - +class SubscriptionForm(forms.Form): + notify_on = forms.ChoiceField(choices=[("all", "All changes"), ("significant", "Only significant state changes")], widget=forms.RadioSelect, initial="all") email = forms.EmailField(label="Your email") - def __init__(self, *args, **kwargs): - self.clist = kwargs.pop('clist') - self.significant = kwargs.pop('significant') - super(SubscribeForm, self).__init__(*args, **kwargs) + def __init__(self, operation, clist, *args, **kwargs): + self.operation = operation + self.clist = clist - def save(self, *args, **kwargs): - self.send_email() - return True + super(SubscriptionForm, self).__init__(*args, **kwargs) - def send_email(self): - domain = Site.objects.get_current().domain - today = datetime.date.today().strftime('%Y%m%d') - subject = 'Confirm list subscription: %s' % self.clist - from_email = settings.DEFAULT_FROM_EMAIL - to_email = self.cleaned_data['email'] - auth = hashlib.md5('%s%s%s%s%s' % (settings.SECRET_KEY, today, to_email, 'subscribe', self.significant)).hexdigest() - context = { - 'domain': domain, - 'clist': self.clist, - 'today': today, - 'auth': auth, - 'to_email': to_email, - 'significant': self.significant, - } - send_mail(None, to_email, from_email, subject, 'community/public/subscribe_email.txt', context) + if operation == "subscribe": + self.fields["notify_on"].label = "Get notified on" + else: + self.fields["notify_on"].label = "For notifications on" - -class UnSubscribeForm(SubscribeForm): - - def send_email(self): - domain = Site.objects.get_current().domain - today = datetime.date.today().strftime('%Y%m%d') - subject = 'Confirm list subscription cancelation: %s' % self.clist - from_email = settings.DEFAULT_FROM_EMAIL - to_email = self.cleaned_data['email'] - auth = hashlib.md5('%s%s%s%s%s' % (settings.SECRET_KEY, today, to_email, 'unsubscribe', self.significant)).hexdigest() - context = { - 'domain': domain, - 'clist': self.clist, - 'today': today, - 'auth': auth, - 'to_email': to_email, - 'significant': self.significant, - } - send_mail(None, to_email, from_email, subject, 'community/public/unsubscribe_email.txt', context) + def clean(self): + if self.operation == "subscribe": + if EmailSubscription.objects.filter(community_list=self.clist, email=self.cleaned_data["email"], significant=self.cleaned_data["notify_on"] == "significant").exists(): + raise forms.ValidationError("This email address is already subscribed.") + else: + if not EmailSubscription.objects.filter(community_list=self.clist, email=self.cleaned_data["email"], significant=self.cleaned_data["notify_on"] == "significant").exists(): + raise forms.ValidationError("Couldn't find a matching subscription?") diff --git a/ietf/community/management/__init__.py b/ietf/community/management/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ietf/community/management/commands/__init__.py b/ietf/community/management/commands/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ietf/community/management/commands/update_community_lists.py b/ietf/community/management/commands/update_community_lists.py deleted file mode 100644 index 4191c6f5d..000000000 --- a/ietf/community/management/commands/update_community_lists.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys -import datetime - -from django.core.management.base import BaseCommand - -from ietf.community.models import Rule, CommunityList - - -class Command(BaseCommand): - help = (u"Update drafts in community lists by reviewing their rules") - - - def handle(self, *args, **options): - now = datetime.datetime.now() - - rules = Rule.objects.filter(last_updated__lt=now - datetime.timedelta(hours=1)) - count = rules.count() - index = 1 - for rule in rules: - sys.stdout.write('Updating rule [%s/%s]\r' % (index, count)) - sys.stdout.flush() - rule.save() - index += 1 - if index > 1: - print - cls = CommunityList.objects.filter(cached__isnull=False) - count = cls.count() - index = 1 - for cl in cls: - sys.stdout.write('Clearing community list cache [%s/%s]\r' % (index, count)) - sys.stdout.flush() - cl.cached = None - cl.save() - index += 1 - if index > 1: - print diff --git a/ietf/community/migrations/0003_cleanup.py b/ietf/community/migrations/0003_cleanup.py new file mode 100644 index 000000000..b81cf1d23 --- /dev/null +++ b/ietf/community/migrations/0003_cleanup.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('person', '0004_auto_20150308_0440'), + ('doc', '0010_auto_20150930_0251'), + ('group', '0006_auto_20150718_0509'), + ('community', '0002_auto_20141222_1749'), + ] + + operations = [ + migrations.RenameModel( + old_name='Rule', + new_name='SearchRule', + ), + migrations.RemoveField( + model_name='displayconfiguration', + name='community_list', + ), + migrations.DeleteModel( + name='DisplayConfiguration', + ), + migrations.RemoveField( + model_name='documentchangedates', + name='document', + ), + migrations.DeleteModel( + name='DocumentChangeDates', + ), + migrations.RemoveField( + model_name='expectedchange', + name='community_list', + ), + migrations.RemoveField( + model_name='expectedchange', + name='document', + ), + migrations.DeleteModel( + name='ExpectedChange', + ), + migrations.RemoveField( + model_name='listnotification', + name='event', + ), + migrations.DeleteModel( + name='ListNotification', + ), + migrations.RemoveField( + model_name='searchrule', + name='cached_ids', + ), + migrations.RenameField( + model_name='communitylist', + old_name='added_ids', + new_name='added_docs', + ), + migrations.RemoveField( + model_name='communitylist', + name='cached', + ), + migrations.RemoveField( + model_name='communitylist', + name='secret', + ), + migrations.AddField( + model_name='searchrule', + name='group', + field=models.ForeignKey(blank=True, to='group.Group', null=True), + preserve_default=True, + ), + migrations.AddField( + model_name='searchrule', + name='person', + field=models.ForeignKey(blank=True, to='person.Person', null=True), + preserve_default=True, + ), + migrations.AddField( + model_name='searchrule', + name='state', + field=models.ForeignKey(blank=True, to='doc.State', null=True), + preserve_default=True, + ), + migrations.AddField( + model_name='searchrule', + name='text', + field=models.CharField(default=b'', max_length=255, blank=True), + preserve_default=True, + ), + migrations.RemoveField( + model_name='searchrule', + name='last_updated', + ), + migrations.AlterField( + model_name='searchrule', + name='rule_type', + field=models.CharField(max_length=30, choices=[(b'group', b'All I-Ds associated with a particular group'), (b'area', b'All I-Ds associated with all groups in a particular Area'), (b'group_rfc', b'All RFCs associated with a particular group'), (b'area_rfc', b'All RFCs associated with all groups in a particular Area'), (b'state_iab', b'All I-Ds that are in a particular IAB state'), (b'state_iana', b'All I-Ds that are in a particular IANA state'), (b'state_iesg', b'All I-Ds that are in a particular IESG state'), (b'state_irtf', b'All I-Ds that are in a particular IRTF state'), (b'state_ise', b'All I-Ds that are in a particular ISE state'), (b'state_rfceditor', b'All I-Ds that are in a particular RFC Editor state'), (b'state_ietf', b'All I-Ds that are in a particular Working Group state'), (b'author', b'All I-Ds with a particular author'), (b'author_rfc', b'All RFCs with a particular author'), (b'ad', b'All I-Ds with a particular responsible AD'), (b'shepherd', b'All I-Ds with a particular document shepherd'), (b'name_contains', b'All I-Ds with particular text in the name')]), + preserve_default=True, + ), + migrations.AlterUniqueTogether( + name='searchrule', + unique_together=set([]), + ), + ] diff --git a/ietf/community/migrations/0004_cleanup_data.py b/ietf/community/migrations/0004_cleanup_data.py new file mode 100644 index 000000000..e4432a3ee --- /dev/null +++ b/ietf/community/migrations/0004_cleanup_data.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + +def port_rules_to_typed_system(apps, schema_editor): + SearchRule = apps.get_model("community", "SearchRule") + State = apps.get_model("doc", "State") + Group = apps.get_model("group", "Group") + Person = apps.get_model("person", "Person") + + draft_active = State.objects.get(type="draft", slug="active") + draft_rfc = State.objects.get(type="draft", slug="rfc") + + def try_to_uniquify_person(rule, person_qs): + if rule.community_list.user and len(person_qs) > 1: + user_specific_qs = person_qs.filter(user=rule.community_list.user) + if len(user_specific_qs) > 0 and len(user_specific_qs) < len(person_qs): + return user_specific_qs + + return person_qs + + + for rule in SearchRule.objects.all().iterator(): + handled = False + + if rule.rule_type in ['wg_asociated', 'area_asociated', 'wg_asociated_rfc', 'area_asociated_rfc']: + try: + rule.group = Group.objects.get(acronym=rule.value) + + if rule.rule_type in ['wg_asociated_rfc', 'area_asociated_rfc']: + rule.state = draft_rfc + else: + rule.state = draft_active + handled = True + except Group.DoesNotExist: + pass + + + elif rule.rule_type in ['in_iab_state', 'in_iana_state', 'in_iesg_state', 'in_irtf_state', 'in_ise_state', 'in_rfcEdit_state', 'in_wg_state']: + state_types = { + 'in_iab_state': 'draft-stream-iab', + 'in_iana_state': 'draft-iana-review', + 'in_iesg_state': 'draft-iesg', + 'in_irtf_state': 'draft-stream-irtf', + 'in_ise_state': 'draft-stream-ise', + 'in_rfcEdit_state': 'draft-rfceditor', + 'in_wg_state': 'draft-stream-ietf', + } + + try: + rule.state = State.objects.get(type=state_types[rule.rule_type], slug=rule.value) + handled = True + except State.DoesNotExist: + pass + + + elif rule.rule_type in ["author", "author_rfc"]: + found_persons = list(try_to_uniquify_person(rule, Person.objects.filter(email__documentauthor__id__gte=1).filter(name__icontains=rule.value).distinct())) + + if found_persons: + rule.person = found_persons[0] + rule.state = draft_active + + for p in found_persons[1:]: + SearchRule.objects.create( + community_list=rule.community_list, + rule_type=rule.rule_type, + state=rule.state, + person=p, + ) + #print "created", rule.rule_type, p.name + + handled = True + + elif rule.rule_type == "ad_responsible": + try: + rule.person = Person.objects.get(id=rule.value) + rule.state = draft_active + handled = True + except Person.DoesNotExist: + pass + + + elif rule.rule_type == "shepherd": + found_persons = list(try_to_uniquify_person(rule, Person.objects.filter(email__shepherd_document_set__type="draft").filter(name__icontains=rule.value).distinct())) + + if found_persons: + rule.person = found_persons[0] + rule.state = draft_active + + for p in found_persons[1:]: + SearchRule.objects.create( + community_list=rule.community_list, + rule_type=rule.rule_type, + state=rule.state, + person=p, + ) + #print "created", rule.rule_type, p.name + + handled = True + + elif rule.rule_type == "with_text": + rule.state = draft_active + + if rule.value: + rule.text = rule.value + handled = True + + if handled: + rule.save() + else: + rule.delete() + #print "NOT HANDLED", rule.pk, rule.rule_type, rule.value + +def delete_extra_person_rules(apps, schema_editor): + SearchRule = apps.get_model("community", "SearchRule") + SearchRule.objects.exclude(person=None).filter(value="").delete() + +RENAMED_RULES = [ + ('wg_asociated', 'group'), + ('area_asociated', 'area'), + ('wg_asociated_rfc', 'group_rfc'), + ('area_asociated_rfc', 'area_rfc'), + + ('in_iab_state', 'state_iab'), + ('in_iana_state', 'state_iana'), + ('in_iesg_state', 'state_iesg'), + ('in_irtf_state', 'state_irtf'), + ('in_ise_state', 'state_ise'), + ('in_rfcEdit_state', 'state_rfceditor'), + ('in_wg_state', 'state_ietf'), + + ('ad_responsible', 'ad'), + + ('with_text', 'name_contains'), +] + +def rename_rule_type_forwards(apps, schema_editor): + SearchRule = apps.get_model("community", "SearchRule") + + renamings = dict(RENAMED_RULES) + + for r in SearchRule.objects.all(): + if r.rule_type in renamings: + r.rule_type = renamings[r.rule_type] + r.save() + +def rename_rule_type_backwards(apps, schema_editor): + SearchRule = apps.get_model("community", "SearchRule") + + renamings = dict((to, fro) for fro, to in RENAMED_RULES) + + for r in SearchRule.objects.all(): + if r.rule_type in renamings: + r.rule_type = renamings[r.rule_type] + r.save() + +def get_rid_of_empty_lists(apps, schema_editor): + CommunityList = apps.get_model("community", "CommunityList") + + for cl in CommunityList.objects.all(): + if not cl.added_docs.exists() and not cl.searchrule_set.exists() and not cl.emailsubscription_set.exists(): + cl.delete() + +def noop(apps, schema_editor): + pass + +class Migration(migrations.Migration): + + dependencies = [ + ('community', '0003_cleanup'), + ] + + operations = [ + migrations.RunPython(port_rules_to_typed_system, delete_extra_person_rules), + migrations.RunPython(rename_rule_type_forwards, rename_rule_type_backwards), + migrations.RunPython(get_rid_of_empty_lists, noop), + migrations.RemoveField( + model_name='searchrule', + name='value', + ), + ] diff --git a/ietf/community/models.py b/ietf/community/models.py index aeed6e9d4..f91bfbfee 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -1,175 +1,63 @@ -import hashlib - -from django.conf import settings from django.contrib.auth.models import User -from django.core.urlresolvers import reverse from django.db import models -from django.db.models import signals, Q +from django.db.models import signals -from ietf.utils.mail import send_mail -from ietf.doc.models import Document, DocEvent +from ietf.doc.models import Document, DocEvent, State from ietf.group.models import Group - -from ietf.community.rules import TYPES_OF_RULES, RuleManager -from ietf.community.display import (TYPES_OF_SORT, DisplayField, - SortMethod) -from ietf.community.constants import SIGNIFICANT_STATES - +from ietf.person.models import Person class CommunityList(models.Model): - user = models.ForeignKey(User, blank=True, null=True) group = models.ForeignKey(Group, blank=True, null=True) - added_ids = models.ManyToManyField(Document) - secret = models.CharField(max_length=255, null=True, blank=True) - cached = models.TextField(null=True, blank=True) + added_docs = models.ManyToManyField(Document) def long_name(self): if self.user: return 'Personal ID list of %s' % self.user.username - else: + elif self.group: return 'ID list for %s' % self.group.name + else: + return 'ID list' def __unicode__(self): return self.long_name() - def get_public_url(self): - if self.user: - return reverse('view_personal_list', None, args=(self.user.username, )) - else: - return reverse('view_group_list', None, args=(self.group.acronym, )) - def get_manage_url(self): - if self.user: - return reverse('manage_personal_list', None, args=()) - else: - return reverse('manage_group_list', None, args=(self.group.acronym, )) +class SearchRule(models.Model): + # these types define the UI for setting up the rule, and also + # helps when interpreting the rule and matching documents + RULE_TYPES = [ + ('group', 'All I-Ds associated with a particular group'), + ('area', 'All I-Ds associated with all groups in a particular Area'), + ('group_rfc', 'All RFCs associated with a particular group'), + ('area_rfc', 'All RFCs associated with all groups in a particular Area'), - def get_display_config(self): - dconfig = getattr(self, '_cached_dconfig', None) - if not dconfig: - try: - self._cached_dconfig = DisplayConfiguration.objects.get(community_list=self) - except DisplayConfiguration.DoesNotExist: - self._cached_dconfig = DisplayConfiguration(community_list=self) - return self._cached_dconfig - return self._cached_dconfig + ('state_iab', 'All I-Ds that are in a particular IAB state'), + ('state_iana', 'All I-Ds that are in a particular IANA state'), + ('state_iesg', 'All I-Ds that are in a particular IESG state'), + ('state_irtf', 'All I-Ds that are in a particular IRTF state'), + ('state_ise', 'All I-Ds that are in a particular ISE state'), + ('state_rfceditor', 'All I-Ds that are in a particular RFC Editor state'), + ('state_ietf', 'All I-Ds that are in a particular Working Group state'), - def get_documents(self): - if hasattr(self, '_cached_documents'): - return self._cached_documents - docs = self.added_ids.all().distinct().select_related('type', 'group', 'ad') - for rule in self.rule_set.all(): - docs = docs | rule.cached_ids.all().distinct() - sort_field = self.get_display_config().get_sort_method().get_sort_field() - docs = docs.distinct().order_by(sort_field) - self._cached_documents = docs - return self._cached_documents + ('author', 'All I-Ds with a particular author'), + ('author_rfc', 'All RFCs with a particular author'), - def get_rfcs_and_drafts(self): - if hasattr(self, '_cached_rfcs_and_drafts'): - return self._cached_rfcs_and_drafts - docs = self.get_documents() - sort_method = self.get_display_config().get_sort_method() - sort_field = sort_method.get_sort_field() - if hasattr(sort_method, 'get_full_rfc_sort'): - rfcs = sort_method.get_full_rfc_sort(docs.filter(states__name='rfc').distinct()) - else: - rfcs = docs.filter(states__name='rfc').distinct().order_by(sort_field) - if hasattr(sort_method, 'get_full_draft_sort'): - drafts = sort_method.get_full_draft_sort(docs.exclude(pk__in=rfcs).distinct()) - else: - drafts = docs.exclude(pk__in=rfcs).distinct().order_by(sort_field) - self._cached_rfcs_and_drafts = (rfcs, drafts) - return self._cached_rfcs_and_drafts + ('ad', 'All I-Ds with a particular responsible AD'), - def add_subscriptor(self, email, significant): - self.emailsubscription_set.get_or_create(email=email, significant=significant) + ('shepherd', 'All I-Ds with a particular document shepherd'), - def save(self, *args, **kwargs): - super(CommunityList, self).save(*args, **kwargs) - if not self.secret: - self.secret = hashlib.md5('%s%s%s%s' % (settings.SECRET_KEY, self.id, self.user and self.user.id or '', self.group and self.group.id or '')).hexdigest() - self.save() - - def update(self): - self.cached=None - self.save() - - -class Rule(models.Model): + ('name_contains', 'All I-Ds with particular text in the name'), + ] community_list = models.ForeignKey(CommunityList) - cached_ids = models.ManyToManyField(Document) - rule_type = models.CharField(max_length=30, choices=TYPES_OF_RULES) - value = models.CharField(max_length=255) - last_updated = models.DateTimeField(auto_now=True) + rule_type = models.CharField(max_length=30, choices=RULE_TYPES) - class Meta: - unique_together= ("community_list", "rule_type", "value") - - def get_callable_rule(self): - for i in RuleManager.__subclasses__(): - if i.codename == self.rule_type: - return i(self.value) - return RuleManager(self.value) - - def save(self, *args, **kwargs): - super(Rule, self).save(*args, **kwargs) - rule = self.get_callable_rule() - self.cached_ids = rule.get_documents() - self.community_list.update() - - def delete(self): - self.community_list.update() - super(Rule, self).delete() - - -class DisplayConfiguration(models.Model): - - community_list = models.ForeignKey(CommunityList) - sort_method = models.CharField( - max_length=100, - choices=TYPES_OF_SORT, - default='by_filename', - blank=False, - null=False) - display_fields = models.TextField( - default='filename,title,date') - - def get_display_fields_config(self): - fields = self.display_fields and self.display_fields.split(',') or [] - config = [] - for i in DisplayField.__subclasses__(): - config.append({ - 'codename': i.codename, - 'description': i.description, - 'active': i.codename in fields, - }) - return config - - def get_active_fields(self): - fields = self.display_fields and self.display_fields.split(',') or '' - active_fields = [i for i in DisplayField.__subclasses__() if i.codename in fields] - return active_fields - - def get_all_fields(self): - all_fields = [i for i in DisplayField.__subclasses__()] - return all_fields - - def get_sort_method(self): - for i in SortMethod.__subclasses__(): - if i.codename == self.sort_method: - return i() - return SortMethod() - - def save(self, *args, **kwargs): - super(DisplayConfiguration, self).save(*args, **kwargs) - self.community_list.update() - - def delete(self): - self.community_list.update() - super(DisplayConfiguration, self).delete() + # these are filled in depending on the type + state = models.ForeignKey(State, blank=True, null=True) + group = models.ForeignKey(Group, blank=True, null=True) + person = models.ForeignKey(Person, blank=True, null=True) + text = models.CharField(max_length=255, blank=True, default="") class EmailSubscription(models.Model): @@ -177,41 +65,19 @@ class EmailSubscription(models.Model): email = models.CharField(max_length=200) significant = models.BooleanField(default=False) - -class ListNotification(models.Model): - - event = models.ForeignKey(DocEvent) - significant = models.BooleanField(default=False) - - def notify_by_email(self): - clists = CommunityList.objects.filter( - Q(added_ids=self.event.doc) | Q(rule__cached_ids=self.event.doc)).distinct() - from_email = settings.DEFAULT_FROM_EMAIL - for l in clists: - subject = '%s notification: Changes on %s' % (l.long_name(), self.event.doc.name) - context = {'notification': self.event, - 'clist': l} - filter_subscription = {'community_list': l} - if not self.significant: - filter_subscription['significant'] = False - for to_email in list(set([i.email for i in EmailSubscription.objects.filter(**filter_subscription)])): - send_mail(None, to_email, from_email, subject, 'community/public/notification_email.txt', context) + def __unicode__(self): + return u"%s to %s (%s changes)" % (self.email, self.community_list, "significant" if self.significant else "all") def notify_events(sender, instance, **kwargs): if not isinstance(instance, DocEvent): return - if instance.doc.type.slug != 'draft' or instance.type == 'added_comment': + + if instance.doc.type_id != 'draft': return - significant = False - if instance.type == 'changed_document' and 'tate changed' in instance.desc: - for i in SIGNIFICANT_STATES: - if ('%s' % i) in instance.desc: - significant = True - break - notification = ListNotification.objects.create( - event=instance, - significant=significant, - ) - notification.notify_by_email() + + from ietf.community.utils import notify_event_to_subscribers + notify_event_to_subscribers(instance) + + signals.post_save.connect(notify_events) diff --git a/ietf/community/resources.py b/ietf/community/resources.py index 8446aff67..2e440adf3 100644 --- a/ietf/community/resources.py +++ b/ietf/community/resources.py @@ -14,7 +14,7 @@ from ietf.utils.resources import UserResource class CommunityListResource(ModelResource): user = ToOneField(UserResource, 'user', null=True) group = ToOneField(GroupResource, 'group', null=True) - added_ids = ToManyField(DocumentResource, 'added_ids', null=True) + added_docs = ToManyField(DocumentResource, 'added_docs', null=True) class Meta: queryset = CommunityList.objects.all() serializer = api.Serializer() @@ -25,55 +25,23 @@ class CommunityListResource(ModelResource): "cached": ALL, "user": ALL_WITH_RELATIONS, "group": ALL_WITH_RELATIONS, - "added_ids": ALL_WITH_RELATIONS, + "added_docs": ALL_WITH_RELATIONS, } api.community.register(CommunityListResource()) -class DisplayConfigurationResource(ModelResource): - community_list = ToOneField(CommunityListResource, 'community_list') - class Meta: - queryset = DisplayConfiguration.objects.all() - serializer = api.Serializer() - #resource_name = 'displayconfiguration' - filtering = { - "id": ALL, - "sort_method": ALL, - "display_fields": ALL, - "community_list": ALL_WITH_RELATIONS, - } -api.community.register(DisplayConfigurationResource()) - -from ietf.doc.resources import DocEventResource -class ListNotificationResource(ModelResource): - event = ToOneField(DocEventResource, 'event') - class Meta: - queryset = ListNotification.objects.all() - serializer = api.Serializer() - #resource_name = 'listnotification' - filtering = { - "id": ALL, - "significant": ALL, - "event": ALL_WITH_RELATIONS, - } -api.community.register(ListNotificationResource()) - from ietf.doc.resources import DocumentResource -class RuleResource(ModelResource): +class SearchRuleResource(ModelResource): community_list = ToOneField(CommunityListResource, 'community_list') - cached_ids = ToManyField(DocumentResource, 'cached_ids', null=True) class Meta: - queryset = Rule.objects.all() + queryset = SearchRule.objects.all() serializer = api.Serializer() #resource_name = 'rule' filtering = { "id": ALL, "rule_type": ALL, - "value": ALL, - "last_updated": ALL, "community_list": ALL_WITH_RELATIONS, - "cached_ids": ALL_WITH_RELATIONS, } -api.community.register(RuleResource()) +api.community.register(SearchRuleResource()) class EmailSubscriptionResource(ModelResource): community_list = ToOneField(CommunityListResource, 'community_list') diff --git a/ietf/community/rules.py b/ietf/community/rules.py deleted file mode 100644 index a77b36570..000000000 --- a/ietf/community/rules.py +++ /dev/null @@ -1,292 +0,0 @@ -from ietf.doc.models import Document -from ietf.group.models import Group -from ietf.person.models import Person -from ietf.doc.models import State - - -class RuleManager(object): - - codename = '' - description = '' - - def __init__(self, value): - self.value = self.get_value(value) - - def get_value(self, value): - return value - - def get_documents(self): - return Document.objects.none() - - def options(self): - return None - - def show_value(self): - return self.value - - -class WgAsociatedRule(RuleManager): - codename = 'wg_asociated' - description = 'All I-Ds associated with a particular WG' - - def get_documents(self): - return Document.objects.filter(type='draft', states__slug='active').filter(group__acronym=self.value).distinct() - - def options(self): - return [(i.acronym, "%s — %s"%(i.acronym, i.name)) for i in Group.objects.filter(type='wg', state='active').distinct().order_by('acronym')] - - def show_value(self): - try: - return Group.objects.get(acronym=self.value).name - except Group.DoesNotExist: - return self.value - - -class AreaAsociatedRule(RuleManager): - codename = 'area_asociated' - description = 'All I-Ds associated with all WGs in a particular Area' - - def get_documents(self): - return Document.objects.filter(type='draft', states__slug='active').filter(group__parent__acronym=self.value, group__parent__type='area').distinct() - - def options(self): - return [(i.acronym, "%s — %s"%(i.acronym, i.name)) for i in Group.objects.filter(type='area', state='active').distinct().order_by('name')] - - def show_value(self): - try: - return Group.objects.get(acronym=self.value).name - except Group.DoesNotExist: - return self.value - - -class AdResponsibleRule(RuleManager): - codename = 'ad_responsible' - description = 'All I-Ds with a particular responsible AD' - - def get_documents(self): - return Document.objects.filter(type='draft', states__slug='active').filter(ad=self.value).distinct() - - def options(self): - return [(i.pk, i.name) for i in Person.objects.filter(role__name='ad',role__group__state='active').distinct().order_by('name')] - - def show_value(self): - try: - return Person.objects.get(pk=self.value).name - except Person.DoesNotExist: - return self.value - - -class AuthorRule(RuleManager): - codename = 'author' - description = 'All I-Ds with a particular author' - - def get_documents(self): - return Document.objects.filter(type='draft', states__slug='active').filter(authors__person__name__icontains=self.value).distinct() - - -class ShepherdRule(RuleManager): - codename = 'shepherd' - description = 'All I-Ds with a particular document shepherd' - - def get_documents(self): - return Document.objects.filter(type='draft', states__slug='active').filter(shepherd__person__name__icontains=self.value).distinct() - - -# class ReferenceToRFCRule(RuleManager): -# codename = 'reference_to_rfc' -# description = 'All I-Ds that have a reference to a particular RFC' -# -# def get_documents(self): -# return Document.objects.filter(type='draft', states__slug='active').filter(relateddocument__target__document__states__slug='rfc', relateddocument__target__name__icontains=self.value).distinct() -# -# -# class ReferenceToIDRule(RuleManager): -# codename = 'reference_to_id' -# description = 'All I-Ds that have a reference to a particular I-D' -# -# def get_documents(self): -# return Document.objects.filter(type='draft', states__slug='active').filter(relateddocument__target__document__type='draft', relateddocument__target__name__icontains=self.value).distinct() -# -# -# class ReferenceFromRFCRule(RuleManager): -# codename = 'reference_from_rfc' -# description = 'All I-Ds that are referenced by a particular RFC' -# -# def get_documents(self): -# return Document.objects.filter(type='draft', states__slug='active').filter(relateddocument__source__states__slug='rfc', relateddocument__source__name__icontains=self.value).distinct() -# -# -# -# class ReferenceFromIDRule(RuleManager): -# codename = 'reference_from_id' -# description = 'All I-Ds that are referenced by a particular I-D' -# -# def get_documents(self): -# return Document.objects.filter(type='draft', states__slug='active').filter(relateddocument__source__type='draft', relateddocument__source__name__icontains=self.value).distinct() - - -class WithTextRule(RuleManager): - codename = 'with_text' - description = 'All I-Ds that contain a particular text string in the name' - - def get_documents(self): - return Document.objects.filter(type='draft', states__slug='active').filter(name__icontains=self.value).distinct() - -class IABInState(RuleManager): - codename = 'in_iab_state' - description = 'All I-Ds that are in a particular IAB state' - - def get_documents(self): - return Document.objects.filter(states__type='draft-stream-iab', states__slug=self.value).distinct() - - def options(self): - return [(i.slug, i.name) for i in State.objects.filter(type='draft-stream-iab').order_by('name')] - - def show_value(self): - try: - return State.objects.get(type='draft-stream-iab', slug=self.value).name - except State.DoesNotExist: - return self.value - -class IANAInState(RuleManager): - codename = 'in_iana_state' - description = 'All I-Ds that are in a particular IANA state' - - def get_documents(self): - return Document.objects.filter(states__type='draft-iana-review', states__slug=self.value).distinct() - - def options(self): - return [(i.slug, i.name) for i in State.objects.filter(type='draft-iana-review').order_by('name')] - - def show_value(self): - try: - return State.objects.get(type='draft-iana-review', slug=self.value).name - except State.DoesNotExist: - return self.value - -class IESGInState(RuleManager): - codename = 'in_iesg_state' - description = 'All I-Ds that are in a particular IESG state' - - def get_documents(self): - return Document.objects.filter(states__type='draft-iesg', states__slug=self.value).distinct() - - def options(self): - return [(i.slug, i.name) for i in State.objects.filter(type='draft-iesg').order_by('name')] - - def show_value(self): - try: - return State.objects.get(type='draft-iesg', slug=self.value).name - except State.DoesNotExist: - return self.value - -class IRTFInState(RuleManager): - codename = 'in_irtf_state' - description = 'All I-Ds that are in a particular IRTF state' - - def get_documents(self): - return Document.objects.filter(states__type='draft-stream-irtf', states__slug=self.value).distinct() - - def options(self): - return [(i.slug, i.name) for i in State.objects.filter(type='draft-stream-irtf').order_by('name')] - - def show_value(self): - try: - return State.objects.get(type='draft-stream-irtf', slug=self.value).name - except State.DoesNotExist: - return self.value - -class ISEInState(RuleManager): - codename = 'in_ise_state' - description = 'All I-Ds that are in a particular ISE state' - - def get_documents(self): - return Document.objects.filter(states__type='draft-stream-ise', states__slug=self.value).distinct() - - def options(self): - return [(i.slug, i.name) for i in State.objects.filter(type='draft-stream-ise').order_by('name')] - - def show_value(self): - try: - return State.objects.get(type='draft-stream-ise', slug=self.value).name - except State.DoesNotExist: - return self.value - -class RfcEditorInState(RuleManager): - codename = 'in_rfcEdit_state' - description = 'All I-Ds that are in a particular RFC Editor state' - - def get_documents(self): - return Document.objects.filter(states__type='draft-rfceditor', states__slug=self.value).distinct() - - def options(self): - return [(i.slug, i.type_id + ": " + i.name) for i in State.objects.filter(type='draft-rfceditor').order_by('name')] - - def show_value(self): - try: - return State.objects.get(type='draft-rfceditor', slug=self.value).name - except State.DoesNotExist: - return self.value - -class WGInState(RuleManager): - codename = 'in_wg_state' - description = 'All I-Ds that are in a particular Working Group state' - - def get_documents(self): - return Document.objects.filter(states__type='draft-stream-ietf', states__slug=self.value).distinct() - - def options(self): - return [(i.slug, i.type_id + ": " + i.name) for i in State.objects.filter(type='draft-stream-ietf').order_by('name')] - - def show_value(self): - try: - return State.objects.get(type='draft-stream-ietf', slug=self.value).name - except State.DoesNotExist: - return self.value - -class RfcWgAsociatedRule(RuleManager): - codename = 'wg_asociated_rfc' - description = 'All RFCs associated with a particular WG' - - def get_documents(self): - return Document.objects.filter(type='draft', states__slug='rfc').filter(group__acronym=self.value).distinct() - - def options(self): - return [(i.acronym, "%s — %s"%(i.acronym, i.name)) for i in Group.objects.filter(type='wg').distinct().order_by('acronym')] - - def show_value(self): - try: - return Group.objects.get(type='draft', acronym=self.value).name - except Group.DoesNotExist: - return self.value - - -class RfcAreaAsociatedRule(RuleManager): - codename = 'area_asociated_rfc' - description = 'All RFCs associated with all WGs in a particular Area' - - def get_documents(self): - return Document.objects.filter(type='draft', states__slug='rfc').filter(group__parent__acronym=self.value, group__parent__type='area').distinct() - - def options(self): - return [(i.acronym, "%s — %s"%(i.acronym, i.name)) for i in Group.objects.filter(type='area').distinct().order_by('name')] - - def show_value(self): - try: - return Group.objects.get(type='draft', acronym=self.value).name - except Group.DoesNotExist: - return self.value - - -class RfcAuthorRule(RuleManager): - codename = 'author_rfc' - description = 'All RFCs with a particular author' - - def get_documents(self): - return Document.objects.filter(type='draft', states__slug='rfc').filter(authors__person__name__icontains=self.value).distinct() - - - -TYPES_OF_RULES = [(i.codename, i.description) for i in RuleManager.__subclasses__()] - - diff --git a/ietf/community/templatetags/__init__.py b/ietf/community/templatetags/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ietf/community/templatetags/community_tags.py b/ietf/community/templatetags/community_tags.py deleted file mode 100644 index 8cbfe3061..000000000 --- a/ietf/community/templatetags/community_tags.py +++ /dev/null @@ -1,22 +0,0 @@ -from django import template -from django.template.loader import render_to_string -from django.conf import settings - -register = template.Library() - -@register.inclusion_tag('community/display_field.html', takes_context=False) -def show_field(field, doc): - return {'field': field, - 'value': field.get_value(doc), - } - - -@register.simple_tag -def get_clist_view(clist): - if settings.DEBUG or not clist.cached: - clist.cached = render_to_string('community/raw_view.html', { - 'cl': clist, - 'dc': clist.get_display_config() - }) - clist.save() - return clist.cached diff --git a/ietf/community/tests.py b/ietf/community/tests.py index 71e27d952..356a1a133 100644 --- a/ietf/community/tests.py +++ b/ietf/community/tests.py @@ -1,16 +1,130 @@ import json -from django.core.urlresolvers import reverse as urlreverse +from pyquery import PyQuery -from ietf.community.models import CommunityList +from django.core.urlresolvers import reverse as urlreverse +from django.contrib.auth.models import User + +from ietf.community.models import CommunityList, SearchRule, EmailSubscription +from ietf.community.utils import docs_matching_community_list_rule, community_list_rules_matching_doc +from ietf.doc.models import State +from ietf.doc.utils import add_state_change_event +from ietf.person.models import Person from ietf.utils.test_data import make_test_data from ietf.utils.test_utils import login_testing_unauthorized, TestCase +from ietf.utils.mail import outbox class CommunityListTests(TestCase): + def test_rule_matching(self): + draft = make_test_data() + iesg_state = State.objects.get(type="draft-iesg", slug="lc") + draft.set_state(iesg_state) + + clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + + rule_group = SearchRule.objects.create(rule_type="group", group=draft.group, state=State.objects.get(type="draft", slug="active"), community_list=clist) + rule_group_rfc = SearchRule.objects.create(rule_type="group_rfc", group=draft.group, state=State.objects.get(type="draft", slug="rfc"), community_list=clist) + rule_area = SearchRule.objects.create(rule_type="area", group=draft.group.parent, state=State.objects.get(type="draft", slug="active"), community_list=clist) + + rule_state_iesg = SearchRule.objects.create(rule_type="state_iesg", state=State.objects.get(type="draft-iesg", slug="lc"), community_list=clist) + + rule_author = SearchRule.objects.create(rule_type="author", state=State.objects.get(type="draft", slug="active"), person=Person.objects.filter(email__documentauthor__document=draft).first(), community_list=clist) + + rule_ad = SearchRule.objects.create(rule_type="ad", state=State.objects.get(type="draft", slug="active"), person=draft.ad, community_list=clist) + + rule_shepherd = SearchRule.objects.create(rule_type="shepherd", state=State.objects.get(type="draft", slug="active"), person=draft.shepherd.person, community_list=clist) + + rule_name_contains = SearchRule.objects.create(rule_type="name_contains", state=State.objects.get(type="draft", slug="active"), text="-".join(draft.name.split("-")[2:]), community_list=clist) + + # doc -> rules + matching_rules = list(community_list_rules_matching_doc(draft)) + self.assertTrue(rule_group in matching_rules) + self.assertTrue(rule_group_rfc not in matching_rules) + self.assertTrue(rule_area in matching_rules) + self.assertTrue(rule_state_iesg in matching_rules) + self.assertTrue(rule_author in matching_rules) + self.assertTrue(rule_ad in matching_rules) + self.assertTrue(rule_shepherd in matching_rules) + self.assertTrue(rule_name_contains in matching_rules) + + # rule -> docs + self.assertTrue(draft in list(docs_matching_community_list_rule(rule_group))) + self.assertTrue(draft not in list(docs_matching_community_list_rule(rule_group_rfc))) + self.assertTrue(draft in list(docs_matching_community_list_rule(rule_area))) + self.assertTrue(draft in list(docs_matching_community_list_rule(rule_state_iesg))) + self.assertTrue(draft in list(docs_matching_community_list_rule(rule_author))) + self.assertTrue(draft in list(docs_matching_community_list_rule(rule_ad))) + self.assertTrue(draft in list(docs_matching_community_list_rule(rule_shepherd))) + self.assertTrue(draft in list(docs_matching_community_list_rule(rule_name_contains))) + + def test_view_list(self): + draft = make_test_data() + + url = urlreverse("community_personal_view_list", kwargs={ "username": "plain" }) + + # without list + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # with list + clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + clist.added_docs.add(draft) + SearchRule.objects.create( + community_list=clist, + rule_type="name_contains", + state=State.objects.get(type="draft", slug="active"), + text="test", + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(draft.name in r.content) + + def test_manage_list(self): + draft = make_test_data() + + url = urlreverse("community_personal_manage_list", kwargs={ "username": "plain" }) + login_testing_unauthorized(self, "plain", url) + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # add document + r = self.client.post(url, { "action": "add_documents", "documents": draft.pk }) + self.assertEqual(r.status_code, 302) + clist = CommunityList.objects.get(user__username="plain") + self.assertTrue(clist.added_docs.filter(pk=draft.pk)) + + # add rule + r = self.client.post(url, { + "action": "add_rule", + "rule_type": "author_rfc", + "author_rfc-person": Person.objects.filter(email__documentauthor__document=draft).first().pk, + "author_rfc-state": State.objects.get(type="draft", slug="rfc").pk, + }) + self.assertEqual(r.status_code, 302) + clist = CommunityList.objects.get(user__username="plain") + self.assertTrue(clist.searchrule_set.filter(rule_type="author_rfc")) + + # rule shows up on GET + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + rule = clist.searchrule_set.filter(rule_type="author_rfc").first() + q = PyQuery(r.content) + self.assertEqual(len(q('#r%s' % rule.pk)), 1) + + # remove rule + r = self.client.post(url, { + "action": "remove_rule", + "rule": rule.pk, + }) + + clist = CommunityList.objects.get(user__username="plain") + self.assertTrue(not clist.searchrule_set.filter(rule_type="author_rfc")) + def test_track_untrack_document_for_personal_list_through_ajax(self): draft = make_test_data() - url = urlreverse("community_personal_track_document", kwargs={ "name": draft.name }) + url = urlreverse("community_personal_track_document", kwargs={ "username": "plain", "name": draft.name }) login_testing_unauthorized(self, "plain", url) # track @@ -18,20 +132,20 @@ class CommunityListTests(TestCase): self.assertEqual(r.status_code, 200) self.assertEqual(json.loads(r.content)["success"], True) clist = CommunityList.objects.get(user__username="plain") - self.assertEqual(list(clist.added_ids.all()), [draft]) + self.assertEqual(list(clist.added_docs.all()), [draft]) # untrack - url = urlreverse("community_personal_untrack_document", kwargs={ "name": draft.name }) + url = urlreverse("community_personal_untrack_document", kwargs={ "username": "plain", "name": draft.name }) r = self.client.post(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest') self.assertEqual(r.status_code, 200) self.assertEqual(json.loads(r.content)["success"], True) clist = CommunityList.objects.get(user__username="plain") - self.assertEqual(list(clist.added_ids.all()), []) + self.assertEqual(list(clist.added_docs.all()), []) def test_track_untrack_document_for_group_list(self): draft = make_test_data() - url = urlreverse("community_group_track_document", kwargs={ "name": draft.name, "acronym": draft.group.acronym }) + url = urlreverse("community_group_track_document", kwargs={ "acronym": draft.group.acronym, "name": draft.name }) login_testing_unauthorized(self, "marschairman", url) # track @@ -41,15 +155,162 @@ class CommunityListTests(TestCase): r = self.client.post(url) self.assertEqual(r.status_code, 302) clist = CommunityList.objects.get(group__acronym=draft.group.acronym) - self.assertEqual(list(clist.added_ids.all()), [draft]) + self.assertEqual(list(clist.added_docs.all()), [draft]) # untrack - url = urlreverse("community_group_untrack_document", kwargs={ "name": draft.name, "acronym": draft.group.acronym }) + url = urlreverse("community_group_untrack_document", kwargs={ "acronym": draft.group.acronym, "name": draft.name }) r = self.client.get(url) self.assertEqual(r.status_code, 200) r = self.client.post(url) self.assertEqual(r.status_code, 302) clist = CommunityList.objects.get(group__acronym=draft.group.acronym) - self.assertEqual(list(clist.added_ids.all()), []) + self.assertEqual(list(clist.added_docs.all()), []) + + def test_csv(self): + draft = make_test_data() + + url = urlreverse("community_personal_csv", kwargs={ "username": "plain" }) + + # without list + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # with list + clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + clist.added_docs.add(draft) + SearchRule.objects.create( + community_list=clist, + rule_type="name_contains", + state=State.objects.get(type="draft", slug="active"), + text="test", + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + # this is a simple-minded test, we don't actually check the fields + self.assertTrue(draft.name in r.content) + + def test_feed(self): + draft = make_test_data() + + url = urlreverse("community_personal_feed", kwargs={ "username": "plain" }) + + # without list + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # with list + clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + clist.added_docs.add(draft) + SearchRule.objects.create( + community_list=clist, + rule_type="name_contains", + state=State.objects.get(type="draft", slug="active"), + text="test", + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(draft.name in r.content) + + # only significant + r = self.client.get(url + "?significant=1") + self.assertEqual(r.status_code, 200) + self.assertTrue('' not in r.content) + + def extract_confirm_url(self, confirm_email): + # dig out confirm_email link + msg = confirm_email.get_payload(decode=True) + line_start = "http" + confirm_url = None + for line in msg.split("\n"): + if line.strip().startswith(line_start): + confirm_url = line.strip() + self.assertTrue(confirm_url) + + return confirm_url + + def test_subscription(self): + draft = make_test_data() + + url = urlreverse("community_personal_subscription", kwargs={ "operation": "subscribe", "username": "plain" }) + + # subscribe without list + r = self.client.get(url) + self.assertEqual(r.status_code, 404) + + # subscribe with list + clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + clist.added_docs.add(draft) + SearchRule.objects.create( + community_list=clist, + rule_type="name_contains", + state=State.objects.get(type="draft", slug="active"), + text="test", + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # do subscribe + mailbox_before = len(outbox) + r = self.client.post(url, { "email": "subscriber@example.com", "notify_on": "significant" }) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(outbox), mailbox_before + 1) + + # go to confirm page + confirm_url = self.extract_confirm_url(outbox[-1]) + r = self.client.get(confirm_url) + self.assertEqual(r.status_code, 200) + + # confirm subscribe + r = self.client.post(confirm_url, { 'action': 'confirm' }) + self.assertEqual(r.status_code, 302) + self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email="subscriber@example.com", significant=True).count(), 1) + + # unsubscribe + url = urlreverse("community_personal_subscription", kwargs={ "operation": "unsubscribe", "username": "plain" }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # do unsubscribe + mailbox_before = len(outbox) + r = self.client.post(url, { "email": "subscriber@example.com", "notify_on": "significant" }) + self.assertEqual(r.status_code, 200) + self.assertEqual(len(outbox), mailbox_before + 1) + + # go to confirm page + confirm_url = self.extract_confirm_url(outbox[-1]) + r = self.client.get(confirm_url) + self.assertEqual(r.status_code, 200) + + # confirm unsubscribe + r = self.client.post(confirm_url, { 'action': 'confirm' }) + self.assertEqual(r.status_code, 302) + self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email="subscriber@example.com", significant=True).count(), 0) + + def test_notification(self): + draft = make_test_data() + + clist = CommunityList.objects.create(user=User.objects.get(username="plain")) + clist.added_docs.add(draft) + SearchRule.objects.create( + community_list=clist, + rule_type="name_contains", + state=State.objects.get(type="draft", slug="active"), + text="test", + ) + + EmailSubscription.objects.create(community_list=clist, email="subscriber@example.com", significant=True) + + mailbox_before = len(outbox) + active_state = State.objects.get(type="draft", slug="active") + system = Person.objects.get(name="(System)") + add_state_change_event(draft, system, None, active_state) + self.assertEqual(len(outbox), mailbox_before) + + mailbox_before = len(outbox) + rfc_state = State.objects.get(type="draft", slug="rfc") + add_state_change_event(draft, system, active_state, rfc_state) + self.assertEqual(len(outbox), mailbox_before + 1) + self.assertTrue(draft.name in outbox[-1]["Subject"]) + diff --git a/ietf/community/urls.py b/ietf/community/urls.py index 99458eacf..119269a67 100644 --- a/ietf/community/urls.py +++ b/ietf/community/urls.py @@ -2,35 +2,21 @@ from django.conf.urls import patterns, url urlpatterns = patterns('ietf.community.views', - url(r'^personal/$', 'manage_personal_list', name='manage_personal_list'), - url(r'^personal/csv/$', 'csv_personal_list', name='csv_personal_list'), - url(r'^personal/(?P[a-f0-9]+)/view/$', 'view_personal_list', name='view_personal_list'), - url(r'^personal/(?P[a-f0-9]+)/csv/$', 'view_csv_personal_list', name='view_csv_personal_list'), - url(r'^personal/(?P[a-f0-9]+)/changes/feed/$', 'changes_personal_list', name='changes_personal_list'), - url(r'^personal/(?P[a-f0-9]+)/changes/significant/feed/$', 'significant_personal_list', name='significant_personal_list'), - url(r'^personal/(?P[a-f0-9]+)/subscribe/$', 'subscribe_personal_list', {'significant': False}, name='subscribe_personal_list'), - url(r'^personal/(?P[a-f0-9]+)/subscribe/significant/$', 'subscribe_personal_list', {'significant': True}, name='subscribe_significant_personal_list'), - url(r'^personal/(?P[a-f0-9]+)/unsubscribe/$', 'unsubscribe_personal_list', {'significant': False}, name='unsubscribe_personal_list'), - url(r'^personal/(?P[a-f0-9]+)/unsubscribe/significant/$', 'unsubscribe_personal_list', {'significant': True}, name='unsubscribe_significant_personal_list'), - url(r'^personal/trackdocument/(?P[^/]+)/$', 'track_document', name='community_personal_track_document'), - url(r'^personal/untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_personal_untrack_document'), + url(r'^personal/(?P[^/]+)/$', 'view_list', name='community_personal_view_list'), + url(r'^personal/(?P[^/]+)/manage/$', 'manage_list', name='community_personal_manage_list'), + url(r'^personal/(?P[^/]+)/trackdocument/(?P[^/]+)/$', 'track_document', name='community_personal_track_document'), + url(r'^personal/(?P[^/]+)/untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_personal_untrack_document'), + url(r'^personal/(?P[^/]+)/csv/$', 'export_to_csv', name='community_personal_csv'), + url(r'^personal/(?P[^/]+)/feed/$', 'feed', name='community_personal_feed'), + url(r'^personal/(?P[^/]+)/(?Psubscribe|unsubscribe)/$', 'subscription', name='community_personal_subscription'), + url(r'^personal/(?P[^/]+)/(?Psubscribe|unsubscribe)/confirm/(?P[^/]+)/$', 'confirm_subscription', name='community_personal_confirm_subscription'), - url(r'^group/(?P[\w.@+-]+)/$', 'manage_group_list', name='manage_group_list'), - url(r'^group/(?P[\w.@+-]+)/view/$', 'view_group_list', name='view_group_list'), - url(r'^group/(?P[\w.@+-]+)/changes/feed/$', 'changes_group_list', name='changes_group_list'), - url(r'^group/(?P[\w.@+-]+)/changes/significant/feed/$', 'significant_group_list', name='significant_group_list'), - url(r'^group/(?P[\w.@+-]+)/csv/$', 'csv_group_list', name='csv_group_list'), - url(r'^group/(?P[\w.@+-]+)/subscribe/$', 'subscribe_group_list', {'significant': False}, name='subscribe_group_list'), - url(r'^group/(?P[\w.@+-]+)/subscribe/significant/$', 'subscribe_group_list', {'significant': True}, name='subscribe_significant_group_list'), - url(r'^group/(?P[\w.@+-]+)/unsubscribe/$', 'unsubscribe_group_list', {'significant': False}, name='unsubscribe_group_list'), - url(r'^group/(?P[\w.@+-]+)/unsubscribe/significant/$', 'unsubscribe_group_list', {'significant': True}, name='unsubscribe_significant_group_list'), + url(r'^group/(?P[\w.@+-]+)/$', 'view_list', name='community_group_view_list'), + url(r'^group/(?P[\w.@+-]+)/manage/$', 'manage_list', name='community_group_manage_list'), url(r'^group/(?P[\w.@+-]+)/trackdocument/(?P[^/]+)/$', 'track_document', name='community_group_track_document'), url(r'^group/(?P[\w.@+-]+)/untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_group_untrack_document'), - - - url(r'^(?P[\d]+)/remove_rule/(?P[^/]+)/$', 'remove_rule', name='community_remove_rule'), - url(r'^(?P[\d]+)/subscribe/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[a-f0-9]+)/$', 'confirm_subscription', name='confirm_subscription'), - url(r'^(?P[\d]+)/subscribe/significant/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[a-f0-9]+)/$', 'confirm_significant_subscription', name='confirm_significant_subscription'), - url(r'^(?P[\d]+)/unsubscribe/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[a-f0-9]+)/$', 'confirm_unsubscription', name='confirm_unsubscription'), - url(r'^(?P[\d]+)/unsubscribe/significant/confirm/(?P[\w.@+-]+)/(?P[\d]+)/(?P[a-f0-9]+)/$', 'confirm_significant_unsubscription', name='confirm_significant_unsubscription'), + url(r'^group/(?P[\w.@+-]+)/csv/$', 'export_to_csv', name='community_group_csv'), + url(r'^group/(?P[\w.@+-]+)/feed/$', 'feed', name='community_group_feed'), + url(r'^group/(?P[^/]+)/(?Psubscribe|unsubscribe)/$', 'subscription', name='community_group_subscription'), + url(r'^group/(?P[^/]+)/(?Psubscribe|unsubscribe)/confirm/(?P[^/]+)/$', 'confirm_subscription', name='community_group_confirm_subscription'), ) diff --git a/ietf/community/utils.py b/ietf/community/utils.py index 5e6c45358..496317b50 100644 --- a/ietf/community/utils.py +++ b/ietf/community/utils.py @@ -1,16 +1,39 @@ -from ietf.community.models import CommunityList +from django.db.models import Q +from django.conf import settings +from django.contrib.sites.models import Site +from django.http import Http404 +import django.core.signing + +from ietf.community.models import CommunityList, EmailSubscription, SearchRule +from ietf.doc.models import Document, State from ietf.group.models import Role +from ietf.person.models import Person -def can_manage_community_list_for_group(user, group): - if not user or not user.is_authenticated() or not group: +from ietf.utils.mail import send_mail + +def states_of_significant_change(): + return State.objects.filter(used=True).filter( + Q(type="draft-stream-ietf", slug__in=['adopt-wg', 'wg-lc', 'writeupw', 'parked', 'dead']) | + Q(type="draft-iesg", slug__in=['pub-req', 'lc', 'iesg-eva', 'rfcqueue']) | + Q(type="draft-stream-iab", slug__in=['active', 'review-c', 'rfc-edit']) | + Q(type="draft-stream-irtf", slug__in=['active', 'rg-lc', 'irsg-w', 'iesg-rev', 'rfc-edit', 'iesghold']) | + Q(type="draft-stream-ise", slug__in=['receive', 'ise-rev', 'iesg-rev', 'rfc-edit', 'iesghold']) | + Q(type="draft", slug__in=['rfc', 'dead']) + ) + +def can_manage_community_list(user, clist): + if not user or not user.is_authenticated(): return False - if group.type_id == 'area': - return Role.objects.filter(name__slug='ad', person__user=user, group=group).exists() - elif group.type_id in ('wg', 'rg'): - return Role.objects.filter(name__slug='chair', person__user=user, group=group).exists() - else: - return False + if clist.user: + return user == clist.user + elif clist.group: + if clist.group.type_id == 'area': + return Role.objects.filter(name__slug='ad', person__user=user, group=clist.group).exists() + elif clist.group.type_id in ('wg', 'rg'): + return Role.objects.filter(name__slug='chair', person__user=user, group=clist.group).exists() + + return False def augment_docs_with_tracking_info(docs, user): """Add attribute to each document with whether the document is tracked @@ -21,7 +44,148 @@ def augment_docs_with_tracking_info(docs, user): if user and user.is_authenticated(): clist = CommunityList.objects.filter(user=user).first() if clist: - tracked.update(clist.get_documents().filter(pk__in=docs).values_list("pk", flat=True)) + tracked.update(docs_tracked_by_community_list(clist).filter(pk__in=docs).values_list("pk", flat=True)) for d in docs: d.tracked_in_personal_community_list = d.pk in tracked + +def docs_matching_community_list_rule(rule): + docs = Document.objects.all() + if rule.rule_type in ['group', 'area', 'group_rfc', 'area_rfc']: + return docs.filter(Q(group=rule.group_id) | Q(group__parent=rule.group_id), states=rule.state) + elif rule.rule_type.startswith("state_"): + return docs.filter(states=rule.state) + elif rule.rule_type in ["author", "author_rfc"]: + return docs.filter(states=rule.state, documentauthor__author__person=rule.person) + elif rule.rule_type == "ad": + return docs.filter(states=rule.state, ad=rule.person) + elif rule.rule_type == "shepherd": + return docs.filter(states=rule.state, shepherd__person=rule.person) + elif rule.rule_type == "name_contains": + return docs.filter(states=rule.state, name__icontains=rule.text) + + raise NotImplementedError + +def community_list_rules_matching_doc(doc): + from django.db import connection + + states = list(doc.states.values_list("pk", flat=True)) + + rules = SearchRule.objects.none() + + if doc.group_id: + groups = [doc.group_id] + if doc.group.parent_id: + groups.append(doc.group.parent_id) + rules |= SearchRule.objects.filter( + rule_type__in=['group', 'area', 'group_rfc', 'area_rfc'], + state__in=states, + group__in=groups + ) + + rules |= SearchRule.objects.filter( + rule_type__in=['state_iab', 'state_iana', 'state_iesg', 'state_irtf', 'state_ise', 'state_rfceditor', 'state_ietf'], + state__in=states, + ) + + rules |= SearchRule.objects.filter( + rule_type__in=["author", "author_rfc"], + state__in=states, + person__in=list(Person.objects.filter(email__documentauthor__document=doc)), + ) + + if doc.ad_id: + rules |= SearchRule.objects.filter( + rule_type="ad", + state__in=states, + person=doc.ad_id, + ) + + if doc.shepherd_id: + rules |= SearchRule.objects.filter( + rule_type="shepherd", + state__in=states, + person__email=doc.shepherd_id, + ) + + rules |= SearchRule.objects.filter( + rule_type="name_contains", + state__in=states, + ).extra( + # we need a reverse icontains here, unfortunately this means we need concatenation which isn't quite cross-platform + where=["%s like '%%' || text || '%%'" if connection.vendor == "sqlite" else "%s like concat('%%', text, '%%')"], + params=[doc.name] + ) + + return rules + + +def docs_tracked_by_community_list(clist): + if clist.pk is None: + return Document.objects.none() + + # in theory, we could use an OR query, but databases seem to have + # trouble with OR queries and complicated joins so do the OR'ing + # manually + doc_ids = set(clist.added_docs.values_list("pk", flat=True)) + for rule in clist.searchrule_set.all(): + doc_ids = doc_ids | set(docs_matching_community_list_rule(rule).values_list("pk", flat=True)) + + return Document.objects.filter(pk__in=doc_ids) + +def community_lists_tracking_doc(doc): + return CommunityList.objects.filter(Q(added_docs=doc) | Q(searchrule__in=community_list_rules_matching_doc(doc))) + + +def notify_event_to_subscribers(event): + significant = event.type == "changed_state" and event.state_id in [s.pk for s in states_of_significant_change()] + + subscriptions = EmailSubscription.objects.filter(community_list__in=community_lists_tracking_doc(event.doc)).distinct() + + if not significant: + subscriptions = subscriptions.filter(significant=False) + + for sub in subscriptions.select_related("community_list"): + clist = sub.community_list + subject = '%s notification: Changes to %s' % (clist.long_name(), event.doc.name) + + send_mail(None, sub.email, settings.DEFAULT_FROM_EMAIL, subject, 'community/notification_email.txt', + context = { + 'event': event, + 'clist': clist, + }) + +def confirmation_salt(operation, clist): + return ":".join(["community", + operation, + "personal" if clist.user else "group", + clist.user.username if clist.user else clist.group.acronym]) + +def send_subscription_confirmation_email(request, clist, operation, to_email, significant): + domain = Site.objects.get_current().domain + subject = 'Confirm list subscription: %s' % clist + from_email = settings.DEFAULT_FROM_EMAIL + + auth = django.core.signing.dumps([to_email, 1 if significant else 0], salt=confirmation_salt("subscribe", clist)) + + send_mail(request, to_email, from_email, subject, 'community/confirm_email.txt', { + 'domain': domain, + 'clist': clist, + 'auth': auth, + 'operation': operation, + }) + +def verify_confirmation_data(auth, clist, operation): + try: + data = django.core.signing.loads(auth, salt=confirmation_salt(operation, clist), max_age=24 * 60 * 60) + except django.core.signing.BadSignature: + raise Http404("Invalid or expired auth") + + try: + to_email, significant = data[:2] + except ValueError: + raise Http404("Invalid data") + + return to_email, bool(significant) + + diff --git a/ietf/community/views.py b/ietf/community/views.py index fcc0a6d10..a51aa0423 100644 --- a/ietf/community/views.py +++ b/ietf/community/views.py @@ -1,324 +1,300 @@ import csv import uuid import datetime -import hashlib import json -from django.db import IntegrityError -from django.conf import settings -from django.http import HttpResponse, HttpResponseForbidden, Http404, HttpResponseRedirect +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, Http404 from django.shortcuts import get_object_or_404, render, redirect from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User +from django.utils.html import strip_tags -from ietf.community.models import CommunityList, Rule, EmailSubscription -from ietf.community.forms import RuleForm, DisplayForm, SubscribeForm, UnSubscribeForm -from ietf.community.utils import can_manage_community_list_for_group +from ietf.community.models import CommunityList, SearchRule, EmailSubscription +from ietf.community.forms import SearchRuleTypeForm, SearchRuleForm, AddDocumentsForm, SubscriptionForm +from ietf.community.utils import can_manage_community_list +from ietf.community.utils import docs_tracked_by_community_list, docs_matching_community_list_rule +from ietf.community.utils import states_of_significant_change +from ietf.community.utils import send_subscription_confirmation_email +from ietf.community.utils import verify_confirmation_data from ietf.group.models import Group from ietf.doc.models import DocEvent, Document +from ietf.doc.utils_search import prepare_document_table +def lookup_list(username=None, acronym=None): + assert username or acronym -def _manage_list(request, clist): - display_config = clist.get_display_config() - if request.method == 'POST' and request.POST.get('save_rule', None): - rule_form = RuleForm(request.POST, clist=clist) - display_form = DisplayForm(instance=display_config) - if rule_form.is_valid(): - try: - rule_form.save() - except IntegrityError: - pass; - rule_form = RuleForm(clist=clist) - display_form = DisplayForm(instance=display_config) - elif request.method == 'POST' and request.POST.get('save_display', None): - display_form = DisplayForm(request.POST, instance=display_config) - rule_form = RuleForm(clist=clist) - if display_form.is_valid(): - display_form.save() - rule_form = RuleForm(clist=clist) - display_form = DisplayForm(instance=display_config) + if acronym: + group = get_object_or_404(Group, acronym=acronym) + clist = CommunityList.objects.filter(group=group).first() or CommunityList(group=group) else: - rule_form = RuleForm(clist=clist) - display_form = DisplayForm(instance=display_config) - clist = CommunityList.objects.get(id=clist.id) - return render(request, 'community/manage_clist.html', - {'cl': clist, - 'dc': display_config, - 'display_form': display_form, - 'rule_form': rule_form}) + user = get_object_or_404(User, username=username) + clist = CommunityList.objects.filter(user=user).first() or CommunityList(user=user) + return clist + + +def view_list(request, username=None, acronym=None): + clist = lookup_list(username, acronym) + + docs = docs_tracked_by_community_list(clist) + docs, meta = prepare_document_table(request, docs, request.GET) + + return render(request, 'community/view_list.html', { + 'clist': clist, + 'docs': docs, + 'meta': meta, + 'can_manage_list': can_manage_community_list(request.user, clist), + }) @login_required -def manage_personal_list(request): - clist = CommunityList.objects.get_or_create(user=request.user)[0] - return _manage_list(request, clist) +def manage_list(request, username=None, acronym=None): + # we need to be a bit careful because clist may not exist in the + # database so we can't call related stuff on it yet + clist = lookup_list(username, acronym) - -@login_required -def manage_group_list(request, acronym): - group = get_object_or_404(Group, acronym=acronym) - if not can_manage_community_list_for_group(request.user, group): + if not can_manage_community_list(request.user, clist): return HttpResponseForbidden("You do not have permission to access this view") - clist = CommunityList.objects.get_or_create(group=group)[0] - return _manage_list(request, clist) + action = request.POST.get('action') + + if request.method == 'POST' and action == 'add_documents': + add_doc_form = AddDocumentsForm(request.POST) + if add_doc_form.is_valid(): + if clist.pk is None: + clist.save() + + for d in add_doc_form.cleaned_data['documents']: + clist.added_docs.add(d) + + return HttpResponseRedirect("") + else: + add_doc_form = AddDocumentsForm() + + if request.method == 'POST' and action == 'add_rule': + rule_type_form = SearchRuleTypeForm(request.POST) + if rule_type_form.is_valid(): + rule_type = rule_type_form.cleaned_data['rule_type'] + + if rule_type: + rule_form = SearchRuleForm(clist, rule_type, request.POST) + if rule_form.is_valid(): + if clist.pk is None: + clist.save() + + rule = rule_form.save(commit=False) + rule.community_list = clist + rule.rule_type = rule_type + rule.save() + + return HttpResponseRedirect("") + else: + rule_type_form = SearchRuleTypeForm() + rule_form = None + + if request.method == 'POST' and action == 'remove_rule': + rule_pk = request.POST.get('rule') + if clist.pk is not None and rule_pk: + rule = get_object_or_404(SearchRule, pk=rule_pk, community_list=clist) + rule.delete() + + return HttpResponseRedirect("") + + rules = clist.searchrule_set.all() if clist.pk is not None else [] + for r in rules: + r.matching_documents_count = docs_matching_community_list_rule(r).count() + + empty_rule_forms = { rule_type: SearchRuleForm(clist, rule_type) for rule_type, _ in SearchRule.RULE_TYPES } + + total_count = docs_tracked_by_community_list(clist).count() + + return render(request, 'community/manage_list.html', { + 'clist': clist, + 'rules': rules, + 'individually_added': clist.added_docs.count() if clist.pk is not None else 0, + 'rule_type_form': rule_type_form, + 'rule_form': rule_form, + 'empty_rule_forms': empty_rule_forms, + 'total_count': total_count, + 'add_doc_form': add_doc_form, + }) + @login_required -def track_document(request, name, acronym=None): +def track_document(request, name, username=None, acronym=None): doc = get_object_or_404(Document, docalias__name=name) if request.method == "POST": - if acronym: - group = get_object_or_404(Group, acronym=acronym) - if not can_manage_community_list_for_group(request.user, group): - return HttpResponseForbidden("You do not have permission to access this view") + clist = lookup_list(username, acronym) + if not can_manage_community_list(request.user, clist): + return HttpResponseForbidden("You do not have permission to access this view") - clist = CommunityList.objects.get_or_create(group=group)[0] - else: - clist = CommunityList.objects.get_or_create(user=request.user)[0] + if clist.pk is None: + clist.save() - clist.added_ids.add(doc) + clist.added_docs.add(doc) if request.is_ajax(): return HttpResponse(json.dumps({ 'success': True }), content_type='text/plain') else: - return redirect("manage_personal_list") + if clist.group: + return redirect('community_group_view_list', acronym=clist.group.acronym) + else: + return redirect('community_personal_view_list', username=clist.user.username) return render(request, "community/track_document.html", { "name": doc.name, }) @login_required -def untrack_document(request, name, acronym=None): +def untrack_document(request, name, username=None, acronym=None): doc = get_object_or_404(Document, docalias__name=name) - if acronym: - group = get_object_or_404(Group, acronym=acronym) - if not can_manage_community_list_for_group(request.user, group): - return HttpResponseForbidden("You do not have permission to access this view") - clist = get_object_or_404(CommunityList, group=group) - else: - clist = get_object_or_404(CommunityList, user=request.user) + clist = lookup_list(username, acronym) + if not can_manage_community_list(request.user, clist): + return HttpResponseForbidden("You do not have permission to access this view") if request.method == "POST": - clist.added_ids.remove(doc) + if clist.pk is not None: + clist.added_docs.remove(doc) + if request.is_ajax(): return HttpResponse(json.dumps({ 'success': True }), content_type='text/plain') else: - return redirect("manage_personal_list") + if clist.group: + return redirect('community_group_view_list', acronym=clist.group.acronym) + else: + return redirect('community_personal_view_list', username=clist.user.username) return render(request, "community/untrack_document.html", { "name": doc.name, }) -@login_required -def remove_rule(request, list_id, rule_id): - clist = get_object_or_404(CommunityList, pk=list_id) - if ((clist.user and clist.user != request.user) - or (clist.group and not can_manage_community_list_for_group(request.user, clist.group))): - return HttpResponseForbidden("You do not have permission to access this view") +def export_to_csv(request, username=None, acronym=None): + clist = lookup_list(username, acronym) - rule = get_object_or_404(Rule, pk=rule_id) - rule.delete() - return HttpResponseRedirect(clist.get_manage_url()) + response = HttpResponse(content_type='text/csv') + if clist.group: + filename = "%s-draft-list.csv" % clist.group.acronym + else: + filename = "draft-list.csv" -def _view_list(request, clist): - display_config = clist.get_display_config() - return render(request, 'community/public/view_list.html', - {'cl': clist, - 'dc': display_config, - }) + response['Content-Disposition'] = 'attachment; filename=%s' % filename + writer = csv.writer(response, dialect=csv.excel, delimiter=',') -def view_personal_list(request, secret): - clist = get_object_or_404(CommunityList, secret=secret) - return _view_list(request, clist) + header = [ + "Name", + "Title", + "Date of latest revision", + "Status in the IETF process", + "Associated group", + "Associated AD", + "Date of latest change", + ] + writer.writerow(header) + docs = docs_tracked_by_community_list(clist).select_related('type', 'group', 'ad') + for doc in docs.prefetch_related("states", "tags"): + row = [] + row.append(doc.name) + row.append(doc.title) + e = doc.latest_event(type='new_revision') + row.append(e.time.strftime("%Y-%m-%d") if e else "") + row.append(strip_tags(doc.friendly_state())) + row.append(doc.group.acronym if doc.group else "") + row.append(unicode(doc.ad) if doc.ad else "") + e = doc.latest_event() + row.append(e.time.strftime("%Y-%m-%d") if e else "") + writer.writerow([v.encode("utf-8") for v in row]) -def view_group_list(request, acronym): - group = get_object_or_404(Group, acronym=acronym) - clist = get_object_or_404(CommunityList, group=group) - return _view_list(request, clist) + return response +def feed(request, username=None, acronym=None): + clist = lookup_list(username, acronym) -def _atom_view(request, clist, significant=False): - documents = [i['pk'] for i in clist.get_documents().values('pk')] - startDate = datetime.datetime.now() - datetime.timedelta(days=14) + significant = request.GET.get('significant', '') == '1' + + documents = docs_tracked_by_community_list(clist).values_list('pk', flat=True) + since = datetime.datetime.now() - datetime.timedelta(days=14) + + events = DocEvent.objects.filter( + doc__in=documents, + time__gte=since, + ).distinct().order_by('-time', '-id').select_related("doc") - notifications = DocEvent.objects.filter(doc__pk__in=documents, time__gte=startDate)\ - .distinct()\ - .order_by('-time', '-id') if significant: - notifications = notifications.filter(listnotification__significant=True) + events = events.filter(type="changed_state", statedocevent__state__in=list(states_of_significant_change())) host = request.get_host() feed_url = 'https://%s%s' % (host, request.get_full_path()) feed_id = uuid.uuid5(uuid.NAMESPACE_URL, feed_url.encode('utf-8')) - title = '%s RSS Feed' % clist.long_name() + title = u'%s RSS Feed' % clist.long_name() if significant: - subtitle = 'Document significant changes' + subtitle = 'Significant document changes' else: subtitle = 'Document changes' - return render(request, 'community/public/atom.xml', - {'cl': clist, - 'entries': notifications, - 'title': title, - 'subtitle': subtitle, - 'id': feed_id.get_urn(), - 'updated': datetime.datetime.today(), - }, - content_type='text/xml') + return render(request, 'community/atom.xml', { + 'clist': clist, + 'entries': events[:50], + 'title': title, + 'subtitle': subtitle, + 'id': feed_id.get_urn(), + 'updated': datetime.datetime.now(), + }, content_type='text/xml') -def changes_personal_list(request, secret): - clist = get_object_or_404(CommunityList, secret=secret) - return _atom_view(request, clist) - - -def changes_group_list(request, acronym): - group = get_object_or_404(Group, acronym=acronym) - clist = get_object_or_404(CommunityList, group=group) - return _atom_view(request, clist) - - -def significant_personal_list(request, secret): - clist = get_object_or_404(CommunityList, secret=secret) - return _atom_view(request, clist, significant=True) - - -def significant_group_list(request, acronym): - group = get_object_or_404(Group, acronym=acronym) - clist = get_object_or_404(CommunityList, group=group) - return _atom_view(request, clist, significant=True) - - -def _csv_list(request, clist): - display_config = clist.get_display_config() - response = HttpResponse(content_type='text/csv') - response['Content-Disposition'] = 'attachment; filename=draft-list.csv' - - writer = csv.writer(response, dialect=csv.excel, delimiter=',') - header = [] - fields = display_config.get_all_fields() - for field in fields: - header.append(field.description) - writer.writerow(header) - - for doc in clist.get_documents(): - row = [] - for field in fields: - row.append(field().get_value(doc, raw=True)) - writer.writerow(row) - return response - -@login_required -def csv_personal_list(request): - clist = CommunityList.objects.get_or_create(user=request.user)[0] - return _csv_list(request, clist) - - -@login_required -def csv_group_list(request, acronym): - group = get_object_or_404(Group, acronym=acronym) - if not can_manage_community_list_for_group(request.user, group): - return HttpResponseForbidden("You do not have permission to access this view") - - clist = CommunityList.objects.get_or_create(group=group)[0] - return _csv_list(request, clist) - -def view_csv_personal_list(request, secret): - clist = get_object_or_404(CommunityList, secret=secret) - return _csv_list(request, clist) - -def _subscribe_list(request, clist, significant): - success = False - if request.method == 'POST': - form = SubscribeForm(data=request.POST, clist=clist, significant=significant) - if form.is_valid(): - form.save() - success = True - else: - form = SubscribeForm(clist=clist, significant=significant) - return render(request, 'community/public/subscribe.html', - {'cl': clist, - 'form': form, - 'success': success, - }) - - -def _unsubscribe_list(request, clist, significant): - success = False - if request.method == 'POST': - form = UnSubscribeForm(data=request.POST, clist=clist, significant=significant) - if form.is_valid(): - form.save() - success = True - else: - form = UnSubscribeForm(clist=clist, significant=significant) - return render(request, 'community/public/unsubscribe.html', - {'cl': clist, - 'form': form, - 'success': success, - 'significant': significant, - }) - - -def subscribe_personal_list(request, secret, significant=False): - clist = get_object_or_404(CommunityList, secret=secret) - return _subscribe_list(request, clist, significant=significant) - - -def subscribe_group_list(request, acronym, significant=False): - group = get_object_or_404(Group, acronym=acronym) - clist = get_object_or_404(CommunityList, group=group) - return _subscribe_list(request, clist, significant=significant) - - -def unsubscribe_personal_list(request, secret, significant=False): - clist = get_object_or_404(CommunityList, secret=secret) - return _unsubscribe_list(request, clist, significant=significant) - - -def unsubscribe_group_list(request, acronym, significant=False): - group = get_object_or_404(Group, acronym=acronym) - clist = get_object_or_404(CommunityList, group=group) - return _unsubscribe_list(request, clist, significant=significant) - - -def confirm_subscription(request, list_id, email, date, confirm_hash, significant=False): - clist = get_object_or_404(CommunityList, pk=list_id) - valid = hashlib.md5('%s%s%s%s%s' % (settings.SECRET_KEY, date, email, 'subscribe', significant)).hexdigest() == confirm_hash - if not valid: +def subscription(request, operation, username=None, acronym=None): + clist = lookup_list(username, acronym) + if clist.pk is None: raise Http404 - (subscription, created) = EmailSubscription.objects.get_or_create( - community_list=clist, - email=email, - significant=significant) - return render(request, 'community/public/subscription_confirm.html', - {'cl': clist, - 'significant': significant, - }) + + to_email = None + if request.method == 'POST': + form = SubscriptionForm(operation, clist, request.POST) + if form.is_valid(): + to_email = form.cleaned_data['email'] + significant = form.cleaned_data['notify_on'] == "significant" + + send_subscription_confirmation_email(request, clist, operation, to_email, significant) + else: + form = SubscriptionForm(operation, clist) + + return render(request, 'community/subscription.html', { + 'clist': clist, + 'form': form, + 'to_email': to_email, + 'operation': operation, + }) -def confirm_significant_subscription(request, list_id, email, date, confirm_hash): - return confirm_subscription(request, list_id, email, date, confirm_hash, significant=True) - - -def confirm_unsubscription(request, list_id, email, date, confirm_hash, significant=False): - clist = get_object_or_404(CommunityList, pk=list_id) - valid = hashlib.md5('%s%s%s%s%s' % (settings.SECRET_KEY, date, email, 'unsubscribe', significant)).hexdigest() == confirm_hash - if not valid: +def confirm_subscription(request, operation, auth, username=None, acronym=None): + clist = lookup_list(username, acronym) + if clist.pk is None: raise Http404 - EmailSubscription.objects.filter( - community_list=clist, - email=email, - significant=significant).delete() - return render(request, 'community/public/unsubscription_confirm.html', - {'cl': clist, - 'significant': significant, - }) + to_email, significant = verify_confirmation_data(auth, clist, operation="subscribe") + + if request.method == "POST" and request.POST.get("action") == "confirm": + if operation == "subscribe": + if not EmailSubscription.objects.filter(community_list=clist, email__iexact=to_email, significant=significant): + EmailSubscription.objects.create(community_list=clist, email=to_email, significant=significant) + elif operation == "unsubscribe": + EmailSubscription.objects.filter( + community_list=clist, + email__iexact=to_email, + significant=significant).delete() + + if clist.group: + return redirect('community_group_view_list', acronym=clist.group.acronym) + else: + return redirect('community_personal_view_list', username=clist.user.username) + + return render(request, 'community/confirm_subscription.html', { + 'clist': clist, + 'to_email': to_email, + 'significant': significant, + 'operation': operation, + }) -def confirm_significant_unsubscription(request, list_id, email, date, confirm_hash): - return confirm_unsubscription(request, list_id, email, date, confirm_hash, significant=True) diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index b3b6034b4..c3eb88b4e 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -871,6 +871,7 @@ class ShepherdWriteupUploadForm(forms.Form): def clean_txt(self): return get_cleaned_text_file_content(self.cleaned_data["txt"]) +@login_required def edit_shepherd_writeup(request, name): """Change this document's shepherd writeup""" doc = get_object_or_404(Document, type="draft", name=name) diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index f2b017ddd..3a857aaf6 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -451,3 +451,10 @@ form.navbar-form input.form-control.input-sm { width: 141px; } pointer-events: none; } + +/* Community lists */ + +label#list-feeds { + display: inline-block; + font-weight: normal; +} diff --git a/ietf/static/ietf/js/manage-community-list.js b/ietf/static/ietf/js/manage-community-list.js new file mode 100644 index 000000000..f6c1ade8b --- /dev/null +++ b/ietf/static/ietf/js/manage-community-list.js @@ -0,0 +1,27 @@ +$(document).ready(function () { + $("[name=rule_type]").on("click change keypress", function () { + var form = $(this).closest("form"); + var ruleType = $(this).val(); + var emptyForms = $(".empty-forms"); + + var currentFormContent = form.find(".form-content-placeholder .rule-type"); + if (!ruleType || !currentFormContent.hasClass(ruleType)) { + // move previous back into the collection + if (currentFormContent.length > 0) + emptyForms.append(currentFormContent); + else + currentFormContent.html(""); // make sure it's empty + + // insert new + if (ruleType) + form.find(".form-content-placeholder").append(emptyForms.find("." + ruleType)); + } + }); + + $("[name=rule_type]").each(function () { + // don't trigger the handler if we have a form with errors + var placeholderContent = $(this).closest("form").find(".form-content-placeholder >"); + if (placeholderContent.length == 0 || placeholderContent.hasClass("rule-type")) + $(this).trigger("change"); + }); +}); diff --git a/ietf/templates/base/menu.html b/ietf/templates/base/menu.html index 856114b25..12d19a6ef 100644 --- a/ietf/templates/base/menu.html +++ b/ietf/templates/base/menu.html @@ -52,10 +52,10 @@ {% endif %} {% if user and user.is_authenticated %} -
  • My tracked docs
  • +
  • My tracked docs
  • {% for g in user|managed_groups %} -
  • {{ g.acronym }} {{ g.type.slug }} docs
  • +
  • {{ g.acronym }} {{ g.type.slug }} docs
  • {% endfor %} {% else %}
  • Sign in to track docs
  • diff --git a/ietf/templates/community/public/atom.xml b/ietf/templates/community/atom.xml similarity index 100% rename from ietf/templates/community/public/atom.xml rename to ietf/templates/community/atom.xml diff --git a/ietf/templates/community/confirm_email.txt b/ietf/templates/community/confirm_email.txt new file mode 100644 index 000000000..5c8fcf5e6 --- /dev/null +++ b/ietf/templates/community/confirm_email.txt @@ -0,0 +1,14 @@ +{% autoescape off %} +Hello, + +{% filter wordwrap:73 %}In order to {% if operation == "subscribe" %}complete{% else %}cancel{% endif %} your subscription on {% if significant %}significant {% endif %}changes to {{ clist.long_name }}, please follow this link or copy it and paste it in your web browser:{% endfilter %} + +https://{{ domain }}{% if clist.user %}{% url "community_personal_confirm_subscription" clist.user.username operation auth %}{% else %}{% url "community_group_confirm_subscription" operation clist.group.acronym auth %}{% endif %} + +The link is valid for 24 hours. + +Best regards, + + The Datatracker draft tracking service + (for the IETF Secretariat) +{% endautoescape %} diff --git a/ietf/templates/community/confirm_subscription.html b/ietf/templates/community/confirm_subscription.html new file mode 100644 index 000000000..2629ad504 --- /dev/null +++ b/ietf/templates/community/confirm_subscription.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %} + +{% block title %}Subscription to {{ clist.long_name }}{% endblock %} + +{% block content %} + {% origin %} +

    Subscription to {{ clist.long_name }}

    + +

    Confirm {% if operation == "subscribe" %}subscription{% else %}cancelling subscription{% endif %} of {{ to_email }} to {% if significant %}significant{% endif %} changes to {{ clist.long_name }}.

    + +
    {% csrf_token %} +

    + Back to list + +

    +
    +{% endblock %} diff --git a/ietf/templates/community/customize_display.html b/ietf/templates/community/customize_display.html deleted file mode 100644 index 2061f16e9..000000000 --- a/ietf/templates/community/customize_display.html +++ /dev/null @@ -1,26 +0,0 @@ -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %}{% origin %} -{% load bootstrap3 %} - -{% bootstrap_messages %} - -
    - {% csrf_token %} - {% bootstrap_form display_form %} - -
    - - {% for field in dc.get_display_fields_config %} -
    - -
    - {% endfor %} -
    - - {% buttons %} - - {% endbuttons %} -
    diff --git a/ietf/templates/community/display_field.html b/ietf/templates/community/display_field.html deleted file mode 100644 index 843377c18..000000000 --- a/ietf/templates/community/display_field.html +++ /dev/null @@ -1,2 +0,0 @@ -{# Copyright The IETF Trust 2015, All Rights Reserved #}{% load origin %}{% origin %} -{{ value|safe }} diff --git a/ietf/templates/community/manage_clist.html b/ietf/templates/community/manage_clist.html deleted file mode 100644 index b39c8f971..000000000 --- a/ietf/templates/community/manage_clist.html +++ /dev/null @@ -1,132 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} -{% load future %} -{% load staticfiles %} -{% load bootstrap3 %} - -{% block title %}{{ cl.long_name }}{% endblock %} - -{% block content %} - {% origin %} -

    {{ cl.long_name }}

    - - {% bootstrap_messages %} - - - -
    -
    - {% include "community/view_list.html" %} -
    - -
    -

    - In order to add some individual documents to your list, you have to: -

    -
      -
    • Search for the document or documents you want to add using the datatracker search form.
    • -
    • In the search results, you'll find a link to add individual documents to your list.
    • -
    - Document search - - - - - - - - - {% for doc in cl.added_ids.all %} - - - - - - - {% endfor %} - -
    NameStateTitle
    {{ doc.display_name }}{{ doc.get_state }}{{ doc.title }}Remove
    -
    - -
    - - - - - - {% for rule in cl.rule_set.all %} - {% with rule.get_callable_rule as callable %} - - - - - - - {% endwith %} - {% endfor %} - -
    RuleValueDocuments
    {{ callable.description }}{{ callable.show_value }}{% with rule.cached_ids.count as count %}{{ count }} document{{ count|pluralize }}{% endwith %}Remove
    - -

    Add a new rule

    - -
    - {% csrf_token %} - {% bootstrap_form rule_form %} - - {% buttons %} - - {% endbuttons %} -
    -
    - -
    - {% include "community/customize_display.html" %} -
    - - -
    - -{% endblock %} - -{% comment %} -XXX scrolling jumps around when using this, unfortunately - -{% endcomment %} diff --git a/ietf/templates/community/manage_list.html b/ietf/templates/community/manage_list.html new file mode 100644 index 000000000..b6a7488f7 --- /dev/null +++ b/ietf/templates/community/manage_list.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %} +{% load bootstrap3 %} +{% load staticfiles %} + +{% block pagehead %} + + +{% endblock %} + +{% block title %}Manage {{ clist.long_name }}{% endblock %} + +{% block content %} + {% origin %} +

    Manage {{ clist.long_name }}

    + + + + {% bootstrap_messages %} + +

    The list currently tracks {{ total_count }} document{{ total_count|pluralize }}.

    + +

    Back to list

    + +

    Individual documents

    + +

    The list tracks {{ individually_added }} individually added document{{ individually_added|pluralize }}.

    + + {% if clist.group %} +

    You can add individual documents here:

    + {% else %} +

    You can conveniently track individual documents in your personal list with the track icon in search results.

    + +

    You can also add documents here:

    + {% endif %} + +
    + {% csrf_token %} + {% bootstrap_field add_doc_form.documents show_label=False %} + +
    + + +

    Search rules

    + +

    You can track documents with a search rule. When a document fulfills the search criteria, it will automatically show up in the list.

    + + {% if rules %} + + + + + + {% for rule in rules %} + + + + + + + {% endfor %} + +
    RuleValueDocuments
    {{ rule.get_rule_type_display }} + {% if "group" in rule.rule_type or "area" in rule.rule_type %} + {{ rule.group.acronym }} + {% elif "state_" in rule.rule_type %} + {{ rule.state }} + {% elif "author" in rule.rule_type or rule.rule_type == "ad" or "shepherd" in rule.rule_type %} + {{ rule.person }} + {% elif "name_contains" in rule.rule_type %} + {{ rule.text }} + {% endif %} + {{ rule.matching_documents_count }} match{{ rule.matching_documents_count|pluralize:"es" }} +
    + {% csrf_token %} + + +
    +
    + + {% else %} + +

    No rules defined.

    + + {% endif %} + + + +
    +

    Add a new rule

    + +
    + {% csrf_token %} + {% bootstrap_form rule_type_form %} + +
    + {% if rule_form %} + {% bootstrap_form rule_form %} + {% endif %} +
    + + {% buttons %} + + {% endbuttons %} +
    + +
    + {% for rule_type, f in empty_rule_forms.items %} +
    + {% bootstrap_form f %} +
    + {% endfor %} +
    +
    + +{% endblock %} + +{% block js %} + + + +{% endblock %} diff --git a/ietf/templates/community/public/notification_email.txt b/ietf/templates/community/notification_email.txt similarity index 51% rename from ietf/templates/community/public/notification_email.txt rename to ietf/templates/community/notification_email.txt index 6db28028d..73a130d9f 100644 --- a/ietf/templates/community/public/notification_email.txt +++ b/ietf/templates/community/notification_email.txt @@ -3,14 +3,14 @@ Hello, This is a notification from the {{ clist.long_name }}. -Document: {{ notification.doc }}, -https://datatracker.ietf.org/doc/{{ notification.doc }} +Document: {{ event.doc }}, +https://datatracker.ietf.org/doc/{{ event.doc_id }}/ Change: -{{ notification.desc|textify|striptags }} +{{ event.desc|textify|striptags }} Best regards, - The datatracker draft tracking service + The Datatracker draft tracking service (for the IETF Secretariat) {% endautoescape %} diff --git a/ietf/templates/community/public/subscribe.html b/ietf/templates/community/public/subscribe.html deleted file mode 100644 index ce4bd596e..000000000 --- a/ietf/templates/community/public/subscribe.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} - -{% load bootstrap3 %} - -{% block title %}Subscribe to {{ cl.long_name }}{% endblock %} - -{% block content %} - {% origin %} - - {% if success %} -

    Subscription successful

    - -

    We have sent an email to your email address with instructions to complete your subscription.

    - {% else %} -

    Subscribe to {{ cl.long_name }}

    - - {% bootstrap_messages %} - -

    Subscribe to the email list for notifications of {% if significant %}significant {% endif %}changes on {{ cl.long_name }}.

    - -
    - {% csrf_token %} - {% bootstrap_form form %} - - {% buttons %} - - {% endbuttons %} -
    - {% endif %} -{% endblock %} diff --git a/ietf/templates/community/public/subscribe_email.txt b/ietf/templates/community/public/subscribe_email.txt deleted file mode 100644 index b0a8b3fc8..000000000 --- a/ietf/templates/community/public/subscribe_email.txt +++ /dev/null @@ -1,12 +0,0 @@ -{% autoescape off %} -Hello, - -In order to complete your subscription for {% if significant %}significant {% endif %}changes on {{ clist.long_name }}, please follow this link or copy it and paste it in your web browser: - -https://{{ domain }}{% if significant %}{% url "confirm_significant_subscription" clist.id to_email today auth %}{% else %}{% url "confirm_subscription" clist.id to_email today auth %}{% endif %} - -Best regards, - - The datatracker login manager service - (for the IETF Secretariat) -{% endautoescape %} diff --git a/ietf/templates/community/public/subscription_confirm.html b/ietf/templates/community/public/subscription_confirm.html deleted file mode 100644 index 55cce1f35..000000000 --- a/ietf/templates/community/public/subscription_confirm.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} - -{% block title %}Subscription to {{ cl.long_name }}{% endblock %} - -{% block content %} - {% origin %} -

    Subscription to {{ cl.long_name }}

    - -

    Your email address {{ email }} has been successfully subscribed to {{ cl.long_name }}.

    - -

    - Back -

    -{% endblock %} diff --git a/ietf/templates/community/public/unsubscribe.html b/ietf/templates/community/public/unsubscribe.html deleted file mode 100644 index 3ce159077..000000000 --- a/ietf/templates/community/public/unsubscribe.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} - -{% load bootstrap3 %} - -{% block title %}Cancel subscription to {{ cl.long_name }}{% endblock %} - -{% block content %} - {% origin %} - - {% if success %} -

    Cancellation successful

    - -

    - You will receive a confirmation email shortly containing further instructions on how to cancel your subscription. -

    - {% else %} -

    Cancel subscription to {{ cl.long_name }}

    - - {% bootstrap_messages %} - -

    - Cancel your subscription to the email list for notifications of {% if significant %}significant {% endif %}changes on {{ cl.long_name }}. -

    - -
    - {% csrf_token %} - {% bootstrap_form form %} - - {% buttons %} - - {% endbuttons %} -
    - {% endif %} -{% endblock %} diff --git a/ietf/templates/community/public/unsubscription_confirm.html b/ietf/templates/community/public/unsubscription_confirm.html deleted file mode 100644 index 8f9a109d7..000000000 --- a/ietf/templates/community/public/unsubscription_confirm.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} - -{% block title %}Cancelled subscription to {{ cl.long_name }}{% endblock %} - -{% block content %} - {% origin %} -

    Cancelled subscription to {{ cl.long_name }}

    - -

    - Your email address {{ email }} has been successfully removed from the {{ cl.long_name }} {% if significant %}significant {% endif %}changes mailing list. -

    - -

    - Back -

    -{% endblock %} diff --git a/ietf/templates/community/public/view_list.html b/ietf/templates/community/public/view_list.html deleted file mode 100644 index 2a475af17..000000000 --- a/ietf/templates/community/public/view_list.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} - -{% block pagehead %} - - -{% endblock %} - -{% block title %}{{ cl.long_name }}{% endblock %} - -{% block content %} - {% origin %} -

    {{ cl.long_name }}

    -

    - Subscribe to notification email lists: -

    - - {% include "community/view_list.html" %} -{% endblock %} diff --git a/ietf/templates/community/raw_view.html b/ietf/templates/community/raw_view.html deleted file mode 100644 index 02e91cf3a..000000000 --- a/ietf/templates/community/raw_view.html +++ /dev/null @@ -1,50 +0,0 @@ -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %}{% origin %} -{% load community_tags %} -{% load future %} - -{% with cl.get_rfcs_and_drafts as documents %} - {% with dc.get_active_fields as fields %} -

    Drafts

    - - - - {% for field in fields %} - - {% endfor %} - - - - {% for doc in documents.1 %} - - {% for field in fields %} - - {% endfor %} - - {% endfor %} - -
    {{ field.description }}
    {% show_field field doc %}
    - {% endwith %} - - {% with dc.get_active_fields as fields %} -

    RFCs

    - - - - {% for field in fields %} - - {% endfor %} - - - - {% for doc in documents.0 %} - - {% for field in fields %} - - {% endfor %} - - {% endfor %} - -
    {{ field.rfcDescription }}
    {% show_field field doc %}
    - {% endwith %} -{% endwith %} diff --git a/ietf/templates/community/subscription.html b/ietf/templates/community/subscription.html new file mode 100644 index 000000000..7147dd598 --- /dev/null +++ b/ietf/templates/community/subscription.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015, All Rights Reserved #} +{% load origin %} + +{% load bootstrap3 %} + +{% block title %}Subscription to {{ clist.long_name }}{% endblock %} + +{% block content %} + {% origin %} + + {% if not to_email %} +

    Subscription to {{ clist.long_name }}

    + + {% bootstrap_messages %} + + {% if operation == "subscribe" %} +

    Get notified when changes happen to any of the tracked documents.

    + {% else %} +

    Unsubscribe from getting notified when changes happen to any of the tracked documents.

    + {% endif %} + +
    + {% csrf_token %} + {% bootstrap_form form %} + + {% buttons %} + Back to list + + + {% endbuttons %} +
    + {% else %} +

    Sent confirmation email

    + +

    A message has been sent to {{ to_email }} with + a link for confirming {% if operation == subscribe %}the subscription{% else %}cancelling the subscription{% endif %}.

    + +

    + Back to list +

    + {% endif %} +{% endblock %} diff --git a/ietf/templates/community/public/unsubscribe_email.txt b/ietf/templates/community/unsubscribe_email.txt similarity index 100% rename from ietf/templates/community/public/unsubscribe_email.txt rename to ietf/templates/community/unsubscribe_email.txt diff --git a/ietf/templates/community/view_list.html b/ietf/templates/community/view_list.html index deb71d692..e996d6107 100644 --- a/ietf/templates/community/view_list.html +++ b/ietf/templates/community/view_list.html @@ -1,4 +1,38 @@ +{% extends "base.html" %} {# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %}{% origin %} -{% load community_tags %} -{% get_clist_view cl %} +{% load origin %} +{% load bootstrap3 %} + +{% block title %}{{ clist.long_name }}{% endblock %} + +{% block content %} + {% origin %} +

    {{ clist.long_name }}

    + + {% bootstrap_messages %} + + + + {% include "doc/search/search_results.html" with skip_no_matches_warning=True %} +{% endblock %} diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 35406c499..f6d88acf8 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -434,8 +434,8 @@
    {% if user.is_authenticated %} - Untrack - Track + Untrack + Track {% endif %} {% if can_edit and iesg_state %} diff --git a/ietf/templates/doc/search/search_result_row.html b/ietf/templates/doc/search/search_result_row.html index f0c65cdb0..654eeafc5 100644 --- a/ietf/templates/doc/search/search_result_row.html +++ b/ietf/templates/doc/search/search_result_row.html @@ -13,10 +13,10 @@ {% if user.is_authenticated %} - + - + {% endif %} From cdcad43fc0b7ba074638a28b19c113c995118445 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 17 Mar 2016 12:02:45 +0000 Subject: [PATCH 13/14] Simplify community lists further by letting email subscriptions reuse the existing infrastructure for accounts and emails, instead of a having a separate confirmation step - Legacy-Id: 10951 --- ietf/community/forms.py | 31 +++++---- ietf/community/migrations/0003_cleanup.py | 6 ++ .../community/migrations/0004_cleanup_data.py | 45 ++++++++++++- ietf/community/models.py | 13 ++-- ietf/community/resources.py | 4 +- ietf/community/tests.py | 63 +++++------------ ietf/community/urls.py | 6 +- ietf/community/utils.py | 44 +----------- ietf/community/views.py | 67 +++++++------------ ietf/ietfauth/urls.py | 2 +- ietf/static/ietf/css/ietf.css | 4 ++ ietf/templates/community/confirm_email.txt | 14 ---- .../community/confirm_subscription.html | 19 ------ ietf/templates/community/subscription.html | 46 ++++++++----- .../templates/community/unsubscribe_email.txt | 12 ---- ietf/templates/community/view_list.html | 8 ++- 16 files changed, 158 insertions(+), 226 deletions(-) delete mode 100644 ietf/templates/community/confirm_email.txt delete mode 100644 ietf/templates/community/confirm_subscription.html delete mode 100644 ietf/templates/community/unsubscribe_email.txt diff --git a/ietf/community/forms.py b/ietf/community/forms.py index 7c48569a7..9690feafe 100644 --- a/ietf/community/forms.py +++ b/ietf/community/forms.py @@ -88,25 +88,24 @@ class SearchRuleForm(forms.ModelForm): f.required = True -class SubscriptionForm(forms.Form): - notify_on = forms.ChoiceField(choices=[("all", "All changes"), ("significant", "Only significant state changes")], widget=forms.RadioSelect, initial="all") - email = forms.EmailField(label="Your email") - - def __init__(self, operation, clist, *args, **kwargs): - self.operation = operation +class SubscriptionForm(forms.ModelForm): + def __init__(self, user, clist, *args, **kwargs): self.clist = clist + self.user = user super(SubscriptionForm, self).__init__(*args, **kwargs) - if operation == "subscribe": - self.fields["notify_on"].label = "Get notified on" - else: - self.fields["notify_on"].label = "For notifications on" + self.fields["notify_on"].widget = forms.RadioSelect(choices=self.fields["notify_on"].choices) + self.fields["email"].queryset = self.fields["email"].queryset.filter(person__user=user, active=True).order_by("-primary") + self.fields["email"].widget = forms.RadioSelect(choices=[t for t in self.fields["email"].choices if t[0]]) + + if self.fields["email"].queryset: + self.fields["email"].initial = self.fields["email"].queryset[0] def clean(self): - if self.operation == "subscribe": - if EmailSubscription.objects.filter(community_list=self.clist, email=self.cleaned_data["email"], significant=self.cleaned_data["notify_on"] == "significant").exists(): - raise forms.ValidationError("This email address is already subscribed.") - else: - if not EmailSubscription.objects.filter(community_list=self.clist, email=self.cleaned_data["email"], significant=self.cleaned_data["notify_on"] == "significant").exists(): - raise forms.ValidationError("Couldn't find a matching subscription?") + if EmailSubscription.objects.filter(community_list=self.clist, email=self.cleaned_data["email"], notify_on=self.cleaned_data["notify_on"]).exists(): + raise forms.ValidationError("You already have a subscription like this.") + + class Meta: + model = EmailSubscription + fields = ("notify_on", "email") diff --git a/ietf/community/migrations/0003_cleanup.py b/ietf/community/migrations/0003_cleanup.py index b81cf1d23..cc97353fa 100644 --- a/ietf/community/migrations/0003_cleanup.py +++ b/ietf/community/migrations/0003_cleanup.py @@ -105,4 +105,10 @@ class Migration(migrations.Migration): name='searchrule', unique_together=set([]), ), + migrations.AddField( + model_name='emailsubscription', + name='notify_on', + field=models.CharField(default=b'all', max_length=30, choices=[(b'all', b'All changes'), (b'significant', b'Only significant state changes')]), + preserve_default=True, + ), ] diff --git a/ietf/community/migrations/0004_cleanup_data.py b/ietf/community/migrations/0004_cleanup_data.py index e4432a3ee..d7ac40d5b 100644 --- a/ietf/community/migrations/0004_cleanup_data.py +++ b/ietf/community/migrations/0004_cleanup_data.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import migrations +from django.db import migrations, models def port_rules_to_typed_system(apps, schema_editor): SearchRule = apps.get_model("community", "SearchRule") @@ -163,6 +163,37 @@ def get_rid_of_empty_lists(apps, schema_editor): if not cl.added_docs.exists() and not cl.searchrule_set.exists() and not cl.emailsubscription_set.exists(): cl.delete() +def move_email_subscriptions_to_preregistered_email(apps, schema_editor): + EmailSubscription = apps.get_model("community", "EmailSubscription") + Email = apps.get_model("person", "Email") + Person = apps.get_model("person", "Person") + + for e in EmailSubscription.objects.all(): + email_obj = None + try: + email_obj = Email.objects.get(address=e.email) + except Email.DoesNotExist: + if e.community_list.user: + person = Person.objects.filter(user=e.community_list.user).first() + + #print "creating", e.email, person.ascii + # we'll register it on the user, on the assumption + # that the user and the subscriber is the same person + email_obj = Email.objects.create( + address=e.email, + person=person, + ) + + if not email_obj: + print "deleting", e.email + e.delete() + +def fill_in_notify_on(apps, schema_editor): + EmailSubscription = apps.get_model("community", "EmailSubscription") + + EmailSubscription.objects.filter(significant=False, notify_on="all") + EmailSubscription.objects.filter(significant=True, notify_on="significant") + def noop(apps, schema_editor): pass @@ -175,9 +206,21 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython(port_rules_to_typed_system, delete_extra_person_rules), migrations.RunPython(rename_rule_type_forwards, rename_rule_type_backwards), + migrations.RunPython(move_email_subscriptions_to_preregistered_email, noop), migrations.RunPython(get_rid_of_empty_lists, noop), + migrations.RunPython(fill_in_notify_on, noop), migrations.RemoveField( model_name='searchrule', name='value', ), + migrations.AlterField( + model_name='emailsubscription', + name='email', + field=models.ForeignKey(to='person.Email'), + preserve_default=True, + ), + migrations.RemoveField( + model_name='emailsubscription', + name='significant', + ), ] diff --git a/ietf/community/models.py b/ietf/community/models.py index f91bfbfee..c90effbf0 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -4,7 +4,7 @@ from django.db.models import signals from ietf.doc.models import Document, DocEvent, State from ietf.group.models import Group -from ietf.person.models import Person +from ietf.person.models import Person, Email class CommunityList(models.Model): user = models.ForeignKey(User, blank=True, null=True) @@ -62,11 +62,16 @@ class SearchRule(models.Model): class EmailSubscription(models.Model): community_list = models.ForeignKey(CommunityList) - email = models.CharField(max_length=200) - significant = models.BooleanField(default=False) + email = models.ForeignKey(Email) + + NOTIFICATION_CHOICES = [ + ("all", "All changes"), + ("significant", "Only significant state changes") + ] + notify_on = models.CharField(max_length=30, choices=NOTIFICATION_CHOICES, default="all") def __unicode__(self): - return u"%s to %s (%s changes)" % (self.email, self.community_list, "significant" if self.significant else "all") + return u"%s to %s (%s changes)" % (self.email, self.community_list, self.notify_on) def notify_events(sender, instance, **kwargs): diff --git a/ietf/community/resources.py b/ietf/community/resources.py index 2e440adf3..4ec9007cf 100644 --- a/ietf/community/resources.py +++ b/ietf/community/resources.py @@ -51,8 +51,8 @@ class EmailSubscriptionResource(ModelResource): #resource_name = 'emailsubscription' filtering = { "id": ALL, - "email": ALL, - "significant": ALL, + "email": ALL_WITH_RELATIONS, + "notify_on": ALL, "community_list": ALL_WITH_RELATIONS, } api.community.register(EmailSubscriptionResource()) diff --git a/ietf/community/tests.py b/ietf/community/tests.py index 356a1a133..eb8fc16d9 100644 --- a/ietf/community/tests.py +++ b/ietf/community/tests.py @@ -9,7 +9,7 @@ from ietf.community.models import CommunityList, SearchRule, EmailSubscription from ietf.community.utils import docs_matching_community_list_rule, community_list_rules_matching_doc from ietf.doc.models import State from ietf.doc.utils import add_state_change_event -from ietf.person.models import Person +from ietf.person.models import Person, Email from ietf.utils.test_data import make_test_data from ietf.utils.test_utils import login_testing_unauthorized, TestCase from ietf.utils.mail import outbox @@ -217,28 +217,18 @@ class CommunityListTests(TestCase): self.assertEqual(r.status_code, 200) self.assertTrue('' not in r.content) - def extract_confirm_url(self, confirm_email): - # dig out confirm_email link - msg = confirm_email.get_payload(decode=True) - line_start = "http" - confirm_url = None - for line in msg.split("\n"): - if line.strip().startswith(line_start): - confirm_url = line.strip() - self.assertTrue(confirm_url) - - return confirm_url - def test_subscription(self): draft = make_test_data() - url = urlreverse("community_personal_subscription", kwargs={ "operation": "subscribe", "username": "plain" }) + url = urlreverse("community_personal_subscription", kwargs={ "username": "plain" }) - # subscribe without list + login_testing_unauthorized(self, "plain", url) + + # subscription without list r = self.client.get(url) self.assertEqual(r.status_code, 404) - # subscribe with list + # subscription with list clist = CommunityList.objects.create(user=User.objects.get(username="plain")) clist.added_docs.add(draft) SearchRule.objects.create( @@ -250,42 +240,19 @@ class CommunityListTests(TestCase): r = self.client.get(url) self.assertEqual(r.status_code, 200) - # do subscribe - mailbox_before = len(outbox) - r = self.client.post(url, { "email": "subscriber@example.com", "notify_on": "significant" }) - self.assertEqual(r.status_code, 200) - self.assertEqual(len(outbox), mailbox_before + 1) - - # go to confirm page - confirm_url = self.extract_confirm_url(outbox[-1]) - r = self.client.get(confirm_url) - self.assertEqual(r.status_code, 200) - - # confirm subscribe - r = self.client.post(confirm_url, { 'action': 'confirm' }) + # subscribe + email = Email.objects.filter(person__user__username="plain").first() + r = self.client.post(url, { "email": email.pk, "notify_on": "significant", "action": "subscribe" }) self.assertEqual(r.status_code, 302) - self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email="subscriber@example.com", significant=True).count(), 1) - # unsubscribe - url = urlreverse("community_personal_subscription", kwargs={ "operation": "unsubscribe", "username": "plain" }) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) + subscription = EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").first() - # do unsubscribe - mailbox_before = len(outbox) - r = self.client.post(url, { "email": "subscriber@example.com", "notify_on": "significant" }) - self.assertEqual(r.status_code, 200) - self.assertEqual(len(outbox), mailbox_before + 1) + self.assertTrue(subscription) - # go to confirm page - confirm_url = self.extract_confirm_url(outbox[-1]) - r = self.client.get(confirm_url) - self.assertEqual(r.status_code, 200) - - # confirm unsubscribe - r = self.client.post(confirm_url, { 'action': 'confirm' }) + # delete subscription + r = self.client.post(url, { "subscription_id": subscription.pk, "action": "unsubscribe" }) self.assertEqual(r.status_code, 302) - self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email="subscriber@example.com", significant=True).count(), 0) + self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").count(), 0) def test_notification(self): draft = make_test_data() @@ -299,7 +266,7 @@ class CommunityListTests(TestCase): text="test", ) - EmailSubscription.objects.create(community_list=clist, email="subscriber@example.com", significant=True) + EmailSubscription.objects.create(community_list=clist, email=Email.objects.filter(person__user__username="plain").first(), notify_on="significant") mailbox_before = len(outbox) active_state = State.objects.get(type="draft", slug="active") diff --git a/ietf/community/urls.py b/ietf/community/urls.py index 119269a67..befa5e3af 100644 --- a/ietf/community/urls.py +++ b/ietf/community/urls.py @@ -8,8 +8,7 @@ urlpatterns = patterns('ietf.community.views', url(r'^personal/(?P[^/]+)/untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_personal_untrack_document'), url(r'^personal/(?P[^/]+)/csv/$', 'export_to_csv', name='community_personal_csv'), url(r'^personal/(?P[^/]+)/feed/$', 'feed', name='community_personal_feed'), - url(r'^personal/(?P[^/]+)/(?Psubscribe|unsubscribe)/$', 'subscription', name='community_personal_subscription'), - url(r'^personal/(?P[^/]+)/(?Psubscribe|unsubscribe)/confirm/(?P[^/]+)/$', 'confirm_subscription', name='community_personal_confirm_subscription'), + url(r'^personal/(?P[^/]+)/subscription/$', 'subscription', name='community_personal_subscription'), url(r'^group/(?P[\w.@+-]+)/$', 'view_list', name='community_group_view_list'), url(r'^group/(?P[\w.@+-]+)/manage/$', 'manage_list', name='community_group_manage_list'), @@ -17,6 +16,5 @@ urlpatterns = patterns('ietf.community.views', url(r'^group/(?P[\w.@+-]+)/untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_group_untrack_document'), url(r'^group/(?P[\w.@+-]+)/csv/$', 'export_to_csv', name='community_group_csv'), url(r'^group/(?P[\w.@+-]+)/feed/$', 'feed', name='community_group_feed'), - url(r'^group/(?P[^/]+)/(?Psubscribe|unsubscribe)/$', 'subscription', name='community_group_subscription'), - url(r'^group/(?P[^/]+)/(?Psubscribe|unsubscribe)/confirm/(?P[^/]+)/$', 'confirm_subscription', name='community_group_confirm_subscription'), + url(r'^group/(?P[\w.@+-]+)/subscription/$', 'subscription', name='community_group_subscription'), ) diff --git a/ietf/community/utils.py b/ietf/community/utils.py index 496317b50..ed694229e 100644 --- a/ietf/community/utils.py +++ b/ietf/community/utils.py @@ -1,8 +1,5 @@ from django.db.models import Q from django.conf import settings -from django.contrib.sites.models import Site -from django.http import Http404 -import django.core.signing from ietf.community.models import CommunityList, EmailSubscription, SearchRule from ietf.doc.models import Document, State @@ -143,49 +140,14 @@ def notify_event_to_subscribers(event): subscriptions = EmailSubscription.objects.filter(community_list__in=community_lists_tracking_doc(event.doc)).distinct() if not significant: - subscriptions = subscriptions.filter(significant=False) + subscriptions = subscriptions.filter(notify_on="all") - for sub in subscriptions.select_related("community_list"): + for sub in subscriptions.select_related("community_list", "email"): clist = sub.community_list subject = '%s notification: Changes to %s' % (clist.long_name(), event.doc.name) - send_mail(None, sub.email, settings.DEFAULT_FROM_EMAIL, subject, 'community/notification_email.txt', + send_mail(None, sub.email.address, settings.DEFAULT_FROM_EMAIL, subject, 'community/notification_email.txt', context = { 'event': event, 'clist': clist, }) - -def confirmation_salt(operation, clist): - return ":".join(["community", - operation, - "personal" if clist.user else "group", - clist.user.username if clist.user else clist.group.acronym]) - -def send_subscription_confirmation_email(request, clist, operation, to_email, significant): - domain = Site.objects.get_current().domain - subject = 'Confirm list subscription: %s' % clist - from_email = settings.DEFAULT_FROM_EMAIL - - auth = django.core.signing.dumps([to_email, 1 if significant else 0], salt=confirmation_salt("subscribe", clist)) - - send_mail(request, to_email, from_email, subject, 'community/confirm_email.txt', { - 'domain': domain, - 'clist': clist, - 'auth': auth, - 'operation': operation, - }) - -def verify_confirmation_data(auth, clist, operation): - try: - data = django.core.signing.loads(auth, salt=confirmation_salt(operation, clist), max_age=24 * 60 * 60) - except django.core.signing.BadSignature: - raise Http404("Invalid or expired auth") - - try: - to_email, significant = data[:2] - except ValueError: - raise Http404("Invalid data") - - return to_email, bool(significant) - - diff --git a/ietf/community/views.py b/ietf/community/views.py index a51aa0423..41e38cfd6 100644 --- a/ietf/community/views.py +++ b/ietf/community/views.py @@ -14,8 +14,6 @@ from ietf.community.forms import SearchRuleTypeForm, SearchRuleForm, AddDocument from ietf.community.utils import can_manage_community_list from ietf.community.utils import docs_tracked_by_community_list, docs_matching_community_list_rule from ietf.community.utils import states_of_significant_change -from ietf.community.utils import send_subscription_confirmation_email -from ietf.community.utils import verify_confirmation_data from ietf.group.models import Group from ietf.doc.models import DocEvent, Document from ietf.doc.utils_search import prepare_document_table @@ -39,11 +37,14 @@ def view_list(request, username=None, acronym=None): docs = docs_tracked_by_community_list(clist) docs, meta = prepare_document_table(request, docs, request.GET) + subscribed = request.user.is_authenticated() and EmailSubscription.objects.filter(community_list=clist, email__person__user=request.user) + return render(request, 'community/view_list.html', { 'clist': clist, 'docs': docs, 'meta': meta, 'can_manage_list': can_manage_community_list(request.user, clist), + 'subscribed': subscribed, }) @login_required @@ -245,56 +246,34 @@ def feed(request, username=None, acronym=None): }, content_type='text/xml') -def subscription(request, operation, username=None, acronym=None): +@login_required +def subscription(request, username=None, acronym=None): clist = lookup_list(username, acronym) if clist.pk is None: raise Http404 - to_email = None - if request.method == 'POST': - form = SubscriptionForm(operation, clist, request.POST) - if form.is_valid(): - to_email = form.cleaned_data['email'] - significant = form.cleaned_data['notify_on'] == "significant" + existing_subscriptions = EmailSubscription.objects.filter(community_list=clist, email__person__user=request.user) - send_subscription_confirmation_email(request, clist, operation, to_email, significant) + if request.method == 'POST': + action = request.POST.get("action") + if action == "subscribe": + form = SubscriptionForm(request.user, clist, request.POST) + if form.is_valid(): + subscription = form.save(commit=False) + subscription.community_list = clist + subscription.save() + + return HttpResponseRedirect("") + + elif action == "unsubscribe": + existing_subscriptions.filter(pk=request.POST.get("subscription_id")).delete() + + return HttpResponseRedirect("") else: - form = SubscriptionForm(operation, clist) + form = SubscriptionForm(request.user, clist) return render(request, 'community/subscription.html', { 'clist': clist, 'form': form, - 'to_email': to_email, - 'operation': operation, + 'existing_subscriptions': existing_subscriptions, }) - - -def confirm_subscription(request, operation, auth, username=None, acronym=None): - clist = lookup_list(username, acronym) - if clist.pk is None: - raise Http404 - - to_email, significant = verify_confirmation_data(auth, clist, operation="subscribe") - - if request.method == "POST" and request.POST.get("action") == "confirm": - if operation == "subscribe": - if not EmailSubscription.objects.filter(community_list=clist, email__iexact=to_email, significant=significant): - EmailSubscription.objects.create(community_list=clist, email=to_email, significant=significant) - elif operation == "unsubscribe": - EmailSubscription.objects.filter( - community_list=clist, - email__iexact=to_email, - significant=significant).delete() - - if clist.group: - return redirect('community_group_view_list', acronym=clist.group.acronym) - else: - return redirect('community_personal_view_list', username=clist.user.username) - - return render(request, 'community/confirm_subscription.html', { - 'clist': clist, - 'to_email': to_email, - 'significant': significant, - 'operation': operation, - }) - diff --git a/ietf/ietfauth/urls.py b/ietf/ietfauth/urls.py index d7156642f..a87da91bc 100644 --- a/ietf/ietfauth/urls.py +++ b/ietf/ietfauth/urls.py @@ -10,7 +10,7 @@ urlpatterns = patterns('ietf.ietfauth.views', url(r'^logout/$', logout), # url(r'^loggedin/$', 'ietf_loggedin'), # url(r'^loggedout/$', 'logged_out'), - url(r'^profile/$', 'profile'), + url(r'^profile/$', 'profile', name="account_profile"), # (r'^login/(?P[a-z0-9.@]+)/(?P.+)$', 'url_login'), url(r'^testemail/$', 'test_email'), url(r'^create/$', 'create_account', name='create_account'), diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 3a857aaf6..e0e6c3209 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -458,3 +458,7 @@ label#list-feeds { display: inline-block; font-weight: normal; } + +.email-subscription button[type=submit] { + margin-left: 3em; +} diff --git a/ietf/templates/community/confirm_email.txt b/ietf/templates/community/confirm_email.txt deleted file mode 100644 index 5c8fcf5e6..000000000 --- a/ietf/templates/community/confirm_email.txt +++ /dev/null @@ -1,14 +0,0 @@ -{% autoescape off %} -Hello, - -{% filter wordwrap:73 %}In order to {% if operation == "subscribe" %}complete{% else %}cancel{% endif %} your subscription on {% if significant %}significant {% endif %}changes to {{ clist.long_name }}, please follow this link or copy it and paste it in your web browser:{% endfilter %} - -https://{{ domain }}{% if clist.user %}{% url "community_personal_confirm_subscription" clist.user.username operation auth %}{% else %}{% url "community_group_confirm_subscription" operation clist.group.acronym auth %}{% endif %} - -The link is valid for 24 hours. - -Best regards, - - The Datatracker draft tracking service - (for the IETF Secretariat) -{% endautoescape %} diff --git a/ietf/templates/community/confirm_subscription.html b/ietf/templates/community/confirm_subscription.html deleted file mode 100644 index 2629ad504..000000000 --- a/ietf/templates/community/confirm_subscription.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html" %} -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %} - -{% block title %}Subscription to {{ clist.long_name }}{% endblock %} - -{% block content %} - {% origin %} -

    Subscription to {{ clist.long_name }}

    - -

    Confirm {% if operation == "subscribe" %}subscription{% else %}cancelling subscription{% endif %} of {{ to_email }} to {% if significant %}significant{% endif %} changes to {{ clist.long_name }}.

    - -
    {% csrf_token %} -

    - Back to list - -

    -
    -{% endblock %} diff --git a/ietf/templates/community/subscription.html b/ietf/templates/community/subscription.html index 7147dd598..63495c4d3 100644 --- a/ietf/templates/community/subscription.html +++ b/ietf/templates/community/subscription.html @@ -9,17 +9,36 @@ {% block content %} {% origin %} - {% if not to_email %} -

    Subscription to {{ clist.long_name }}

    +

    Subscription to {{ clist.long_name }}

    - {% bootstrap_messages %} + {% bootstrap_messages %} - {% if operation == "subscribe" %} -

    Get notified when changes happen to any of the tracked documents.

    - {% else %} -

    Unsubscribe from getting notified when changes happen to any of the tracked documents.

    - {% endif %} +

    Get notified when changes happen to any of the tracked documents.

    + {% if existing_subscriptions %} +

    Existing subscriptions

    + +
      + {% for s in existing_subscriptions %} + + {% endfor %} +
    + {% endif %} + +

    Back to list

    + +

    Add new subscription

    + +

    The email addresses you can choose between are those registered in your profile.

    + + {% if form.fields.email.queryset %}
    {% csrf_token %} {% bootstrap_form form %} @@ -27,17 +46,10 @@ {% buttons %} Back to list - + {% endbuttons %}
    {% else %} -

    Sent confirmation email

    - -

    A message has been sent to {{ to_email }} with - a link for confirming {% if operation == subscribe %}the subscription{% else %}cancelling the subscription{% endif %}.

    - -

    - Back to list -

    +
    You do not have any active email addresses registered with your account. Go to your profile and add or activate one.
    {% endif %} {% endblock %} diff --git a/ietf/templates/community/unsubscribe_email.txt b/ietf/templates/community/unsubscribe_email.txt deleted file mode 100644 index 7b93d2f30..000000000 --- a/ietf/templates/community/unsubscribe_email.txt +++ /dev/null @@ -1,12 +0,0 @@ -{% autoescape off %} -Hello, - -In order to complete the cancelation of your subscription to {% if significant %}significant {% endif %}changes on {{ clist.long_name }}, please follow this link or copy it and paste it in your web browser: - -https://{{ domain }}{% if significant %}{% url "confirm_significant_unsubscription" clist.id to_email today auth %}{% else %}{% url "confirm_unsubscription" clist.id to_email today auth %}{% endif %} - -Best regards, - - The datatracker login manager service - (for the IETF Secretariat) -{% endautoescape %} diff --git a/ietf/templates/community/view_list.html b/ietf/templates/community/view_list.html index e996d6107..905481621 100644 --- a/ietf/templates/community/view_list.html +++ b/ietf/templates/community/view_list.html @@ -17,9 +17,11 @@ {% endif %} {% if clist.pk != None %} -
  • Subscribe to changes
  • - -
  • Unsubscribe
  • + {% if subscribed %} +
  • Change subscription
  • + {% else %} +
  • Subscribe to changes
  • + {% endif %} {% endif %}
  • Export as CSV
  • From c7589f9b6aeeb5a810d1858df34cd544c9d6fdf8 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Tue, 22 Mar 2016 12:48:44 +0000 Subject: [PATCH 14/14] Integrate community lists for groups with the existing group documents page. Each WG/RG now gets a list with an initial set of rules to populate the list. Refine the community list management interface a bit to support the group lists better - group lists aren't connected to the usual track icons so need to be able to add/remove individual drafts. Change the "name contains" rule to support regular expressions to enable each group to have a default replacement for the previously implemented "related documents" search. Maintain a materialized view of the regexp-matched drafts with a call in the submit code to avoid having to scan all drafts/~1000 group rules all the time. - Legacy-Id: 10963 --- ietf/community/forms.py | 3 + ietf/community/migrations/0003_cleanup.py | 10 +- .../community/migrations/0004_cleanup_data.py | 26 ++++ ietf/community/models.py | 18 ++- ietf/community/tests.py | 127 ++++++++++++++---- ietf/community/urls.py | 23 ++-- ietf/community/utils.py | 46 +++++-- ietf/community/views.py | 69 ++++------ ietf/doc/templatetags/managed_groups.py | 10 +- ietf/group/edit.py | 5 +- ietf/group/info.py | 56 ++++---- ietf/group/tests_info.py | 5 +- ietf/group/urls_info_details.py | 4 + ietf/group/utils.py | 25 ++++ ietf/secr/groups/views.py | 5 +- ietf/submit/tests.py | 4 +- ietf/submit/utils.py | 3 + ietf/templates/base/menu.html | 2 +- ietf/templates/community/list_menu.html | 22 +++ ietf/templates/community/manage_list.html | 34 ++++- ietf/templates/community/subscription.html | 9 +- ietf/templates/community/view_list.html | 31 +---- ietf/templates/group/group_documents.html | 1 + 23 files changed, 371 insertions(+), 167 deletions(-) create mode 100644 ietf/templates/community/list_menu.html diff --git a/ietf/community/forms.py b/ietf/community/forms.py index 9690feafe..d6dce1a68 100644 --- a/ietf/community/forms.py +++ b/ietf/community/forms.py @@ -87,6 +87,9 @@ class SearchRuleForm(forms.ModelForm): for name, f in self.fields.iteritems(): f.required = True + def clean_text(self): + return self.cleaned_data["text"].strip().lower() # names are always lower case + class SubscriptionForm(forms.ModelForm): def __init__(self, user, clist, *args, **kwargs): diff --git a/ietf/community/migrations/0003_cleanup.py b/ietf/community/migrations/0003_cleanup.py index cc97353fa..f90283127 100644 --- a/ietf/community/migrations/0003_cleanup.py +++ b/ietf/community/migrations/0003_cleanup.py @@ -88,7 +88,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='searchrule', name='text', - field=models.CharField(default=b'', max_length=255, blank=True), + field=models.CharField(default=b'', max_length=255, verbose_name=b'Text/RegExp', blank=True), preserve_default=True, ), migrations.RemoveField( @@ -98,7 +98,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='searchrule', name='rule_type', - field=models.CharField(max_length=30, choices=[(b'group', b'All I-Ds associated with a particular group'), (b'area', b'All I-Ds associated with all groups in a particular Area'), (b'group_rfc', b'All RFCs associated with a particular group'), (b'area_rfc', b'All RFCs associated with all groups in a particular Area'), (b'state_iab', b'All I-Ds that are in a particular IAB state'), (b'state_iana', b'All I-Ds that are in a particular IANA state'), (b'state_iesg', b'All I-Ds that are in a particular IESG state'), (b'state_irtf', b'All I-Ds that are in a particular IRTF state'), (b'state_ise', b'All I-Ds that are in a particular ISE state'), (b'state_rfceditor', b'All I-Ds that are in a particular RFC Editor state'), (b'state_ietf', b'All I-Ds that are in a particular Working Group state'), (b'author', b'All I-Ds with a particular author'), (b'author_rfc', b'All RFCs with a particular author'), (b'ad', b'All I-Ds with a particular responsible AD'), (b'shepherd', b'All I-Ds with a particular document shepherd'), (b'name_contains', b'All I-Ds with particular text in the name')]), + field=models.CharField(max_length=30, choices=[(b'group', b'All I-Ds associated with a particular group'), (b'area', b'All I-Ds associated with all groups in a particular Area'), (b'group_rfc', b'All RFCs associated with a particular group'), (b'area_rfc', b'All RFCs associated with all groups in a particular Area'), (b'state_iab', b'All I-Ds that are in a particular IAB state'), (b'state_iana', b'All I-Ds that are in a particular IANA state'), (b'state_iesg', b'All I-Ds that are in a particular IESG state'), (b'state_irtf', b'All I-Ds that are in a particular IRTF state'), (b'state_ise', b'All I-Ds that are in a particular ISE state'), (b'state_rfceditor', b'All I-Ds that are in a particular RFC Editor state'), (b'state_ietf', b'All I-Ds that are in a particular Working Group state'), (b'author', b'All I-Ds with a particular author'), (b'author_rfc', b'All RFCs with a particular author'), (b'ad', b'All I-Ds with a particular responsible AD'), (b'shepherd', b'All I-Ds with a particular document shepherd'), (b'name_contains', b'All I-Ds with particular text/regular expression in the name')]), preserve_default=True, ), migrations.AlterUniqueTogether( @@ -111,4 +111,10 @@ class Migration(migrations.Migration): field=models.CharField(default=b'all', max_length=30, choices=[(b'all', b'All changes'), (b'significant', b'Only significant state changes')]), preserve_default=True, ), + migrations.AddField( + model_name='searchrule', + name='name_contains_index', + field=models.ManyToManyField(to='doc.Document'), + preserve_default=True, + ), ] diff --git a/ietf/community/migrations/0004_cleanup_data.py b/ietf/community/migrations/0004_cleanup_data.py index d7ac40d5b..6790230ac 100644 --- a/ietf/community/migrations/0004_cleanup_data.py +++ b/ietf/community/migrations/0004_cleanup_data.py @@ -194,6 +194,31 @@ def fill_in_notify_on(apps, schema_editor): EmailSubscription.objects.filter(significant=False, notify_on="all") EmailSubscription.objects.filter(significant=True, notify_on="significant") +def add_group_community_lists(apps, schema_editor): + Group = apps.get_model("group", "Group") + Document = apps.get_model("doc", "Document") + State = apps.get_model("doc", "State") + CommunityList = apps.get_model("community", "CommunityList") + SearchRule = apps.get_model("community", "SearchRule") + + active_state = State.objects.get(slug="active", type="draft") + rfc_state = State.objects.get(slug="rfc", type="draft") + + for g in Group.objects.filter(type__in=("rg", "wg")): + clist = CommunityList.objects.filter(group=g).first() + if clist: + SearchRule.objects.get_or_create(community_list=clist, rule_type="group", group=g, state=active_state) + SearchRule.objects.get_or_create(community_list=clist, rule_type="group_rfc", group=g, state=rfc_state) + r, _ = SearchRule.objects.get_or_create(community_list=clist, rule_type="name_contains", text=r"^draft-[^-]+-%s-" % g.acronym, state=active_state) + r.name_contains_index = Document.objects.filter(docalias__name__regex=r.text) + + else: + clist = CommunityList.objects.create(group=g) + SearchRule.objects.create(community_list=clist, rule_type="group", group=g, state=active_state) + SearchRule.objects.create(community_list=clist, rule_type="group_rfc", group=g, state=rfc_state) + r = SearchRule.objects.create(community_list=clist, rule_type="name_contains", text=r"^draft-[^-]+-%s-" % g.acronym, state=active_state) + r.name_contains_index = Document.objects.filter(docalias__name__regex=r.text) + def noop(apps, schema_editor): pass @@ -209,6 +234,7 @@ class Migration(migrations.Migration): migrations.RunPython(move_email_subscriptions_to_preregistered_email, noop), migrations.RunPython(get_rid_of_empty_lists, noop), migrations.RunPython(fill_in_notify_on, noop), + migrations.RunPython(add_group_community_lists, noop), migrations.RemoveField( model_name='searchrule', name='value', diff --git a/ietf/community/models.py b/ietf/community/models.py index c90effbf0..b3e265767 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import User from django.db import models from django.db.models import signals +from django.core.urlresolvers import reverse as urlreverse from ietf.doc.models import Document, DocEvent, State from ietf.group.models import Group @@ -22,6 +23,13 @@ class CommunityList(models.Model): def __unicode__(self): return self.long_name() + def get_absolute_url(self): + if self.user: + return urlreverse("community_personal_view_list", kwargs={ 'username': self.user.username }) + elif self.group: + return urlreverse("group_docs", kwargs={ 'acronym': self.group.acronym }) + return "" + class SearchRule(models.Model): # these types define the UI for setting up the rule, and also @@ -47,7 +55,7 @@ class SearchRule(models.Model): ('shepherd', 'All I-Ds with a particular document shepherd'), - ('name_contains', 'All I-Ds with particular text in the name'), + ('name_contains', 'All I-Ds with particular text/regular expression in the name'), ] community_list = models.ForeignKey(CommunityList) @@ -57,7 +65,13 @@ class SearchRule(models.Model): state = models.ForeignKey(State, blank=True, null=True) group = models.ForeignKey(Group, blank=True, null=True) person = models.ForeignKey(Person, blank=True, null=True) - text = models.CharField(max_length=255, blank=True, default="") + text = models.CharField(verbose_name="Text/RegExp", max_length=255, blank=True, default="") + + # store a materialized view/index over which documents are matched + # by the name_contains rule to avoid having to scan the whole + # database - we update this manually when the rule is changed and + # when new documents are submitted + name_contains_index = models.ManyToManyField(Document) class EmailSubscription(models.Model): diff --git a/ietf/community/tests.py b/ietf/community/tests.py index eb8fc16d9..6cbd5ad34 100644 --- a/ietf/community/tests.py +++ b/ietf/community/tests.py @@ -7,6 +7,8 @@ from django.contrib.auth.models import User from ietf.community.models import CommunityList, SearchRule, EmailSubscription from ietf.community.utils import docs_matching_community_list_rule, community_list_rules_matching_doc +from ietf.community.utils import reset_name_contains_index_for_rule +from ietf.group.utils import setup_default_community_list_for_group from ietf.doc.models import State from ietf.doc.utils import add_state_change_event from ietf.person.models import Person, Email @@ -34,7 +36,8 @@ class CommunityListTests(TestCase): rule_shepherd = SearchRule.objects.create(rule_type="shepherd", state=State.objects.get(type="draft", slug="active"), person=draft.shepherd.person, community_list=clist) - rule_name_contains = SearchRule.objects.create(rule_type="name_contains", state=State.objects.get(type="draft", slug="active"), text="-".join(draft.name.split("-")[2:]), community_list=clist) + rule_name_contains = SearchRule.objects.create(rule_type="name_contains", state=State.objects.get(type="draft", slug="active"), text="draft-.*" + "-".join(draft.name.split("-")[2:]), community_list=clist) + reset_name_contains_index_for_rule(rule_name_contains) # doc -> rules matching_rules = list(community_list_rules_matching_doc(draft)) @@ -79,7 +82,7 @@ class CommunityListTests(TestCase): self.assertEqual(r.status_code, 200) self.assertTrue(draft.name in r.content) - def test_manage_list(self): + def test_manage_personal_list(self): draft = make_test_data() url = urlreverse("community_personal_manage_list", kwargs={ "username": "plain" }) @@ -94,6 +97,17 @@ class CommunityListTests(TestCase): clist = CommunityList.objects.get(user__username="plain") self.assertTrue(clist.added_docs.filter(pk=draft.pk)) + # document shows up on GET + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertTrue(draft.name in r.content) + + # remove document + r = self.client.post(url, { "action": "remove_document", "document": draft.pk }) + self.assertEqual(r.status_code, 302) + clist = CommunityList.objects.get(user__username="plain") + self.assertTrue(not clist.added_docs.filter(pk=draft.pk)) + # add rule r = self.client.post(url, { "action": "add_rule", @@ -105,6 +119,17 @@ class CommunityListTests(TestCase): clist = CommunityList.objects.get(user__username="plain") self.assertTrue(clist.searchrule_set.filter(rule_type="author_rfc")) + # add name_contains rule + r = self.client.post(url, { + "action": "add_rule", + "rule_type": "name_contains", + "name_contains-text": "draft.*mars", + "name_contains-state": State.objects.get(type="draft", slug="active").pk, + }) + self.assertEqual(r.status_code, 302) + clist = CommunityList.objects.get(user__username="plain") + self.assertTrue(clist.searchrule_set.filter(rule_type="name_contains")) + # rule shows up on GET r = self.client.get(url) self.assertEqual(r.status_code, 200) @@ -121,7 +146,43 @@ class CommunityListTests(TestCase): clist = CommunityList.objects.get(user__username="plain") self.assertTrue(not clist.searchrule_set.filter(rule_type="author_rfc")) - def test_track_untrack_document_for_personal_list_through_ajax(self): + def test_manage_group_list(self): + draft = make_test_data() + + url = urlreverse("community_group_manage_list", kwargs={ "acronym": draft.group.acronym }) + setup_default_community_list_for_group(draft.group) + login_testing_unauthorized(self, "marschairman", url) + + # test GET, rest is tested with personal list + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + def test_track_untrack_document(self): + draft = make_test_data() + + url = urlreverse("community_personal_track_document", kwargs={ "username": "plain", "name": draft.name }) + login_testing_unauthorized(self, "plain", url) + + # track + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post(url) + self.assertEqual(r.status_code, 302) + clist = CommunityList.objects.get(user__username="plain") + self.assertEqual(list(clist.added_docs.all()), [draft]) + + # untrack + url = urlreverse("community_personal_untrack_document", kwargs={ "username": "plain", "name": draft.name }) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post(url) + self.assertEqual(r.status_code, 302) + clist = CommunityList.objects.get(user__username="plain") + self.assertEqual(list(clist.added_docs.all()), []) + + def test_track_untrack_document_through_ajax(self): draft = make_test_data() url = urlreverse("community_personal_track_document", kwargs={ "username": "plain", "name": draft.name }) @@ -142,31 +203,6 @@ class CommunityListTests(TestCase): clist = CommunityList.objects.get(user__username="plain") self.assertEqual(list(clist.added_docs.all()), []) - def test_track_untrack_document_for_group_list(self): - draft = make_test_data() - - url = urlreverse("community_group_track_document", kwargs={ "acronym": draft.group.acronym, "name": draft.name }) - login_testing_unauthorized(self, "marschairman", url) - - # track - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - - r = self.client.post(url) - self.assertEqual(r.status_code, 302) - clist = CommunityList.objects.get(group__acronym=draft.group.acronym) - self.assertEqual(list(clist.added_docs.all()), [draft]) - - # untrack - url = urlreverse("community_group_untrack_document", kwargs={ "acronym": draft.group.acronym, "name": draft.name }) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - - r = self.client.post(url) - self.assertEqual(r.status_code, 302) - clist = CommunityList.objects.get(group__acronym=draft.group.acronym) - self.assertEqual(list(clist.added_docs.all()), []) - def test_csv(self): draft = make_test_data() @@ -190,6 +226,17 @@ class CommunityListTests(TestCase): # this is a simple-minded test, we don't actually check the fields self.assertTrue(draft.name in r.content) + def test_csv_for_group(self): + draft = make_test_data() + + url = urlreverse("community_group_csv", kwargs={ "acronym": draft.group.acronym }) + + setup_default_community_list_for_group(draft.group) + + # test GET, rest is tested with personal list + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + def test_feed(self): draft = make_test_data() @@ -217,6 +264,17 @@ class CommunityListTests(TestCase): self.assertEqual(r.status_code, 200) self.assertTrue('' not in r.content) + def test_feed_for_group(self): + draft = make_test_data() + + url = urlreverse("community_group_feed", kwargs={ "acronym": draft.group.acronym }) + + setup_default_community_list_for_group(draft.group) + + # test GET, rest is tested with personal list + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + def test_subscription(self): draft = make_test_data() @@ -254,6 +312,19 @@ class CommunityListTests(TestCase): self.assertEqual(r.status_code, 302) self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").count(), 0) + def test_subscription_for_group(self): + draft = make_test_data() + + url = urlreverse("community_group_subscription", kwargs={ "acronym": draft.group.acronym }) + + setup_default_community_list_for_group(draft.group) + + login_testing_unauthorized(self, "marschairman", url) + + # test GET, rest is tested with personal list + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + def test_notification(self): draft = make_test_data() diff --git a/ietf/community/urls.py b/ietf/community/urls.py index befa5e3af..73d31c8c6 100644 --- a/ietf/community/urls.py +++ b/ietf/community/urls.py @@ -1,20 +1,13 @@ from django.conf.urls import patterns, url -urlpatterns = patterns('ietf.community.views', - url(r'^personal/(?P[^/]+)/$', 'view_list', name='community_personal_view_list'), - url(r'^personal/(?P[^/]+)/manage/$', 'manage_list', name='community_personal_manage_list'), - url(r'^personal/(?P[^/]+)/trackdocument/(?P[^/]+)/$', 'track_document', name='community_personal_track_document'), - url(r'^personal/(?P[^/]+)/untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_personal_untrack_document'), - url(r'^personal/(?P[^/]+)/csv/$', 'export_to_csv', name='community_personal_csv'), - url(r'^personal/(?P[^/]+)/feed/$', 'feed', name='community_personal_feed'), - url(r'^personal/(?P[^/]+)/subscription/$', 'subscription', name='community_personal_subscription'), +urlpatterns = patterns('', + url(r'^personal/(?P[^/]+)/$', 'ietf.community.views.view_list', name='community_personal_view_list'), + url(r'^personal/(?P[^/]+)/manage/$', 'ietf.community.views.manage_list', name='community_personal_manage_list'), + url(r'^personal/(?P[^/]+)/trackdocument/(?P[^/]+)/$', 'ietf.community.views.track_document', name='community_personal_track_document'), + url(r'^personal/(?P[^/]+)/untrackdocument/(?P[^/]+)/$', 'ietf.community.views.untrack_document', name='community_personal_untrack_document'), + url(r'^personal/(?P[^/]+)/csv/$', 'ietf.community.views.export_to_csv', name='community_personal_csv'), + url(r'^personal/(?P[^/]+)/feed/$', 'ietf.community.views.feed', name='community_personal_feed'), + url(r'^personal/(?P[^/]+)/subscription/$', 'ietf.community.views.subscription', name='community_personal_subscription'), - url(r'^group/(?P[\w.@+-]+)/$', 'view_list', name='community_group_view_list'), - url(r'^group/(?P[\w.@+-]+)/manage/$', 'manage_list', name='community_group_manage_list'), - url(r'^group/(?P[\w.@+-]+)/trackdocument/(?P[^/]+)/$', 'track_document', name='community_group_track_document'), - url(r'^group/(?P[\w.@+-]+)/untrackdocument/(?P[^/]+)/$', 'untrack_document', name='community_group_untrack_document'), - url(r'^group/(?P[\w.@+-]+)/csv/$', 'export_to_csv', name='community_group_csv'), - url(r'^group/(?P[\w.@+-]+)/feed/$', 'feed', name='community_group_feed'), - url(r'^group/(?P[\w.@+-]+)/subscription/$', 'subscription', name='community_group_subscription'), ) diff --git a/ietf/community/utils.py b/ietf/community/utils.py index ed694229e..ee8c87042 100644 --- a/ietf/community/utils.py +++ b/ietf/community/utils.py @@ -1,10 +1,15 @@ +import re + from django.db.models import Q from django.conf import settings from ietf.community.models import CommunityList, EmailSubscription, SearchRule from ietf.doc.models import Document, State -from ietf.group.models import Role +from ietf.group.models import Role, Group from ietf.person.models import Person +from ietf.ietfauth.utils import has_role +from django.contrib.auth.models import User +from django.shortcuts import get_object_or_404 from ietf.utils.mail import send_mail @@ -18,6 +23,18 @@ def states_of_significant_change(): Q(type="draft", slug__in=['rfc', 'dead']) ) +def lookup_community_list(username=None, acronym=None): + assert username or acronym + + if acronym: + group = get_object_or_404(Group, acronym=acronym) + clist = CommunityList.objects.filter(group=group).first() or CommunityList(group=group) + else: + user = get_object_or_404(User, username=username) + clist = CommunityList.objects.filter(user=user).first() or CommunityList(user=user) + + return clist + def can_manage_community_list(user, clist): if not user or not user.is_authenticated(): return False @@ -25,6 +42,9 @@ def can_manage_community_list(user, clist): if clist.user: return user == clist.user elif clist.group: + if has_role(user, 'Secretariat'): + return True + if clist.group.type_id == 'area': return Role.objects.filter(name__slug='ad', person__user=user, group=clist.group).exists() elif clist.group.type_id in ('wg', 'rg'): @@ -46,6 +66,21 @@ def augment_docs_with_tracking_info(docs, user): for d in docs: d.tracked_in_personal_community_list = d.pk in tracked +def reset_name_contains_index_for_rule(rule): + if not rule.rule_type == "name_contains": + return + + rule.name_contains_index = Document.objects.filter(docalias__name__regex=rule.text) + +def update_name_contains_indexes_with_new_doc(doc): + for r in SearchRule.objects.filter(rule_type="name_contains"): + # in theory we could use the database to do this query, but + # Django doesn't support a reversed regex operator, and regexp + # support needs backend-specific code so custom SQL is a bit + # cumbersome too + if re.search(r.text, doc.name): + r.name_contains_index.add(doc) + def docs_matching_community_list_rule(rule): docs = Document.objects.all() if rule.rule_type in ['group', 'area', 'group_rfc', 'area_rfc']: @@ -59,13 +94,11 @@ def docs_matching_community_list_rule(rule): elif rule.rule_type == "shepherd": return docs.filter(states=rule.state, shepherd__person=rule.person) elif rule.rule_type == "name_contains": - return docs.filter(states=rule.state, name__icontains=rule.text) + return docs.filter(states=rule.state, searchrule=rule) raise NotImplementedError def community_list_rules_matching_doc(doc): - from django.db import connection - states = list(doc.states.values_list("pk", flat=True)) rules = SearchRule.objects.none() @@ -108,10 +141,7 @@ def community_list_rules_matching_doc(doc): rules |= SearchRule.objects.filter( rule_type="name_contains", state__in=states, - ).extra( - # we need a reverse icontains here, unfortunately this means we need concatenation which isn't quite cross-platform - where=["%s like '%%' || text || '%%'" if connection.vendor == "sqlite" else "%s like concat('%%', text, '%%')"], - params=[doc.name] + name_contains_index=doc, # search our materialized index to avoid full scan ) return rules diff --git a/ietf/community/views.py b/ietf/community/views.py index 41e38cfd6..c841d29bc 100644 --- a/ietf/community/views.py +++ b/ietf/community/views.py @@ -4,35 +4,20 @@ import datetime import json from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, Http404 -from django.shortcuts import get_object_or_404, render, redirect +from django.shortcuts import get_object_or_404, render from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import User from django.utils.html import strip_tags -from ietf.community.models import CommunityList, SearchRule, EmailSubscription +from ietf.community.models import SearchRule, EmailSubscription from ietf.community.forms import SearchRuleTypeForm, SearchRuleForm, AddDocumentsForm, SubscriptionForm -from ietf.community.utils import can_manage_community_list +from ietf.community.utils import lookup_community_list, can_manage_community_list from ietf.community.utils import docs_tracked_by_community_list, docs_matching_community_list_rule -from ietf.community.utils import states_of_significant_change -from ietf.group.models import Group +from ietf.community.utils import states_of_significant_change, reset_name_contains_index_for_rule from ietf.doc.models import DocEvent, Document from ietf.doc.utils_search import prepare_document_table -def lookup_list(username=None, acronym=None): - assert username or acronym - - if acronym: - group = get_object_or_404(Group, acronym=acronym) - clist = CommunityList.objects.filter(group=group).first() or CommunityList(group=group) - else: - user = get_object_or_404(User, username=username) - clist = CommunityList.objects.filter(user=user).first() or CommunityList(user=user) - - return clist - - -def view_list(request, username=None, acronym=None): - clist = lookup_list(username, acronym) +def view_list(request, username=None): + clist = lookup_community_list(username) docs = docs_tracked_by_community_list(clist) docs, meta = prepare_document_table(request, docs, request.GET) @@ -48,10 +33,10 @@ def view_list(request, username=None, acronym=None): }) @login_required -def manage_list(request, username=None, acronym=None): +def manage_list(request, username=None, acronym=None, group_type=None): # we need to be a bit careful because clist may not exist in the # database so we can't call related stuff on it yet - clist = lookup_list(username, acronym) + clist = lookup_community_list(username, acronym) if not can_manage_community_list(request.user, clist): return HttpResponseForbidden("You do not have permission to access this view") @@ -71,6 +56,14 @@ def manage_list(request, username=None, acronym=None): else: add_doc_form = AddDocumentsForm() + if request.method == 'POST' and action == 'remove_document': + document_pk = request.POST.get('document') + if clist.pk is not None and document_pk: + document = get_object_or_404(clist.added_docs, pk=document_pk) + clist.added_docs.remove(document) + + return HttpResponseRedirect("") + if request.method == 'POST' and action == 'add_rule': rule_type_form = SearchRuleTypeForm(request.POST) if rule_type_form.is_valid(): @@ -86,6 +79,8 @@ def manage_list(request, username=None, acronym=None): rule.community_list = clist rule.rule_type = rule_type rule.save() + if rule.rule_type == "name_contains": + reset_name_contains_index_for_rule(rule) return HttpResponseRedirect("") else: @@ -111,7 +106,7 @@ def manage_list(request, username=None, acronym=None): return render(request, 'community/manage_list.html', { 'clist': clist, 'rules': rules, - 'individually_added': clist.added_docs.count() if clist.pk is not None else 0, + 'individually_added': clist.added_docs.all() if clist.pk is not None else [], 'rule_type_form': rule_type_form, 'rule_form': rule_form, 'empty_rule_forms': empty_rule_forms, @@ -125,7 +120,7 @@ def track_document(request, name, username=None, acronym=None): doc = get_object_or_404(Document, docalias__name=name) if request.method == "POST": - clist = lookup_list(username, acronym) + clist = lookup_community_list(username, acronym) if not can_manage_community_list(request.user, clist): return HttpResponseForbidden("You do not have permission to access this view") @@ -137,10 +132,7 @@ def track_document(request, name, username=None, acronym=None): if request.is_ajax(): return HttpResponse(json.dumps({ 'success': True }), content_type='text/plain') else: - if clist.group: - return redirect('community_group_view_list', acronym=clist.group.acronym) - else: - return redirect('community_personal_view_list', username=clist.user.username) + return HttpResponseRedirect(clist.get_absolute_url()) return render(request, "community/track_document.html", { "name": doc.name, @@ -149,7 +141,7 @@ def track_document(request, name, username=None, acronym=None): @login_required def untrack_document(request, name, username=None, acronym=None): doc = get_object_or_404(Document, docalias__name=name) - clist = lookup_list(username, acronym) + clist = lookup_community_list(username, acronym) if not can_manage_community_list(request.user, clist): return HttpResponseForbidden("You do not have permission to access this view") @@ -160,18 +152,15 @@ def untrack_document(request, name, username=None, acronym=None): if request.is_ajax(): return HttpResponse(json.dumps({ 'success': True }), content_type='text/plain') else: - if clist.group: - return redirect('community_group_view_list', acronym=clist.group.acronym) - else: - return redirect('community_personal_view_list', username=clist.user.username) + return HttpResponseRedirect(clist.get_absolute_url()) return render(request, "community/untrack_document.html", { "name": doc.name, }) -def export_to_csv(request, username=None, acronym=None): - clist = lookup_list(username, acronym) +def export_to_csv(request, username=None, acronym=None, group_type=None): + clist = lookup_community_list(username, acronym) response = HttpResponse(content_type='text/csv') @@ -211,8 +200,8 @@ def export_to_csv(request, username=None, acronym=None): return response -def feed(request, username=None, acronym=None): - clist = lookup_list(username, acronym) +def feed(request, username=None, acronym=None, group_type=None): + clist = lookup_community_list(username, acronym) significant = request.GET.get('significant', '') == '1' @@ -247,8 +236,8 @@ def feed(request, username=None, acronym=None): @login_required -def subscription(request, username=None, acronym=None): - clist = lookup_list(username, acronym) +def subscription(request, username=None, acronym=None, group_type=None): + clist = lookup_community_list(username, acronym) if clist.pk is None: raise Http404 diff --git a/ietf/doc/templatetags/managed_groups.py b/ietf/doc/templatetags/managed_groups.py index ecc673c4c..113bc0262 100644 --- a/ietf/doc/templatetags/managed_groups.py +++ b/ietf/doc/templatetags/managed_groups.py @@ -10,11 +10,11 @@ def managed_groups(user): return [] groups = [] - groups.extend(Group.objects.filter( - role__name__slug='ad', - role__person__user=user, - type__slug='area', - state__slug='active').select_related("type")) + # groups.extend(Group.objects.filter( + # role__name__slug='ad', + # role__person__user=user, + # type__slug='area', + # state__slug='active').select_related("type")) groups.extend(Group.objects.filter( role__name__slug='chair', diff --git a/ietf/group/edit.py b/ietf/group/edit.py index b2773d54c..fe19b9e7f 100644 --- a/ietf/group/edit.py +++ b/ietf/group/edit.py @@ -17,7 +17,7 @@ from ietf.doc.utils_charter import charter_name_for_group from ietf.group.models import ( Group, Role, GroupEvent, GroupHistory, GroupStateName, GroupStateTransitions, GroupTypeName, GroupURL, ChangeStateGroupEvent ) from ietf.group.utils import save_group_in_history, can_manage_group_type -from ietf.group.utils import get_group_or_404 +from ietf.group.utils import get_group_or_404, setup_default_community_list_for_group from ietf.ietfauth.utils import has_role from ietf.person.fields import SearchableEmailsField from ietf.person.models import Person, Email @@ -231,6 +231,9 @@ def edit(request, group_type=None, acronym=None, action="edit"): state=clean["state"] ) + if group.features.has_documents: + setup_default_community_list_for_group(group) + e = ChangeStateGroupEvent(group=group, type="changed_state") e.time = group.time e.by = request.user.person diff --git a/ietf/group/info.py b/ietf/group/info.py index 62797d99e..84b178518 100644 --- a/ietf/group/info.py +++ b/ietf/group/info.py @@ -38,7 +38,7 @@ import re from tempfile import mkstemp from collections import OrderedDict -from django.shortcuts import render, redirect +from django.shortcuts import render, redirect, get_object_or_404 from django.template.loader import render_to_string from django.http import HttpResponse, Http404, HttpResponseRedirect from django.conf import settings @@ -55,6 +55,8 @@ from ietf.group.models import Group, Role, ChangeStateGroupEvent from ietf.name.models import GroupTypeName from ietf.group.utils import get_charter_text, can_manage_group_type, milestone_reviewer_for_group_type from ietf.group.utils import can_manage_materials, get_group_or_404 +from ietf.community.utils import docs_tracked_by_community_list, can_manage_community_list +from ietf.community.models import CommunityList, EmailSubscription from ietf.utils.pipe import pipe from ietf.settings import MAILING_LIST_INFO_URL from ietf.mailtrigger.utils import gather_relevant_expansions @@ -354,6 +356,11 @@ def construct_group_menu_context(request, group, selected, group_type, others): if group.state_id != "proposed" and (is_chair or can_manage): actions.append((u"Edit milestones", urlreverse("group_edit_milestones", kwargs=kwargs))) + if group.features.has_documents: + clist = CommunityList.objects.filter(group=group).first() + if clist and can_manage_community_list(request.user, clist): + actions.append((u'Manage document list', urlreverse('community_group_manage_list', kwargs=kwargs))) + if group.features.has_materials and can_manage_materials(request.user, group): actions.append((u"Upload material", urlreverse("ietf.doc.views_material.choose_material_type", kwargs=kwargs))) @@ -378,36 +385,23 @@ def construct_group_menu_context(request, group, selected, group_type, others): return d -def search_for_group_documents(group, request): - qs = Document.objects.filter(states__type="draft", states__slug__in=["active", "rfc"], group=group) - docs, meta = prepare_document_table(request, qs) - - # get the related docs - qs_related = Document.objects.filter(states__type="draft", states__slug="active", name__contains="-%s-" % group.acronym) - raw_docs_related, meta_related = prepare_document_table(request, qs_related) +def prepare_group_documents(request, group, clist): + found_docs, meta = prepare_document_table(request, docs_tracked_by_community_list(clist), request.GET) + docs = [] docs_related = [] - for d in raw_docs_related: - parts = d.name.split("-", 2); - # canonical form draft--wg-etc - if len(parts) >= 3 and parts[1] not in ("ietf", "irtf") and parts[2].startswith(group.acronym + "-") and d not in docs: + + # split results + for d in found_docs: + # non-WG drafts and call for WG adoption are considered related + if (d.group != group + or (d.stream_id and d.get_state_slug("draft-stream-%s" % d.stream_id) in ("c-adopt", "wg-cand"))): d.search_heading = "Related Internet-Draft" docs_related.append(d) - - # move call for WG adoption to related - cleaned_docs = [] - docs_related_names = set(d.name for d in docs_related) - for d in docs: - if d.stream_id and d.get_state_slug("draft-stream-%s" % d.stream_id) in ("c-adopt", "wg-cand"): - if d.name not in docs_related_names: - d.search_heading = "Related Internet-Draft" - docs_related.append(d) else: - cleaned_docs.append(d) + docs.append(d) - docs = cleaned_docs - - docs_related.sort(key=lambda d: d.name) + meta_related = meta.copy() return docs, meta, docs_related, meta_related @@ -423,13 +417,19 @@ def group_documents(request, acronym, group_type=None): if not group.features.has_documents: raise Http404 - docs, meta, docs_related, meta_related = search_for_group_documents(group, request) + clist = get_object_or_404(CommunityList, group=group) + + docs, meta, docs_related, meta_related = prepare_group_documents(request, group, clist) + + subscribed = request.user.is_authenticated() and EmailSubscription.objects.filter(community_list=clist, email__person__user=request.user) context = construct_group_menu_context(request, group, "documents", group_type, { 'docs': docs, 'meta': meta, 'docs_related': docs_related, 'meta_related': meta_related, + 'subscribed': subscribed, + 'clist': clist, }) return render(request, 'group/group_documents.html', context) @@ -440,7 +440,9 @@ def group_documents_txt(request, acronym, group_type=None): if not group.features.has_documents: raise Http404 - docs, meta, docs_related, meta_related = search_for_group_documents(group, request) + clist = get_object_or_404(CommunityList, group=group) + + docs, meta, docs_related, meta_related = prepare_group_documents(request, group, clist) for d in docs: d.prefix = d.get_state().name diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index 9852925fc..d6beb8ccf 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -15,7 +15,7 @@ from django.core.urlresolvers import NoReverseMatch from ietf.doc.models import Document, DocAlias, DocEvent, State from ietf.group.models import Group, GroupEvent, GroupMilestone, GroupStateTransitions -from ietf.group.utils import save_group_in_history +from ietf.group.utils import save_group_in_history, setup_default_community_list_for_group from ietf.name.models import DocTagName, GroupStateName, GroupTypeName from ietf.person.models import Person, Email from ietf.utils.test_utils import TestCase, unicontent @@ -184,13 +184,14 @@ class GroupPagesTests(TestCase): name=draft2.name, ) + setup_default_community_list_for_group(group) + url = urlreverse('ietf.group.info.group_documents', kwargs=dict(group_type=group.type_id, acronym=group.acronym)) r = self.client.get(url) self.assertEqual(r.status_code, 200) self.assertTrue(draft.name in unicontent(r)) self.assertTrue(group.name in unicontent(r)) self.assertTrue(group.acronym in unicontent(r)) - self.assertTrue(draft2.name in unicontent(r)) # Make sure that a logged in user is presented with an opportunity to add results to their community list diff --git a/ietf/group/urls_info_details.py b/ietf/group/urls_info_details.py index c0a97e9eb..e4cfbafc8 100644 --- a/ietf/group/urls_info_details.py +++ b/ietf/group/urls_info_details.py @@ -5,6 +5,10 @@ urlpatterns = patterns('', (r'^$', 'ietf.group.info.group_home', None, "group_home"), (r'^documents/txt/$', 'ietf.group.info.group_documents_txt'), (r'^documents/$', 'ietf.group.info.group_documents', None, "group_docs"), + (r'^documents/manage/$', 'ietf.community.views.manage_list', None, "community_group_manage_list"), + (r'^documents/csv/$', 'ietf.community.views.export_to_csv', None, 'community_group_csv'), + (r'^documents/feed/$', 'ietf.community.views.feed', None, 'community_group_feed'), + (r'^documents/subscription/$', 'ietf.community.views.subscription', None, 'community_group_subscription'), (r'^charter/$', 'ietf.group.info.group_about', None, 'group_charter'), (r'^about/$', 'ietf.group.info.group_about', None, 'group_about'), (r'^history/$','ietf.group.info.history'), diff --git a/ietf/group/utils.py b/ietf/group/utils.py index 9e2a37a75..1254221f9 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -8,6 +8,9 @@ from ietf.group.models import Group, RoleHistory from ietf.person.models import Email from ietf.utils.history import get_history_object_for, copy_many_to_many_for_history from ietf.ietfauth.utils import has_role +from ietf.community.models import CommunityList, SearchRule +from ietf.community.utils import reset_name_contains_index_for_rule +from ietf.doc.models import State def save_group_in_history(group): @@ -107,3 +110,25 @@ def get_group_or_404(acronym, group_type): possible_groups = possible_groups.filter(type=group_type) return get_object_or_404(possible_groups, acronym=acronym) + +def setup_default_community_list_for_group(group): + clist = CommunityList.objects.create(group=group) + SearchRule.objects.create( + community_list=clist, + rule_type="group", + group=group, + state=State.objects.get(slug="active", type="draft"), + ) + SearchRule.objects.create( + community_list=clist, + rule_type="group_rfc", + group=group, + state=State.objects.get(slug="rfc", type="draft"), + ) + related_docs_rule = SearchRule.objects.create( + community_list=clist, + rule_type="name_contains", + text=r"^draft-[^-]+-%s-" % group.acronym, + state=State.objects.get(slug="active", type="draft"), + ) + reset_name_contains_index_for_rule(related_docs_rule) diff --git a/ietf/secr/groups/views.py b/ietf/secr/groups/views.py index feb55d6c1..be14d3fb2 100644 --- a/ietf/secr/groups/views.py +++ b/ietf/secr/groups/views.py @@ -5,7 +5,7 @@ from django.shortcuts import render_to_response, get_object_or_404, redirect from django.template import RequestContext from ietf.group.models import Group, GroupMilestone, ChangeStateGroupEvent, GroupEvent, GroupURL, Role -from ietf.group.utils import save_group_in_history, get_charter_text +from ietf.group.utils import save_group_in_history, get_charter_text, setup_default_community_list_for_group from ietf.ietfauth.utils import role_required from ietf.person.models import Person from ietf.secr.groups.forms import GroupModelForm, GroupMilestoneForm, RoleForm, SearchForm @@ -102,6 +102,9 @@ def add(request): awp.group = group awp.save() + if group.features.has_documents: + setup_default_community_list_for_group(group) + # create GroupEvent(s) # always create started event ChangeStateGroupEvent.objects.create(group=group, diff --git a/ietf/submit/tests.py b/ietf/submit/tests.py index 69f4de312..639a558a9 100644 --- a/ietf/submit/tests.py +++ b/ietf/submit/tests.py @@ -21,6 +21,7 @@ from ietf.person.models import Person from ietf.group.models import Group from ietf.doc.models import Document, DocAlias, DocEvent, State, BallotDocEvent, BallotPositionDocEvent, DocumentAuthor from ietf.submit.models import Submission, Preapproval +from ietf.group.utils import setup_default_community_list_for_group class SubmitTests(TestCase): def setUp(self): @@ -135,7 +136,8 @@ class SubmitTests(TestCase): def submit_new_wg(self, formats): # submit new -> supply submitter info -> approve draft = make_test_data() - + setup_default_community_list_for_group(draft.group) + # prepare draft to suggest replace sug_replaced_draft = Document.objects.create( name="draft-ietf-ames-sug-replaced", diff --git a/ietf/submit/utils.py b/ietf/submit/utils.py index 0ca57276f..2bd024d22 100644 --- a/ietf/submit/utils.py +++ b/ietf/submit/utils.py @@ -14,6 +14,7 @@ from ietf.group.models import Group from ietf.ietfauth.utils import has_role from ietf.name.models import StreamName from ietf.person.models import Person, Email +from ietf.community.utils import update_name_contains_indexes_with_new_doc from ietf.submit.mail import announce_to_lists, announce_new_version, announce_to_authors from ietf.submit.models import Submission, SubmissionEvent, Preapproval, DraftSubmissionStateName from ietf.utils import unaccent @@ -223,6 +224,8 @@ def post_submission(request, submission): new_replaces, new_possibly_replaces = update_replaces_from_submission(request, submission, draft) + update_name_contains_indexes_with_new_doc(draft) + announce_to_lists(request, submission) announce_new_version(request, submission, draft, state_change_msg) announce_to_authors(request, submission) diff --git a/ietf/templates/base/menu.html b/ietf/templates/base/menu.html index 12d19a6ef..b19c555b8 100644 --- a/ietf/templates/base/menu.html +++ b/ietf/templates/base/menu.html @@ -55,7 +55,7 @@
  • My tracked docs
  • {% for g in user|managed_groups %} -
  • {{ g.acronym }} {{ g.type.slug }} docs
  • +
  • {{ g.acronym }} {{ g.type.slug }} docs
  • {% endfor %} {% else %}
  • Sign in to track docs
  • diff --git a/ietf/templates/community/list_menu.html b/ietf/templates/community/list_menu.html new file mode 100644 index 000000000..f1c1fc62e --- /dev/null +++ b/ietf/templates/community/list_menu.html @@ -0,0 +1,22 @@ + diff --git a/ietf/templates/community/manage_list.html b/ietf/templates/community/manage_list.html index b6a7488f7..89ff91bb5 100644 --- a/ietf/templates/community/manage_list.html +++ b/ietf/templates/community/manage_list.html @@ -19,18 +19,38 @@ {% bootstrap_messages %} -

    The list currently tracks {{ total_count }} document{{ total_count|pluralize }}.

    +

    The list currently tracks {{ total_count }} document{{ total_count|pluralize }}.

    -

    Back to list

    +

    Back to list

    Individual documents

    -

    The list tracks {{ individually_added }} individually added document{{ individually_added|pluralize }}.

    - - {% if clist.group %} -

    You can add individual documents here:

    + {% if individually_added %} +

    The list tracks {{ individually_added|length }} individually added document{{ individually_added|length|pluralize }}:

    + + + {% for d in individually_added %} + + + + + {% endfor %} + +
    {{ d.name }} +
    + {% csrf_token %} + + +
    +
    {% else %} -

    You can conveniently track individual documents in your personal list with the track icon in search results.

    +

    The list does not track any individually added documents yet.

    + {% endif %} + + {% if clist.group %} +

    Add individual documents here:

    + {% else %} +

    Conveniently track individual documents in your personal list with the track icon in search results.

    You can also add documents here:

    {% endif %} diff --git a/ietf/templates/community/subscription.html b/ietf/templates/community/subscription.html index 63495c4d3..0a4c337e5 100644 --- a/ietf/templates/community/subscription.html +++ b/ietf/templates/community/subscription.html @@ -30,9 +30,10 @@ {% endfor %} - {% endif %} -

    Back to list

    +

    Back to list

    + + {% endif %}

    Add new subscription

    @@ -44,12 +45,14 @@ {% bootstrap_form form %} {% buttons %} - Back to list + Back to list {% endbuttons %} {% else %}
    You do not have any active email addresses registered with your account. Go to your profile and add or activate one.
    + + Back to list {% endif %} {% endblock %} diff --git a/ietf/templates/community/view_list.html b/ietf/templates/community/view_list.html index 905481621..e329180ae 100644 --- a/ietf/templates/community/view_list.html +++ b/ietf/templates/community/view_list.html @@ -11,30 +11,13 @@ {% bootstrap_messages %} - + {% if can_manage_list %} + + + Manage list + + {% endif %} {% include "doc/search/search_results.html" with skip_no_matches_warning=True %} + {% include "community/list_menu.html" %} {% endblock %} diff --git a/ietf/templates/group/group_documents.html b/ietf/templates/group/group_documents.html index 87484c6a9..301b8ea55 100644 --- a/ietf/templates/group/group_documents.html +++ b/ietf/templates/group/group_documents.html @@ -8,5 +8,6 @@ {% origin %} {% include "doc/search/search_results.html" %} {% include "doc/search/search_results.html" with docs=docs_related meta=meta_related skip_no_matches_warning=True %} + {% include "community/list_menu.html" %} {% endblock group_content %}