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 f96ae1ab2..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 = 'Last revision' - rfcDescription = 'Published' - - 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 '-documentchangedates__new_version_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' - - -class SignificantSort(SortMethod): - codename = 'recent_significant' - description = 'Date of most recent significant change of status' - - def get_sort_field(self): - return '-documentchangedates__significant_change_date' - - -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..d6dce1a68 100644 --- a/ietf/community/forms.py +++ b/ietf/community/forms.py @@ -1,101 +1,114 @@ -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") + + del self.fields["person"] + del self.fields["text"] + + 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 + + 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): + self.clist = clist + self.user = user + + super(SubscriptionForm, self).__init__(*args, **kwargs) + + 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 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 = DisplayConfiguration - fields = ('sort_method', ) - - 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() - - -class SubscribeForm(forms.Form): - - 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 save(self, *args, **kwargs): - self.send_email() - return True - - 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) - - -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) + model = EmailSubscription + fields = ("notify_on", "email") 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/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/migrations/0003_cleanup.py b/ietf/community/migrations/0003_cleanup.py new file mode 100644 index 000000000..f90283127 --- /dev/null +++ b/ietf/community/migrations/0003_cleanup.py @@ -0,0 +1,120 @@ +# -*- 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, verbose_name=b'Text/RegExp', 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/regular expression in the name')]), + preserve_default=True, + ), + migrations.AlterUniqueTogether( + 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, + ), + 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 new file mode 100644 index 000000000..6790230ac --- /dev/null +++ b/ietf/community/migrations/0004_cleanup_data.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +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 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 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 + +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(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', + ), + 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 a644a18fc..b3e265767 100644 --- a/ietf/community/models.py +++ b/ietf/community/models.py @@ -1,263 +1,102 @@ -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 ietf.utils.mail import send_mail -from ietf.doc.models import Document, DocEvent -from ietf.group.models import Group, Role - -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 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 +from ietf.person.models import Person, Email 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) - - 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 short_name(self): - if self.user: - return 'mine' - else: - return '%s' % self.group.acronym + 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): + def get_absolute_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, )) - - 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 - - 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 - - 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 - - def add_subscriptor(self, email, significant): - self.emailsubscription_set.get_or_create(email=email, significant=significant) - - 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() + 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 Rule(models.Model): +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'), + + ('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'), + + ('author', 'All I-Ds with a particular author'), + ('author_rfc', 'All RFCs with a particular author'), + + ('ad', 'All I-Ds with a particular responsible AD'), + + ('shepherd', 'All I-Ds with a particular document shepherd'), + + ('name_contains', 'All I-Ds with particular text/regular expression 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) + rule_type = models.CharField(max_length=30, choices=RULE_TYPES) - class Meta: - unique_together= ("community_list", "rule_type", "value") + # 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(verbose_name="Text/RegExp", max_length=255, blank=True, default="") - last_updated = models.DateTimeField( - auto_now=True) - - 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() - - -class ExpectedChange(models.Model): - - community_list = models.ForeignKey(CommunityList) - document = models.ForeignKey(Document) - expected_date = models.DateField( - verbose_name='Expected date' - ) + # 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): 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") -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, self.notify_on) 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 - (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() + + from ietf.community.utils import notify_event_to_subscribers + notify_event_to_subscribers(instance) + + 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 641c523b5..d2c9f9209 100644 --- a/ietf/community/resources.py +++ b/ietf/community/resources.py @@ -1,14 +1,13 @@ # Autogenerated by the mkresources management command 2014-11-13 23:53 from tastypie.resources import ModelResource -from ietf.api import ToOneField -from tastypie.fields import ToManyField +from tastypie.fields import ToOneField, ToManyField from tastypie.constants import ALL, ALL_WITH_RELATIONS from tastypie.cache import SimpleCache from ietf import api -from ietf.community.models import ( CommunityList, ExpectedChange, DisplayConfiguration, - ListNotification, Rule, EmailSubscription, DocumentChangeDates ) +from ietf.community.models import CommunityList, SearchRule, EmailSubscription + from ietf.doc.resources import DocumentResource from ietf.group.resources import GroupResource @@ -16,7 +15,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: cache = SimpleCache() queryset = CommunityList.objects.all() @@ -28,75 +27,24 @@ 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()) from ietf.doc.resources import DocumentResource -class ExpectedChangeResource(ModelResource): - community_list = ToOneField(CommunityListResource, 'community_list') - document = ToOneField(DocumentResource, 'document') - class Meta: - cache = SimpleCache() - 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): +class SearchRuleResource(ModelResource): community_list = ToOneField(CommunityListResource, 'community_list') class Meta: cache = SimpleCache() - 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: - cache = SimpleCache() - 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): - community_list = ToOneField(CommunityListResource, 'community_list') - cached_ids = ToManyField(DocumentResource, 'cached_ids', null=True) - class Meta: - cache = SimpleCache() - 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') @@ -107,26 +55,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()) - -from ietf.doc.resources import DocumentResource -class DocumentChangeDatesResource(ModelResource): - document = ToOneField(DocumentResource, 'document') - class Meta: - cache = SimpleCache() - 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()) - 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 c88e6b289..000000000 --- a/ietf/community/templatetags/community_tags.py +++ /dev/null @@ -1,45 +0,0 @@ -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 get_user_managed_lists(user): - if not (user and hasattr(user, "is_authenticated") and user.is_authenticated()): - return '' - lists = {'personal': CommunityList.objects.get_or_create(user=user)[0]} - 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, - '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 new file mode 100644 index 000000000..6cbd5ad34 --- /dev/null +++ b/ietf/community/tests.py @@ -0,0 +1,354 @@ +import json + +from pyquery import PyQuery + +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.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 +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="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)) + 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_personal_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)) + + # 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", + "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")) + + # 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) + 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_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 }) + 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_docs.all()), [draft]) + + # untrack + 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_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_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() + + 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 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() + + url = urlreverse("community_personal_subscription", kwargs={ "username": "plain" }) + + login_testing_unauthorized(self, "plain", url) + + # subscription without list + r = self.client.get(url) + self.assertEqual(r.status_code, 404) + + # subscription 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) + + # 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) + + subscription = EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").first() + + self.assertTrue(subscription) + + # 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=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() + + 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=Email.objects.filter(person__user__username="plain").first(), notify_on="significant") + + 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 0f60ba63c..73d31c8c6 100644 --- a/ietf/community/urls.py +++ b/ietf/community/urls.py @@ -1,34 +1,13 @@ 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'), +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.@+-]+)/$', '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'^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'^(?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'), ) diff --git a/ietf/community/utils.py b/ietf/community/utils.py new file mode 100644 index 000000000..ee8c87042 --- /dev/null +++ b/ietf/community/utils.py @@ -0,0 +1,183 @@ +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, 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 + +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 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 + + 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'): + 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 + by the user or not.""" + + tracked = set() + + if user and user.is_authenticated(): + clist = CommunityList.objects.filter(user=user).first() + if clist: + 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 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']: + 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, searchrule=rule) + + raise NotImplementedError + +def community_list_rules_matching_doc(doc): + 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, + name_contains_index=doc, # search our materialized index to avoid full scan + ) + + 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(notify_on="all") + + 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.address, settings.DEFAULT_FROM_EMAIL, subject, 'community/notification_email.txt', + context = { + 'event': event, + 'clist': clist, + }) diff --git a/ietf/community/views.py b/ietf/community/views.py index 87c2a9702..c841d29bc 100644 --- a/ietf/community/views.py +++ b/ietf/community/views.py @@ -1,352 +1,268 @@ import csv import uuid import datetime -import hashlib 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.utils.http import urlquote +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, Http404 +from django.shortcuts import get_object_or_404, render +from django.contrib.auth.decorators import login_required +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.group.models import Group -from ietf.doc.models import DocEvent, DocAlias +from ietf.community.models import SearchRule, EmailSubscription +from ietf.community.forms import SearchRuleTypeForm, SearchRuleForm, AddDocumentsForm, SubscriptionForm +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, reset_name_contains_index_for_rule +from ietf.doc.models import DocEvent, Document +from ietf.doc.utils_search import prepare_document_table +def view_list(request, username=None): + clist = lookup_community_list(username) -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) + 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 +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_community_list(username, acronym) + + if not can_manage_community_list(request.user, clist): + return HttpResponseForbidden("You do not have permission to access this view") + + 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: - 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', - {'cl': clist, - 'dc': display_config, - 'display_form': display_form, - 'rule_form': rule_form}, - context_instance=RequestContext(request)) + 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(): + 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() + if rule.rule_type == "name_contains": + reset_name_contains_index_for_rule(rule) + + 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.all() if clist.pk is not None else [], + '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, + }) -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 track_document(request, name, username=None, acronym=None): + doc = get_object_or_404(Document, docalias__name=name) + + if request.method == "POST": + 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") + + if clist.pk is None: + clist.save() + + clist.added_docs.add(doc) + + if request.is_ajax(): + return HttpResponse(json.dumps({ 'success': True }), content_type='text/plain') + else: + return HttpResponseRedirect(clist.get_absolute_url()) + + return render(request, "community/track_document.html", { + "name": doc.name, + }) + +@login_required +def untrack_document(request, name, username=None, acronym=None): + doc = get_object_or_404(Document, docalias__name=name) + 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") + + if request.method == "POST": + 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 HttpResponseRedirect(clist.get_absolute_url()) + + return render(request, "community/untrack_document.html", { + "name": doc.name, + }) -def manage_group_list(request, acronym): - group = get_object_or_404(Group, acronym=acronym) - if group.type.slug not in ('area', 'wg'): - raise Http404 - 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) +def export_to_csv(request, username=None, acronym=None, group_type=None): + clist = lookup_community_list(username, acronym) + response = HttpResponse(content_type='text/csv') -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 clist.group: + filename = "%s-draft-list.csv" % clist.group.acronym + else: + filename = "draft-list.csv" -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') + response['Content-Disposition'] = 'attachment; filename=%s' % filename -def remove_document(request, list_id, document_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()) + writer = csv.writer(response, dialect=csv.excel, delimiter=',') -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') + 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 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) - rule = get_object_or_404(Rule, pk=rule_id) - rule.delete() - return HttpResponseRedirect(clist.get_manage_url()) + return response +def feed(request, username=None, acronym=None, group_type=None): + clist = lookup_community_list(username, acronym) -def _view_list(request, clist): - display_config = clist.get_display_config() - return render_to_response('community/public/view_list.html', - {'cl': clist, - 'dc': display_config, - }, - context_instance=RequestContext(request)) + 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) -def view_personal_list(request, secret): - clist = get_object_or_404(CommunityList, secret=secret) - return _view_list(request, clist) + events = DocEvent.objects.filter( + doc__in=documents, + time__gte=since, + ).distinct().order_by('-time', '-id').select_related("doc") - -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) - - -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) - - 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_to_response('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', - context_instance=RequestContext(request)) + 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 - - -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) - return _csv_list(request, clist) - - -def csv_group_list(request, acronym): - group = get_object_or_404(Group, acronym=acronym) - if group.type.slug not in ('area', 'wg'): +@login_required +def subscription(request, username=None, acronym=None, group_type=None): + clist = lookup_community_list(username, acronym) + if clist.pk is None: raise Http404 - 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): - clist = get_object_or_404(CommunityList, secret=secret) - return _csv_list(request, clist) + existing_subscriptions = EmailSubscription.objects.filter(community_list=clist, email__person__user=request.user) -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 + 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 = SubscribeForm(clist=clist, significant=significant) - return render_to_response('community/public/subscribe.html', - {'cl': clist, - 'form': form, - 'success': success, - }, - context_instance=RequestContext(request)) + form = SubscriptionForm(request.user, clist) - -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_to_response('community/public/unsubscribe.html', - {'cl': clist, - 'form': form, - 'success': success, - 'significant': significant, - }, - context_instance=RequestContext(request)) - - -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: - raise Http404 - (subscription, created) = EmailSubscription.objects.get_or_create( - community_list=clist, - email=email, - significant=significant) - return render_to_response('community/public/subscription_confirm.html', - {'cl': clist, - 'significant': significant, - }, - context_instance=RequestContext(request)) - - -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: - raise Http404 - EmailSubscription.objects.filter( - community_list=clist, - email=email, - significant=significant).delete() - return render_to_response('community/public/unsubscription_confirm.html', - {'cl': clist, - 'significant': significant, - }, - context_instance=RequestContext(request)) - - -def confirm_significant_unsubscription(request, list_id, email, date, confirm_hash): - return confirm_unsubscription(request, list_id, email, date, confirm_hash, significant=True) + return render(request, 'community/subscription.html', { + 'clist': clist, + 'form': form, + 'existing_subscriptions': existing_subscriptions, + }) diff --git a/ietf/doc/templatetags/managed_groups.py b/ietf/doc/templatetags/managed_groups.py new file mode 100644 index 000000000..113bc0262 --- /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/doc/utils_search.py b/ietf/doc/utils_search.py new file mode 100644 index 000000000..7b9e3c159 --- /dev/null +++ b/ietf/doc/utils_search.py @@ -0,0 +1,189 @@ +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') or "" + sort_reversed = sort_key.startswith("-") + sort_key = sort_key.lstrip("-") + + # sort + def generate_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=generate_sort_key, reverse=sort_reversed) + + # 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']: + h["sort_url"] = "?" + d.urlencode() + if h['key'] == sort_key: + h['sorted'] = True + if sort_reversed: + h['direction'] = 'desc' + d["sort"] = h["key"] + else: + h['direction'] = 'asc' + d["sort"] = "-" + h["key"] + else: + d["sort"] = h["key"] + + return (docs, meta) + + diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index a5b1f194d..5be36fd26 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, default_consensus) -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, can_manage_materials from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, user_is_person, role_required @@ -345,15 +344,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")] @@ -420,7 +411,6 @@ def document_main(request, name, rev=None): shepherd_writeup=shepherd_writeup, search_archive=search_archive, actions=actions, - tracking_document=tracking_document, presentations=presentations, ), context_instance=RequestContext(request)) diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index 23d158b5b..40677842a 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -881,6 +881,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/doc/views_search.py b/ietf/doc/views_search.py index cc64cc838..a449002c9 100644 --- a/ietf/doc/views_search.py +++ b/ietf/doc/views_search.py @@ -35,7 +35,6 @@ import datetime, re from django import forms from django.conf import settings from django.core.cache import cache -from django.core.exceptions import ObjectDoesNotExist from django.core.urlresolvers import reverse as urlreverse from django.db.models import Q from django.http import Http404, HttpResponseBadRequest, HttpResponse, HttpResponseRedirect @@ -44,16 +43,15 @@ from django.utils.cache import _generate_cache_key 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 +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.doc.utils_search import prepare_document_table class SearchForm(forms.Form): @@ -62,7 +60,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) @@ -81,7 +79,7 @@ class SearchForm(forms.Form): ("ad", "AD"), ("-ad", "AD (desc)"), ), required=False, widget=forms.HiddenInput) - doctypes = DocTypeName.objects.filter(used=True).exclude(slug='draft').order_by('name'); + doctypes = forms.ModelMultipleChoiceField(queryset=DocTypeName.objects.filter(used=True).exclude(slug='draft').order_by('name'), required=False) def __init__(self, *args, **kwargs): super(SearchForm, self).__init__(*args, **kwargs) @@ -123,126 +121,27 @@ 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, 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) 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 @@ -281,104 +180,7 @@ def retrieve_search_results(form, 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"] in ["title", "-title"]: - res.append(d.title) - elif query["sort"] in ["date", "-date" ]: - res.append(str(d.latest_revision_date)) - elif query["sort"] in ["status", "-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"] in ["ipr", "-ipr"]: - res.append(len(d.ipr())) - elif query["sort"] in ["ad", "-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, reverse=query["sort"].startswith("-")) - - # 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']: - sort = query.get('sort') - if sort.endswith(h['key']): - h['sorted'] = True - if sort.startswith('-'): - h['direction'] = 'desc' - d["sort"] = h["key"] - else: - h['direction'] = 'asc' - d["sort"] = "-" + h["key"] - else: - d["sort"] = h["key"] - h["sort_url"] = "?" + d.urlencode() - - 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 + return docs def search(request): if request.GET: @@ -395,17 +197,16 @@ def search(request): if not form.is_valid(): return HttpResponseBadRequest("form not valid: %s" % form.errors) - results, meta = retrieve_search_results(form) + 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 } - - doc_is_tracked = get_doc_is_tracked(request, results) + meta = { 'by': None, 'searching': False } 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): @@ -472,7 +273,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" @@ -579,8 +380,9 @@ 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, all_types=True) + 'sort': 'status', + 'doctypes': list(DocTypeName.objects.filter(used=True).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] # @@ -594,7 +396,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 = 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/edit.py b/ietf/group/edit.py index bd9ec2d7c..b75e1e7b3 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 -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 a80c66643..1fa1f315f 100644 --- a/ietf/group/info.py +++ b/ietf/group/info.py @@ -42,7 +42,7 @@ from collections import OrderedDict import debug # pyflakes:ignore from django import forms -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 @@ -51,14 +51,16 @@ 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.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, can_manage_group, milestone_reviewer_for_group_type, can_provide_status_update 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.utils.textupload import get_cleaned_text_file_content from ietf.settings import MAILING_LIST_INFO_URL @@ -373,6 +375,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))) @@ -397,36 +404,23 @@ def construct_group_menu_context(request, group, selected, group_type, others): return d -def search_for_group_documents(group): - form = SearchForm({ 'by':'group', 'group': group.acronym or "", 'rfcs':'on', 'activedrafts': 'on' }) - docs, meta = retrieve_search_results(form) - - # 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) +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 @@ -442,17 +436,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) + clist = get_object_or_404(CommunityList, group=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 = 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, - 'doc_is_tracked': doc_is_tracked, + 'subscribed': subscribed, + 'clist': clist, }) return render(request, 'group/group_documents.html', context) @@ -463,7 +459,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) + 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 a40e2845d..5c70e0856 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -20,7 +20,7 @@ from django.template.defaultfilters import urlize 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 @@ -191,20 +191,21 @@ 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 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/group/urls_info_details.py b/ietf/group/urls_info_details.py index 4b66269f6..c2ab9952d 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'^about/status/$', 'ietf.group.info.group_about_status'), diff --git a/ietf/group/utils.py b/ietf/group/utils.py index 55b4faa0f..1287f826e 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): @@ -124,3 +127,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/group/views_stream.py b/ietf/group/views_stream.py index 6ec34702c..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) + + 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 e2c15bba4..2747dfbba 100644 --- a/ietf/iesg/views.py +++ b/ietf/iesg/views.py @@ -60,7 +60,7 @@ from ietf.iesg.models import TelechatDate from ietf.iesg.utils import telechat_page_count 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")) @@ -370,7 +370,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/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/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/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 21ebd7f27..2aacf362b 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -448,3 +448,14 @@ form.navbar-form input.form-control.input-sm { width: 141px; } padding: inherit; outline: inherit; } + +/* Community lists */ + +label#list-feeds { + display: inline-block; + font-weight: normal; +} + +.email-subscription button[type=submit] { + margin-left: 3em; +} diff --git a/ietf/static/ietf/js/ietf.js b/ietf/static/ietf/js/ietf.js index f40e3a5b7..709ec3374 100644 --- a/ietf/static/ietf/js/ietf.js +++ b/ietf/static/ietf/js/ietf.js @@ -100,24 +100,20 @@ $(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: '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.addClass("hide"); + trigger.parent().find(".track-untrack-doc").not(trigger).removeClass("hide"); } - } } }); }); 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/submit/tests.py b/ietf/submit/tests.py index c4be4dfc8..563640ac7 100644 --- a/ietf/submit/tests.py +++ b/ietf/submit/tests.py @@ -19,6 +19,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): @@ -163,7 +164,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 375cd1d26..09bf6d465 100644 --- a/ietf/submit/utils.py +++ b/ietf/submit/utils.py @@ -13,6 +13,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 @@ -126,7 +127,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 @@ -205,6 +206,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 608b34f1b..5a394dc8f 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" %} 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/customize_display.html b/ietf/templates/community/customize_display.html deleted file mode 100644 index 57032786a..000000000 --- a/ietf/templates/community/customize_display.html +++ /dev/null @@ -1,24 +0,0 @@ -{# Copyright The IETF Trust 2015, All Rights Reserved #} -{% load origin %}{% origin %} -{% load bootstrap3 %} - -
    - {% 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/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_clist.html b/ietf/templates/community/manage_clist.html index a6e32521e..624e9b3aa 100644 --- a/ietf/templates/community/manage_clist.html +++ b/ietf/templates/community/manage_clist.html @@ -49,8 +49,8 @@ {{ doc.display_name }} {{ doc.get_state }} - {{ doc.title }} - Remove + {{ doc.title }} + Remove {% endfor %} diff --git a/ietf/templates/community/manage_list.html b/ietf/templates/community/manage_list.html new file mode 100644 index 000000000..89ff91bb5 --- /dev/null +++ b/ietf/templates/community/manage_list.html @@ -0,0 +1,143 @@ +{% 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

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

    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 %} + +
    + {% 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 1ad53d026..000000000 --- a/ietf/templates/community/public/subscribe.html +++ /dev/null @@ -1,30 +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 }}

    - -

    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 7f7ad4013..000000000 --- a/ietf/templates/community/public/unsubscribe.html +++ /dev/null @@ -1,34 +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 }}

    - -

    - 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/unsubscribe_email.txt b/ietf/templates/community/public/unsubscribe_email.txt deleted file mode 100644 index 7b93d2f30..000000000 --- a/ietf/templates/community/public/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/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 9ec31ca12..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 %} -

    Internet-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..0a4c337e5 --- /dev/null +++ b/ietf/templates/community/subscription.html @@ -0,0 +1,58 @@ +{% 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 %} + +

    Subscription to {{ clist.long_name }}

    + + {% bootstrap_messages %} + +

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

    + + {% if existing_subscriptions %} +

    Existing subscriptions

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

    Back to list

    + + {% endif %} + +

    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 %} + + {% buttons %} + 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/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/community/view_list.html b/ietf/templates/community/view_list.html index deb71d692..e329180ae 100644 --- a/ietf/templates/community/view_list.html +++ b/ietf/templates/community/view_list.html @@ -1,4 +1,23 @@ +{% 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 %} + + {% 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/doc/document_draft.html b/ietf/templates/doc/document_draft.html index b2ac520e9..184b0cab7 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -501,11 +501,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_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 @@
    - +
    diff --git a/ietf/templates/doc/search/search_result_row.html b/ietf/templates/doc/search/search_result_row.html index 4b08cb3db..916786b6e 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.name in doc_is_tracked %} - - - - {% else %} - - - - {% endif %} + + + + + + {% endif %} 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 %}