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
This commit is contained in:
Ole Laursen 2016-03-22 12:48:44 +00:00
parent cdcad43fc0
commit c7589f9b6a
23 changed files with 371 additions and 167 deletions

View file

@ -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):

View file

@ -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,
),
]

View file

@ -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',

View file

@ -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):

View file

@ -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('<entry>' 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()

View file

@ -1,20 +1,13 @@
from django.conf.urls import patterns, url
urlpatterns = patterns('ietf.community.views',
url(r'^personal/(?P<username>[^/]+)/$', 'view_list', name='community_personal_view_list'),
url(r'^personal/(?P<username>[^/]+)/manage/$', 'manage_list', name='community_personal_manage_list'),
url(r'^personal/(?P<username>[^/]+)/trackdocument/(?P<name>[^/]+)/$', 'track_document', name='community_personal_track_document'),
url(r'^personal/(?P<username>[^/]+)/untrackdocument/(?P<name>[^/]+)/$', 'untrack_document', name='community_personal_untrack_document'),
url(r'^personal/(?P<username>[^/]+)/csv/$', 'export_to_csv', name='community_personal_csv'),
url(r'^personal/(?P<username>[^/]+)/feed/$', 'feed', name='community_personal_feed'),
url(r'^personal/(?P<username>[^/]+)/subscription/$', 'subscription', name='community_personal_subscription'),
urlpatterns = patterns('',
url(r'^personal/(?P<username>[^/]+)/$', 'ietf.community.views.view_list', name='community_personal_view_list'),
url(r'^personal/(?P<username>[^/]+)/manage/$', 'ietf.community.views.manage_list', name='community_personal_manage_list'),
url(r'^personal/(?P<username>[^/]+)/trackdocument/(?P<name>[^/]+)/$', 'ietf.community.views.track_document', name='community_personal_track_document'),
url(r'^personal/(?P<username>[^/]+)/untrackdocument/(?P<name>[^/]+)/$', 'ietf.community.views.untrack_document', name='community_personal_untrack_document'),
url(r'^personal/(?P<username>[^/]+)/csv/$', 'ietf.community.views.export_to_csv', name='community_personal_csv'),
url(r'^personal/(?P<username>[^/]+)/feed/$', 'ietf.community.views.feed', name='community_personal_feed'),
url(r'^personal/(?P<username>[^/]+)/subscription/$', 'ietf.community.views.subscription', name='community_personal_subscription'),
url(r'^group/(?P<acronym>[\w.@+-]+)/$', 'view_list', name='community_group_view_list'),
url(r'^group/(?P<acronym>[\w.@+-]+)/manage/$', 'manage_list', name='community_group_manage_list'),
url(r'^group/(?P<acronym>[\w.@+-]+)/trackdocument/(?P<name>[^/]+)/$', 'track_document', name='community_group_track_document'),
url(r'^group/(?P<acronym>[\w.@+-]+)/untrackdocument/(?P<name>[^/]+)/$', 'untrack_document', name='community_group_untrack_document'),
url(r'^group/(?P<acronym>[\w.@+-]+)/csv/$', 'export_to_csv', name='community_group_csv'),
url(r'^group/(?P<acronym>[\w.@+-]+)/feed/$', 'feed', name='community_group_feed'),
url(r'^group/(?P<acronym>[\w.@+-]+)/subscription/$', 'subscription', name='community_group_subscription'),
)

View file

@ -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

View file

@ -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

View file

@ -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',

View file

@ -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

View file

@ -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-<name|ietf|irtf>-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

View file

@ -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

View file

@ -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'),

View file

@ -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)

View file

@ -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,

View file

@ -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",

View file

@ -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)

View file

@ -55,7 +55,7 @@
<li><a href="{% url "community_personal_view_list" user.username %}">My tracked docs</a></li>
{% for g in user|managed_groups %}
<li><a href="{% url "community_group_view_list" g.acronym %}">{{ g.acronym }} {{ g.type.slug }} docs</a></li>
<li><a href="{% url "group_docs" g.acronym %}">{{ g.acronym }} {{ g.type.slug }} docs</a></li>
{% endfor %}
{% else %}
<li><a rel="nofollow" href="/accounts/login/?next={{ request.get_full_path|urlencode }}">Sign in to track docs</a></li>

View file

@ -0,0 +1,22 @@
<ul class="list-inline pull-right" style="margin-top:1em;">
<li>
<label id="list-feeds">Atom feed:</label>
<div class="btn-group" role="group" aria-labelledby="list-feeds">
<a class="btn btn-default" title="Feed of all changes" href="{% if clist.group %}{% url "community_group_feed" clist.group.acronym %}{% else %}{% url "community_personal_feed" clist.user.username %}{% endif %}">All changes</a>
<a class="btn btn-default" title="Feed of only significant state changes" href="{% if clist.group %}{% url "community_group_feed" clist.group.acronym %}{% else %}{% url "community_personal_feed" clist.user.username %}{% endif %}?significant=1">Significant</a>
</div>
</li>
{% if clist.pk != None %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_subscription" clist.group.acronym %}{% else %}{% url "community_personal_subscription" clist.user.username %}{% endif %}">
<i class="glyphicon glyphicon-envelope"></i>
{% if subscribed %}
Change subscription
{% else %}
Subscribe to changes
{% endif %}
</a></li>
{% endif %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_csv" clist.group.acronym %}{% else %}{% url "community_personal_csv" clist.user.username %}{% endif %}"><i class="glyphicon glyphicon-list"></i> Export as CSV</a></li>
</ul>

View file

@ -19,18 +19,38 @@
{% bootstrap_messages %}
<p>The list currently tracks <a href="{% if clist.group %}{% url "community_group_view_list" clist.group.acronym %}{% else %}{% url "community_personal_view_list" clist.user.username %}{% endif%}">{{ total_count }} document{{ total_count|pluralize }}</a>.</p>
<p>The list currently tracks <a href="{{ clist.get_absolute_url }}">{{ total_count }} document{{ total_count|pluralize }}</a>.</p>
<p><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_view_list" clist.group.acronym %}{% else %}{% url "community_personal_view_list" clist.user.username %}{% endif%}">Back to list</a></p>
<p><a class="btn btn-default" href="{{ clist.get_absolute_url }}">Back to list</a></p>
<h2>Individual documents</h2>
<p>The list tracks {{ individually_added }} individually added document{{ individually_added|pluralize }}.</p>
{% if clist.group %}
<p>You can add individual documents here:</p>
{% if individually_added %}
<p>The list tracks {{ individually_added|length }} individually added document{{ individually_added|length|pluralize }}:</p>
<table class="table table-condensed table-striped">
<tbody>
{% for d in individually_added %}
<tr>
<td>{{ d.name }}</td>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="document" value="{{ d.pk }}">
<button class="btn btn-danger btn-xs" name="action" value="remove_document">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>You can conveniently track individual documents in your personal list with the track icon <span class="fa fa-bookmark-o"></span> in <a href="/doc/search/">search results</a>.</p>
<p>The list does not track any individually added documents yet.</p>
{% endif %}
{% if clist.group %}
<p>Add individual documents here:</p>
{% else %}
<p>Conveniently track individual documents in your personal list with the track icon <span class="fa fa-bookmark-o"></span> in <a href="/doc/search/">search results</a>.</p>
<p>You can also add documents here:</p>
{% endif %}

View file

@ -30,9 +30,10 @@
</li>
{% endfor %}
</ul>
{% endif %}
<p><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_view_list" clist.group.acronym %}{% else %}{% url "community_personal_view_list" clist.user.username %}{% endif%}">Back to list</a></p>
<p><a class="btn btn-default" href="{{ clist.get_absolute_url }}">Back to list</a></p>
{% endif %}
<h2>Add new subscription</h2>
@ -44,12 +45,14 @@
{% bootstrap_form form %}
{% buttons %}
<a class="btn btn-default" href="{% if clist.group %}{% url "community_group_view_list" clist.group.acronym %}{% else %}{% url "community_personal_view_list" clist.user.username %}{% endif%}">Back to list</a>
<a class="btn btn-default" href="{{ clist.get_absolute_url }}">Back to list</a>
<button type="submit" name="action" value="subscribe" class="btn btn-primary">Subscribe</button>
{% endbuttons %}
</form>
{% else %}
<div class="alert alert-danger">You do not have any active email addresses registered with your account. Go to <a href="{% url "account_profile" %}">your profile and add or activate one</a>.</div>
<a class="btn btn-default" href="{{ clist.get_absolute_url }}">Back to list</a>
{% endif %}
{% endblock %}

View file

@ -11,30 +11,13 @@
{% bootstrap_messages %}
<ul class="list-inline">
{% if can_manage_list %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_manage_list" clist.group.acronym %}{% else %}{% url "community_personal_manage_list" clist.user.username %}{% endif%}">Manage list</a></li>
{% endif %}
{% if clist.pk != None %}
{% if subscribed %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_subscribe" clist.group.acronym %}{% else %}{% url "community_personal_subscription" clist.user.username %}{% endif%}">Change subscription</a></li>
{% else %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_subscribe" clist.group.acronym %}{% else %}{% url "community_personal_subscription" clist.user.username %}{% endif%}">Subscribe to changes</a></li>
{% endif %}
{% endif %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_csv" clist.group.acronym %}{% else %}{% url "community_personal_csv" clist.user.username %}{% endif%}">Export as CSV</a></li>
<li>
<label id="list-feeds">Atom feed of document changes:</label>
<div class="btn-group" role="group" aria-labelledby="list-feeds">
<a class="btn btn-default" title="Feed of all changes" href="{% if clist.group %}{% url "community_group_feed" clist.group.acronym %}{% else %}{% url "community_personal_feed" clist.user.username %}{% endif%}">All</a>
<a class="btn btn-default" title="Feed of only significant state changes" href="{% if clist.group %}{% url "community_group_feed" clist.group.acronym %}{% else %}{% url "community_personal_feed" clist.user.username %}{% endif%}?significant=1">Significant</a>
</div>
</li>
</ul>
{% if can_manage_list %}
<a class="btn btn-primary" href="{% url "community_personal_manage_list" clist.user.username %}">
<i class="glyphicon glyphicon-cog"></i>
Manage list
</a>
{% endif %}
{% include "doc/search/search_results.html" with skip_no_matches_warning=True %}
{% include "community/list_menu.html" %}
{% endblock %}

View file

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