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 %}