Allow assignment of Person as "action holder" for a Doc, plus rudimentary automation of assignment. Fixes #3146. Commit ready for merge.

- Legacy-Id: 18829
This commit is contained in:
Jennifer Richards 2021-02-12 20:31:00 +00:00
parent df37793e14
commit e11583a87f
36 changed files with 1458 additions and 100 deletions

View file

@ -11,7 +11,7 @@ from .models import (StateType, State, RelatedDocument, DocumentAuthor, Document
StateDocEvent, ConsensusDocEvent, BallotType, BallotDocEvent, WriteupDocEvent, LastCallDocEvent,
TelechatDocEvent, BallotPositionDocEvent, ReviewRequestDocEvent, InitialReviewDocEvent,
AddedMessageEvent, SubmissionDocEvent, DeletedEvent, EditedAuthorsDocEvent, DocumentURL,
ReviewAssignmentDocEvent, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource )
ReviewAssignmentDocEvent, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder )
from ietf.utils.validators import validate_external_resource_value
@ -34,6 +34,11 @@ class DocAuthorInline(admin.TabularInline):
raw_id_fields = ['person', 'email']
extra = 1
class DocActionHolderInline(admin.TabularInline):
model = DocumentActionHolder
raw_id_fields = ['person']
extra = 1
class RelatedDocumentInline(admin.TabularInline):
model = RelatedDocument
def this(self, instance):
@ -72,7 +77,7 @@ class DocumentAdmin(admin.ModelAdmin):
search_fields = ['name']
list_filter = ['type']
raw_id_fields = ['group', 'shepherd', 'ad']
inlines = [DocAuthorInline, RelatedDocumentInline, AdditionalUrlInLine]
inlines = [DocAuthorInline, DocActionHolderInline, RelatedDocumentInline, AdditionalUrlInLine]
form = DocumentForm
def save_model(self, request, obj, form, change):
@ -137,6 +142,13 @@ class BallotTypeAdmin(admin.ModelAdmin):
list_display = ["slug", "doc_type", "name", "question"]
admin.site.register(BallotType, BallotTypeAdmin)
class DocumentActionHolderAdmin(admin.ModelAdmin):
list_display = ['id', 'document', 'person', 'time_added']
raw_id_fields = ['document', 'person']
admin.site.register(DocumentActionHolder, DocumentActionHolderAdmin)
# events
class DocEventAdmin(admin.ModelAdmin):

View file

@ -15,7 +15,7 @@ from ietf.utils.mail import send_mail
from ietf.doc.models import Document, DocEvent, State, IESG_SUBSTATE_TAGS
from ietf.person.models import Person
from ietf.meeting.models import Meeting
from ietf.doc.utils import add_state_change_event
from ietf.doc.utils import add_state_change_event, update_action_holders
from ietf.mailtrigger.utils import gather_address_lists
@ -171,6 +171,9 @@ def expire_draft(doc):
e = add_state_change_event(doc, system, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
events.append(DocEvent.objects.create(doc=doc, rev=doc.rev, by=system, type="expired_document", desc="Document has expired"))

View file

@ -12,7 +12,8 @@ from typing import Optional # pyflakes:ignore
from django.conf import settings
from ietf.doc.models import ( Document, DocEvent, NewRevisionDocEvent, DocAlias, State, DocumentAuthor,
StateDocEvent, BallotPositionDocEvent, BallotDocEvent, BallotType, IRSGBallotDocEvent, TelechatDocEvent)
StateDocEvent, BallotPositionDocEvent, BallotDocEvent, BallotType, IRSGBallotDocEvent, TelechatDocEvent,
DocumentActionHolder)
from ietf.group.models import Group
def draft_name_generator(type_id,group,n):
@ -358,3 +359,9 @@ class BallotPositionDocEventFactory(DocEventFactory):
balloter = factory.SubFactory('ietf.person.factories.PersonFactory')
pos_id = 'discuss'
class DocumentActionHolderFactory(factory.DjangoModelFactory):
class Meta:
model = DocumentActionHolder
document = factory.SubFactory(WgDraftFactory)
person = factory.SubFactory('ietf.person.factories.PersonFactory')

View file

@ -10,6 +10,8 @@ from ietf.doc.fields import SearchableDocAliasesField, SearchableDocAliasField
from ietf.doc.models import RelatedDocument
from ietf.iesg.models import TelechatDate
from ietf.iesg.utils import telechat_page_count
from ietf.person.fields import SearchablePersonsField
class TelechatForm(forms.Form):
telechat_date = forms.TypedChoiceField(coerce=lambda x: datetime.datetime.strptime(x, '%Y-%m-%d').date(), empty_value=None, required=False, help_text="Page counts are the current page counts for the telechat, before this telechat date edit is made.")
@ -54,6 +56,15 @@ class NotifyForm(forms.Form):
addrspecs = [x.strip() for x in self.cleaned_data["notify"].split(',')]
return ', '.join(addrspecs)
class ActionHoldersForm(forms.Form):
action_holders = SearchablePersonsField(required=False)
reason = forms.CharField(
label='Reason for change',
required=False,
max_length=255,
strip=True,
)
IESG_APPROVED_STATE_LIST = ("ann", "rfcqueue", "pub")
class AddDownrefForm(forms.Form):

View file

@ -7,7 +7,7 @@ from django.db.models import Q
from ietf.doc.models import Document, State, DocEvent, LastCallDocEvent, WriteupDocEvent
from ietf.doc.models import IESG_SUBSTATE_TAGS
from ietf.person.models import Person
from ietf.doc.utils import add_state_change_event
from ietf.doc.utils import add_state_change_event, update_action_holders
from ietf.doc.mails import generate_ballot_writeup, generate_approval_mail, generate_last_call_announcement
from ietf.doc.mails import send_last_call_request, email_last_call_expired, email_last_call_expired_with_downref
@ -60,9 +60,14 @@ def expire_last_call(doc):
doc.tags.remove(*prev_tags)
system = Person.objects.get(name="(System)")
events = []
e = add_state_change_event(doc, system, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
doc.save_with_history([e])
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
doc.save_with_history(events)
email_last_call_expired(doc)

View file

@ -15,6 +15,7 @@ from django.utils.encoding import force_text
import debug # pyflakes:ignore
from ietf.doc.templatetags.mail_filters import std_level_prompt
from ietf.utils import log
from ietf.utils.mail import send_mail, send_mail_text
from ietf.ipr.utils import iprs_from_docs, related_docs
from ietf.doc.models import WriteupDocEvent, LastCallDocEvent, DocAlias, ConsensusDocEvent
@ -127,6 +128,22 @@ def email_iesg_processing_document(request, doc, changes):
url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url()),
cc=addrs.cc)
def email_remind_action_holders(request, doc, note=None):
addrs = gather_address_lists('doc_remind_action_holders', doc=doc)
log.assertion(
'not doc.action_holders.exclude(email__in=addrs.to).exists()',
note='All action holders should receive a reminder email. Failed for %s.' % doc.name,
)
send_mail(request, addrs.to, None,
'Reminder: action needed for %s' % doc.display_name(),
'doc/mail/remind_action_holders_mail.txt',
dict(
doc=doc,
doc_url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(),
note=note,
),
cc=addrs.cc)
def html_to_text(html):
return strip_tags(html.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&").replace("<br>", "\n"))

View file

@ -0,0 +1,19 @@
# Generated by Django 2.2.17 on 2021-01-15 12:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('group', '0040_lengthen_used_roles_fields'), # only needed for schema vs data ordering
('doc', '0039_auto_20201109_0439'),
]
operations = [
migrations.AlterField(
model_name='docevent',
name='type',
field=models.CharField(choices=[('new_revision', 'Added new revision'), ('new_submission', 'Uploaded new revision'), ('changed_document', 'Changed document metadata'), ('added_comment', 'Added comment'), ('added_message', 'Added message'), ('edited_authors', 'Edited the documents author list'), ('deleted', 'Deleted document'), ('changed_state', 'Changed state'), ('changed_stream', 'Changed document stream'), ('expired_document', 'Expired document'), ('extended_expiry', 'Extended expiry of document'), ('requested_resurrect', 'Requested resurrect'), ('completed_resurrect', 'Completed resurrect'), ('changed_consensus', 'Changed consensus'), ('published_rfc', 'Published RFC'), ('added_suggested_replaces', 'Added suggested replacement relationships'), ('reviewed_suggested_replaces', 'Reviewed suggested replacement relationships'), ('changed_action_holders', 'Changed action holders for document'), ('changed_group', 'Changed group'), ('changed_protocol_writeup', 'Changed protocol writeup'), ('changed_charter_milestone', 'Changed charter milestone'), ('initial_review', 'Set initial review time'), ('changed_review_announcement', 'Changed WG Review text'), ('changed_action_announcement', 'Changed WG Action text'), ('started_iesg_process', 'Started IESG process on document'), ('created_ballot', 'Created ballot'), ('closed_ballot', 'Closed ballot'), ('sent_ballot_announcement', 'Sent ballot announcement'), ('changed_ballot_position', 'Changed ballot position'), ('changed_ballot_approval_text', 'Changed ballot approval text'), ('changed_ballot_writeup_text', 'Changed ballot writeup text'), ('changed_rfc_editor_note_text', 'Changed RFC Editor Note text'), ('changed_last_call_text', 'Changed last call text'), ('requested_last_call', 'Requested last call'), ('sent_last_call', 'Sent last call'), ('scheduled_for_telechat', 'Scheduled for telechat'), ('iesg_approved', 'IESG approved document (no problem)'), ('iesg_disapproved', 'IESG disapproved document (do not publish)'), ('approved_in_minute', 'Approved in minute'), ('iana_review', 'IANA review comment'), ('rfc_in_iana_registry', 'RFC is in IANA registry'), ('rfc_editor_received_announcement', 'Announcement was received by RFC Editor'), ('requested_publication', 'Publication at RFC Editor requested'), ('sync_from_rfc_editor', 'Received updated information from RFC Editor'), ('requested_review', 'Requested review'), ('assigned_review_request', 'Assigned review request'), ('closed_review_request', 'Closed review request'), ('closed_review_assignment', 'Closed review assignment'), ('downref_approved', 'Downref approved'), ('posted_related_ipr', 'Posted related IPR'), ('removed_related_ipr', 'Removed related IPR')], max_length=50),
),
]

View file

@ -0,0 +1,35 @@
# Generated by Django 2.2.17 on 2021-01-15 12:50
import datetime
from django.db import migrations, models
import django.db.models.deletion
import ietf.utils.models
class Migration(migrations.Migration):
dependencies = [
('person', '0018_auto_20201109_0439'),
('doc', '0040_add_changed_action_holders_docevent_type'),
]
operations = [
migrations.CreateModel(
name='DocumentActionHolder',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time_added', models.DateTimeField(default=datetime.datetime.now)),
('document', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='doc.Document')),
('person', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='person.Person')),
],
),
migrations.AddField(
model_name='document',
name='action_holders',
field=models.ManyToManyField(blank=True, through='doc.DocumentActionHolder', to='person.Person'),
),
migrations.AddConstraint(
model_name='documentactionholder',
constraint=models.UniqueConstraint(fields=('document', 'person'), name='unique_action_holder'),
),
]

View file

@ -630,6 +630,45 @@ class DocumentAuthor(DocumentAuthorInfo):
return u"%s %s (%s)" % (self.document.name, self.person, self.order)
class DocumentActionHolder(models.Model):
"""Action holder for a document"""
document = ForeignKey('Document')
person = ForeignKey(Person)
time_added = models.DateTimeField(default=datetime.datetime.now)
CLEAR_ACTION_HOLDERS_STATES = ['approved', 'ann', 'rfcqueue', 'pub', 'dead'] # draft-iesg state slugs
GROUP_ROLES_OF_INTEREST = ['chair', 'techadv', 'editor', 'secr']
def __str__(self):
return str(self.person)
class Meta:
constraints = [
models.UniqueConstraint(fields=['document', 'person'], name='unique_action_holder')
]
def role_for_doc(self):
"""Brief string description of this person's relationship to the doc"""
roles = []
if self.person in self.document.authors():
roles.append('Author')
if self.person == self.document.ad:
roles.append('Responsible AD')
if self.document.shepherd and self.person == self.document.shepherd.person:
roles.append('Shepherd')
if self.document.group:
roles.extend([
'Group %s' % role.name.name
for role in self.document.group.role_set.filter(
name__in=self.GROUP_ROLES_OF_INTEREST,
person=self.person,
)
])
if not roles:
roles.append('Action Holder')
return ', '.join(roles)
validate_docname = RegexValidator(
r'^[-a-z0-9]+$',
"Provide a valid document name consisting of lowercase letters, numbers and hyphens.",
@ -638,6 +677,8 @@ validate_docname = RegexValidator(
class Document(DocumentInfo):
name = models.CharField(max_length=255, validators=[validate_docname,], unique=True) # immutable
action_holders = models.ManyToManyField(Person, through=DocumentActionHolder, blank=True)
def __str__(self):
return self.name
@ -869,6 +910,11 @@ class Document(DocumentInfo):
return dh
def action_holders_enabled(self):
"""Is the action holder list active for this document?"""
iesg_state = self.get_state('draft-iesg')
return iesg_state and iesg_state.slug != 'idexists'
class DocumentURL(models.Model):
doc = ForeignKey(Document)
tag = ForeignKey(DocUrlTagName)
@ -1000,6 +1046,7 @@ EVENT_TYPES = [
("published_rfc", "Published RFC"),
("added_suggested_replaces", "Added suggested replacement relationships"),
("reviewed_suggested_replaces", "Reviewed suggested replacement relationships"),
("changed_action_holders", "Changed action holders for document"),
# WG events
("changed_group", "Changed group"),

View file

@ -17,7 +17,7 @@ from ietf.doc.models import (BallotType, DeletedEvent, StateType, State, Documen
InitialReviewDocEvent, DocHistoryAuthor, BallotDocEvent, RelatedDocument,
RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent, SubmissionDocEvent,
ReviewRequestDocEvent, ReviewAssignmentDocEvent, EditedAuthorsDocEvent, DocumentURL,
IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource )
IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder )
from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource
class BallotTypeResource(ModelResource):
@ -787,3 +787,22 @@ class DocExtResourceResource(ModelResource):
"name": ALL_WITH_RELATIONS,
}
api.doc.register(DocExtResourceResource())
from ietf.person.resources import PersonResource
class DocumentActionHolderResource(ModelResource):
document = ToOneField(DocumentResource, 'document')
person = ToOneField(PersonResource, 'person')
class Meta:
queryset = DocumentActionHolder.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'documentactionholder'
ordering = ['id', ]
filtering = {
"id": ALL,
"time_added": ALL,
"document": ALL_WITH_RELATIONS,
"person": ALL_WITH_RELATIONS,
}
api.doc.register(DocumentActionHolderResource())

View file

@ -581,3 +581,35 @@ def can_ballot(user,doc):
return has_role(user,'IRSG Member')
else:
return user.person.role_set.filter(name="ad", group__type="area", group__state="active")
@register.filter
def action_holder_badge(action_holder):
"""Add a warning tag if action holder age exceeds limit
>>> from ietf.doc.factories import DocumentActionHolderFactory
>>> old_limit = settings.DOC_ACTION_HOLDER_AGE_LIMIT_DAYS
>>> settings.DOC_ACTION_HOLDER_AGE_LIMIT_DAYS = 15
>>> action_holder_badge(DocumentActionHolderFactory())
''
>>> action_holder_badge(DocumentActionHolderFactory(time_added=datetime.datetime.now() - datetime.timedelta(days=15)))
''
>>> action_holder_badge(DocumentActionHolderFactory(time_added=datetime.datetime.now() - datetime.timedelta(days=16)))
'<span class="label label-danger" title="Goal is &lt;15 days">for 16 days</span>'
>>> action_holder_badge(DocumentActionHolderFactory(time_added=datetime.datetime.now() - datetime.timedelta(days=30)))
'<span class="label label-danger" title="Goal is &lt;15 days">for 30 days</span>'
>>> settings.DOC_ACTION_HOLDER_AGE_LIMIT_DAYS = old_limit
"""
age_limit = settings.DOC_ACTION_HOLDER_AGE_LIMIT_DAYS
age = (datetime.datetime.now() - action_holder.time_added).days
if age > age_limit:
return mark_safe('<span class="label label-danger" title="Goal is &lt;%d days">for %d day%s</span>' % (
age_limit,
age,
's' if age != 1 else ''))
else:
return '' # no alert needed

View file

@ -9,6 +9,7 @@ import io
import lxml
import sys
import bibtexparser
import mock
if sys.version_info[0] == 2 and sys.version_info[1] < 7:
import unittest2 as unittest
@ -224,6 +225,7 @@ class SearchTests(TestCase):
def test_docs_for_ad(self):
ad = RoleFactory(name_id='ad',group__type_id='area',group__state_id='active').person
draft = IndividualDraftFactory(ad=ad)
draft.action_holders.set([PersonFactory()])
draft.set_state(State.objects.get(type='draft-iesg', slug='lc'))
rfc = IndividualDraftFactory(ad=ad)
rfc.set_state(State.objects.get(type='draft', slug='rfc'))
@ -243,6 +245,7 @@ class SearchTests(TestCase):
r = self.client.get(urlreverse('ietf.doc.views_search.docs_for_ad', kwargs=dict(name=ad.full_name_as_key())))
self.assertEqual(r.status_code, 200)
self.assertContains(r, draft.name)
self.assertContains(r, draft.action_holders.first().plain_name())
self.assertContains(r, rfc.canonical_name())
self.assertContains(r, conflrev.name)
self.assertContains(r, statchg.name)
@ -265,18 +268,22 @@ class SearchTests(TestCase):
def test_drafts_in_last_call(self):
draft = IndividualDraftFactory(pages=1)
draft.action_holders.set([PersonFactory()])
draft.set_state(State.objects.get(type="draft-iesg", slug="lc"))
r = self.client.get(urlreverse('ietf.doc.views_search.drafts_in_last_call'))
self.assertEqual(r.status_code, 200)
self.assertContains(r, draft.title)
self.assertContains(r, draft.action_holders.first().plain_name())
def test_in_iesg_process(self):
doc_in_process = IndividualDraftFactory()
doc_in_process.action_holders.set([PersonFactory()])
doc_in_process.set_state(State.objects.get(type='draft-iesg', slug='lc'))
doc_not_in_process = IndividualDraftFactory()
r = self.client.get(urlreverse('ietf.doc.views_search.drafts_in_iesg_process'))
self.assertEqual(r.status_code, 200)
self.assertContains(r, doc_in_process.title)
self.assertContains(r, doc_in_process.action_holders.first().plain_name())
self.assertNotContains(r, doc_not_in_process.title)
def test_indexes(self):
@ -323,6 +330,7 @@ class SearchTests(TestCase):
drafts = WgDraftFactory.create_batch(3,states=[('draft','active'),('draft-iesg','ad-eval')])
for index, draft in enumerate(drafts):
StateDocEventFactory(doc=draft, state=('draft-iesg','ad-eval'), time=datetime.datetime.now()-datetime.timedelta(days=[1,15,29][index]))
draft.action_holders.set([PersonFactory()])
# And one draft that should not show (with the default of 7 days to view)
old = WgDraftFactory()
@ -335,7 +343,9 @@ class SearchTests(TestCase):
q = PyQuery(r.content)
self.assertEqual(len(q('td.doc')),3)
self.assertEqual(q('td.status span.label-warning').text(),"for 15 days")
self.assertEqual(q('td.status span.label-danger').text(),"for 29 days")
self.assertEqual(q('td.status span.label-danger').text(),"for 29 days")
for ah in [draft.action_holders.first() for draft in drafts]:
self.assertContains(r, ah.plain_name())
class DocDraftTestCase(TestCase):
draft_text = """
@ -775,6 +785,95 @@ Man Expires September 22, 2015 [Page 3]
msg_prefix='Non-WG-like group %s (%s) should not include group type in link' % (group.acronym, group.type),
)
@staticmethod
def _pyquery_select_action_holder_string(q, s):
"""Helper to use PyQuery to find an action holder in the draft HTML"""
# selector grabs the action holders heading and finds siblings with a div containing the search string
return q('th:contains("Action Holders") ~ td>div:contains("%s")' % s)
@mock.patch.object(Document, 'action_holders_enabled', return_value=False, new_callable=mock.PropertyMock)
def test_document_draft_hides_action_holders(self, mock_method):
"""Draft should not show action holders when appropriate"""
draft = WgDraftFactory()
url = urlreverse("ietf.doc.views_doc.document_main", kwargs=dict(name=draft.name))
r = self.client.get(url)
self.assertNotContains(r, 'Action Holders') # should not show action holders...
draft.action_holders.set([PersonFactory()])
r = self.client.get(url)
self.assertNotContains(r, 'Action Holders') # ...even if they are assigned
@mock.patch.object(Document, 'action_holders_enabled', return_value=True, new_callable=mock.PropertyMock)
def test_document_draft_shows_action_holders(self, mock_method):
"""Draft should show action holders when appropriate"""
draft = WgDraftFactory()
url = urlreverse("ietf.doc.views_doc.document_main", kwargs=dict(name=draft.name))
# No action holders case should be shown properly
r = self.client.get(url)
self.assertContains(r, 'Action Holders') # should show action holders
q = PyQuery(r.content)
self.assertEqual(len(self._pyquery_select_action_holder_string(q, '(None)')), 1)
# Action holders should be listed when assigned
draft.action_holders.set(PersonFactory.create_batch(3))
# Make one action holder "old"
old_action_holder = draft.documentactionholder_set.first()
old_action_holder.time_added -= datetime.timedelta(days=30)
old_action_holder.save()
with self.settings(DOC_ACTION_HOLDER_AGE_LIMIT_DAYS=20):
r = self.client.get(url)
self.assertContains(r, 'Action Holders') # should still be shown
q = PyQuery(r.content)
self.assertEqual(len(self._pyquery_select_action_holder_string(q, '(None)')), 0)
for person in draft.action_holders.all():
self.assertEqual(len(self._pyquery_select_action_holder_string(q, person.plain_name())), 1)
# check that one action holder was marked as old
self.assertEqual(len(self._pyquery_select_action_holder_string(q, 'for 30 days')), 1)
@mock.patch.object(Document, 'action_holders_enabled', return_value=True, new_callable=mock.PropertyMock)
def test_document_draft_action_holders_buttons(self, mock_method):
"""Buttons for action holders should be shown when AD or secretary"""
draft = WgDraftFactory()
draft.action_holders.set([PersonFactory()])
url = urlreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=draft.name))
edit_ah_url = urlreverse('ietf.doc.views_doc.edit_action_holders', kwargs=dict(name=draft.name))
remind_ah_url = urlreverse('ietf.doc.views_doc.remind_action_holders', kwargs=dict(name=draft.name))
def _run_test(username=None, expect_buttons=False):
if username:
self.client.login(username=username, password=username + '+password')
r = self.client.get(url)
q = PyQuery(r.content)
self.assertEqual(
len(q('th:contains("Action Holders") ~ td a[href="%s"]' % edit_ah_url)),
1 if expect_buttons else 0,
'%s should%s see the edit action holders button but %s' % (
username if username else 'unauthenticated user',
'' if expect_buttons else ' not',
'did not' if expect_buttons else 'did',
)
)
self.assertEqual(
len(q('th:contains("Action Holders") ~ td a[href="%s"]' % remind_ah_url)),
1 if expect_buttons else 0,
'%s should%s see the remind action holders button but %s' % (
username if username else 'unauthenticated user',
'' if expect_buttons else ' not',
'did not' if expect_buttons else 'did',
)
)
_run_test(None, False)
_run_test('plain', False)
_run_test('ad', True)
_run_test('secretary', True)
def test_draft_group_link(self):
"""Link to group 'about' page should have correct format"""
for group_type_id in ['wg', 'rg', 'ag']:

View file

@ -339,6 +339,8 @@ class BallotWriteupsTests(TestCase):
send_last_call_request="1"))
draft = Document.objects.get(name=draft.name)
self.assertEqual(draft.get_state_slug("draft-iesg"), "lc-req")
self.assertCountEqual(draft.action_holders.all(), [ad])
self.assertIn('Changed action holders', draft.latest_event(type='changed_action_holders').desc)
self.assertEqual(len(outbox), mailbox_before + 1)
self.assertTrue("Last Call" in outbox[-1]['Subject'])
self.assertTrue(draft.name in outbox[-1]['Subject'])
@ -501,6 +503,7 @@ class BallotWriteupsTests(TestCase):
self.assertEqual(len(q('textarea[name=ballot_writeup]')), 1)
self.assertFalse(q('[class=help-block]:contains("not completed IETF Last Call")'))
self.assertTrue(q('[type=submit]:contains("Save")'))
self.assertCountEqual(draft.action_holders.all(), [])
# save
r = self.client.post(url, dict(
@ -508,7 +511,8 @@ class BallotWriteupsTests(TestCase):
issue_ballot="1"))
self.assertEqual(r.status_code, 200)
d = Document.objects.get(name=draft.name)
self.assertTrue('iesg-eva' == d.get_state_slug('draft-iesg'))
self.assertTrue('iesg-eva' == d.get_state_slug('draft-iesg'))
self.assertCountEqual(draft.action_holders.all(), [ad])
def test_issue_ballot_warn_if_early(self):
ad = Person.objects.get(user__username="ad")
@ -727,13 +731,17 @@ class ApproveBallotTests(TestCase):
self.assertTrue(not outbox[-1]['CC'])
self.assertTrue('drafts-approval@icann.org' in outbox[-1]['To'])
self.assertTrue("Protocol Action" in draft.message_set.order_by("-time")[0].subject)
# in 'ann' state, action holders should be empty
self.assertCountEqual(draft.action_holders.all(), [])
def test_disapprove_ballot(self):
# This tests a codepath that is not used in production
# and that has already had some drift from usefulness (it results in a
# older-style conflict review response).
draft = IndividualDraftFactory()
ad = Person.objects.get(name="Areað Irector")
draft = IndividualDraftFactory(ad=ad)
draft.set_state(State.objects.get(used=True, type="draft-iesg", slug="nopubadw"))
draft.action_holders.set([ad])
url = urlreverse('ietf.doc.views_ballot.approve_ballot', kwargs=dict(name=draft.name))
login_testing_unauthorized(self, "secretary", url)
@ -748,6 +756,8 @@ class ApproveBallotTests(TestCase):
self.assertEqual(draft.get_state_slug("draft-iesg"), "dead")
self.assertEqual(len(outbox), mailbox_before + 1)
self.assertTrue("NOT be published" in str(outbox[-1]))
self.assertCountEqual(draft.action_holders.all(), [])
self.assertIn('Removed all action holders', draft.latest_event(type='changed_action_holders').desc)
def test_clear_ballot(self):
draft = IndividualDraftFactory()
@ -846,6 +856,7 @@ class MakeLastCallTests(TestCase):
draft = Document.objects.get(name=draft.name)
self.assertEqual(draft.get_state_slug("draft-iesg"), "lc")
self.assertEqual(draft.latest_event(LastCallDocEvent, "sent_last_call").expires.strftime("%Y-%m-%d"), expire_date)
self.assertCountEqual(draft.action_holders.all(), [ad])
self.assertEqual(len(outbox), mailbox_before + 2)
@ -940,7 +951,12 @@ class DeferUndeferTestCase(TestCase):
if doc.type_id in defer_states:
self.assertEqual(doc.get_state(defer_states[doc.type_id][0]).slug,defer_states[doc.type_id][1])
self.assertTrue(doc.active_defer_event())
if doc.type_id == 'draft':
self.assertCountEqual(doc.action_holders.all(), [doc.ad])
self.assertIn('Changed action holders', doc.latest_event(type='changed_action_holders').desc)
else:
self.assertIsNone(doc.latest_event(type='changed_action_holders'))
self.assertEqual(len(outbox), mailbox_before + 2)
self.assertTrue('Telechat update' in outbox[-2]['Subject'])
@ -1000,6 +1016,11 @@ class DeferUndeferTestCase(TestCase):
if doc.type_id in undefer_states:
self.assertEqual(doc.get_state(undefer_states[doc.type_id][0]).slug,undefer_states[doc.type_id][1])
self.assertFalse(doc.active_defer_event())
if doc.type_id == 'draft':
self.assertCountEqual(doc.action_holders.all(), [doc.ad])
self.assertIn('Changed action holders', doc.latest_event(type='changed_action_holders').desc)
else:
self.assertIsNone(doc.latest_event(type='changed_action_holders'))
self.assertEqual(len(outbox), mailbox_before + 2)
self.assertTrue("Telechat update" in outbox[-2]['Subject'])
self.assertTrue('iesg-secretary@' in outbox[-2]['To'])
@ -1035,7 +1056,8 @@ class DeferUndeferTestCase(TestCase):
# when charters support being deferred, be sure to test them here
def setUp(self):
IndividualDraftFactory(name='draft-ietf-mars-test',states=[('draft','active'),('draft-iesg','iesg-eva')])
IndividualDraftFactory(name='draft-ietf-mars-test',states=[('draft','active'),('draft-iesg','iesg-eva')],
ad=Person.objects.get(user__username='ad'))
DocumentFactory(type_id='statchg',name='status-change-imaginary-mid-review',states=[('statchg','iesgeval')])
DocumentFactory(type_id='conflrev',name='conflict-review-imaginary-irtf-submission',states=[('conflrev','iesgeval')])

View file

@ -25,7 +25,7 @@ from ietf.doc.utils import get_tags_for_stream_id, create_ballot_if_not_open
from ietf.name.models import StreamName, DocTagName
from ietf.group.factories import GroupFactory, RoleFactory
from ietf.group.models import Group, Role
from ietf.person.factories import PersonFactory
from ietf.person.factories import PersonFactory, EmailFactory
from ietf.person.models import Person, Email
from ietf.meeting.models import Meeting, MeetingTypeName
from ietf.iesg.models import TelechatDate
@ -41,6 +41,7 @@ class ChangeStateTests(TestCase):
draft = WgDraftFactory(ad=ad,states=[('draft','active'),('draft-iesg','iesg-eva')])
DocEventFactory(type='started_iesg_process',by=ad,doc=draft,rev=draft.rev,desc="Started IESG Process")
draft.tags.add("ad-f-up")
draft.action_holders.add(ad)
url = urlreverse('ietf.doc.views_draft.change_state', kwargs=dict(name=draft.name))
login_testing_unauthorized(self, "ad", url)
@ -67,10 +68,12 @@ class ChangeStateTests(TestCase):
self.assertEqual(draft.get_state_slug("draft-iesg"), "approved")
self.assertTrue(not draft.tags.filter(slug="approved"))
self.assertFalse(draft.tags.exists())
self.assertEqual(draft.docevent_set.count(), events_before + 2)
self.assertEqual(draft.docevent_set.count(), events_before + 3)
self.assertTrue("Test comment" in draft.docevent_set.all()[0].desc)
self.assertTrue("IESG state changed" in draft.docevent_set.all()[1].desc)
self.assertTrue("Removed all action holders" in draft.docevent_set.all()[1].desc)
self.assertTrue("IESG state changed" in draft.docevent_set.all()[2].desc)
self.assertCountEqual(draft.action_holders.all(), [])
# should have sent two emails, the second one to the iesg with approved message
self.assertEqual(len(outbox), mailbox_before + 2)
self.assertTrue("Approved: " in outbox[-1]['Subject'])
@ -79,8 +82,15 @@ class ChangeStateTests(TestCase):
def test_change_state(self):
ad = Person.objects.get(user__username="ad")
draft = WgDraftFactory(name='draft-ietf-mars-test',group__acronym='mars',ad=ad,states=[('draft','active'),('draft-iesg','ad-eval')])
draft = WgDraftFactory(
name='draft-ietf-mars-test',
group__acronym='mars',
ad=ad,
authors=PersonFactory.create_batch(3),
states=[('draft','active'),('draft-iesg','ad-eval')]
)
DocEventFactory(type='started_iesg_process',by=ad,doc=draft,rev=draft.rev,desc="Started IESG Process")
draft.action_holders.add(ad)
url = urlreverse('ietf.doc.views_draft.change_state', kwargs=dict(name=draft.name))
login_testing_unauthorized(self, "secretary", url)
@ -105,7 +115,7 @@ class ChangeStateTests(TestCase):
self.assertTrue(len(q('form .has-error')) > 0)
draft = Document.objects.get(name=draft.name)
self.assertEqual(draft.get_state("draft-iesg"), first_state)
self.assertCountEqual(draft.action_holders.all(), [ad])
# change state
events_before = draft.docevent_set.count()
@ -122,9 +132,11 @@ class ChangeStateTests(TestCase):
self.assertEqual(draft.get_state_slug("draft-iesg"), "review-e")
self.assertTrue(not draft.tags.filter(slug="ad-f-up"))
self.assertTrue(draft.tags.filter(slug="need-rev"))
self.assertEqual(draft.docevent_set.count(), events_before + 2)
self.assertCountEqual(draft.action_holders.all(), [ad] + draft.authors())
self.assertEqual(draft.docevent_set.count(), events_before + 3)
self.assertTrue("Test comment" in draft.docevent_set.all()[0].desc)
self.assertTrue("IESG state changed" in draft.docevent_set.all()[1].desc)
self.assertTrue("Changed action holders" in draft.docevent_set.all()[1].desc)
self.assertTrue("IESG state changed" in draft.docevent_set.all()[2].desc)
self.assertEqual(len(outbox), mailbox_before + 1)
self.assertTrue("State Update Notice" in outbox[-1]['Subject'])
self.assertTrue('draft-ietf-mars-test@' in outbox[-1]['To'])
@ -139,8 +151,13 @@ class ChangeStateTests(TestCase):
def test_pull_from_rfc_queue(self):
ad = Person.objects.get(user__username="ad")
draft = WgDraftFactory(ad=ad,states=[('draft-iesg','rfcqueue')])
draft = WgDraftFactory(
ad=ad,
authors=PersonFactory.create_batch(3),
states=[('draft-iesg','rfcqueue')],
)
DocEventFactory(type='started_iesg_process',by=ad,doc=draft,rev=draft.rev,desc="Started IESG Process")
draft.action_holders.add(*(draft.authors()))
url = urlreverse('ietf.doc.views_draft.change_state', kwargs=dict(name=draft.name))
login_testing_unauthorized(self, "secretary", url)
@ -156,7 +173,7 @@ class ChangeStateTests(TestCase):
draft = Document.objects.get(name=draft.name)
self.assertEqual(draft.get_state_slug("draft-iesg"), "review-e")
self.assertCountEqual(draft.action_holders.all(), [ad])
self.assertEqual(len(outbox), mailbox_before + 2)
self.assertTrue(draft.name in outbox[-1]['Subject'])
@ -234,8 +251,13 @@ class ChangeStateTests(TestCase):
def test_request_last_call(self):
ad = Person.objects.get(user__username="ad")
draft = WgDraftFactory(ad=ad,states=[('draft-iesg','ad-eval')])
draft = WgDraftFactory(
ad=ad,
authors=PersonFactory.create_batch(3),
states=[('draft-iesg','ad-eval')],
)
DocEventFactory(type='started_iesg_process',by=ad,doc=draft,rev=draft.rev,desc="Started IESG Process")
draft.action_holders.add(*(draft.authors()))
self.client.login(username="secretary", password="secretary+password")
url = urlreverse('ietf.doc.views_draft.change_state', kwargs=dict(name=draft.name))
@ -247,6 +269,8 @@ class ChangeStateTests(TestCase):
self.assertEqual(r.status_code,200)
self.assertContains(r, "Your request to issue")
draft = Document.objects.get(name=draft.name)
# last call text
e = draft.latest_event(WriteupDocEvent, type="changed_last_call_text")
self.assertTrue(e)
@ -279,6 +303,9 @@ class ChangeStateTests(TestCase):
# comment
self.assertTrue("Last call was requested" in draft.latest_event().desc)
# action holders
self.assertCountEqual(draft.action_holders.all(), [ad])
class EditInfoTests(TestCase):
def test_edit_info(self):
@ -449,9 +476,10 @@ class EditInfoTests(TestCase):
self.assertEqual(draft.ad, ad)
self.assertEqual(draft.note, "This is a note")
self.assertTrue(not draft.latest_event(TelechatDocEvent, type="scheduled_for_telechat"))
self.assertEqual(draft.docevent_set.count(), events_before + 4)
self.assertEqual(draft.docevent_set.count(), events_before + 5)
self.assertCountEqual(draft.action_holders.all(), [draft.ad])
events = list(draft.docevent_set.order_by('time', 'id'))
self.assertEqual(events[-4].type, "started_iesg_process")
self.assertEqual(events[-5].type, "started_iesg_process")
self.assertEqual(len(outbox), mailbox_before+1)
self.assertTrue('IESG processing' in outbox[-1]['Subject'])
self.assertTrue('draft-ietf-mars-test2@' in outbox[-1]['To'])
@ -460,6 +488,7 @@ class EditInfoTests(TestCase):
draft.set_state(State.objects.get(type_id='draft-iesg', slug='idexists'))
draft.set_state(State.objects.get(type='draft-stream-ietf',slug='writeupw'))
draft.stream = StreamName.objects.get(slug='ietf')
draft.action_holders.clear()
draft.save_with_history([DocEvent.objects.create(doc=draft, rev=draft.rev, type="changed_stream", by=Person.objects.get(user__username="secretary"), desc="Test")])
r = self.client.post(url,
dict(intended_std_level=str(draft.intended_std_level_id),
@ -473,6 +502,7 @@ class EditInfoTests(TestCase):
draft = Document.objects.get(name=draft.name)
self.assertEqual(draft.get_state_slug('draft-iesg'),'pub-req')
self.assertEqual(draft.get_state_slug('draft-stream-ietf'),'sub-pub')
self.assertCountEqual(draft.action_holders.all(), [draft.ad])
def test_edit_consensus(self):
draft = WgDraftFactory()
@ -668,8 +698,9 @@ class ExpireIDsTests(DraftFileMixin, TestCase):
def test_expire_drafts(self):
mars = GroupFactory(type_id='wg',acronym='mars')
ad_role = RoleFactory(group=mars, name_id='ad', person=Person.objects.get(user__username='ad'))
draft = WgDraftFactory(name='draft-ietf-mars-test',group=mars)
ad = Person.objects.get(user__username='ad')
ad_role = RoleFactory(group=mars, name_id='ad', person=ad)
draft = WgDraftFactory(name='draft-ietf-mars-test',group=mars,ad=ad)
DocEventFactory(type='started_iesg_process',by=ad_role.person,doc=draft,rev=draft.rev,desc="Started IESG Process")
self.assertEqual(len(list(get_expired_drafts())), 0)
@ -689,6 +720,8 @@ class ExpireIDsTests(DraftFileMixin, TestCase):
self.assertEqual(len(list(get_expired_drafts())), 0)
draft.action_holders.set([draft.ad])
# test notice
mailbox_before = len(outbox)
@ -710,6 +743,8 @@ class ExpireIDsTests(DraftFileMixin, TestCase):
self.assertEqual(draft.get_state_slug(), "expired")
self.assertEqual(draft.get_state_slug("draft-iesg"), "dead")
self.assertTrue(draft.latest_event(type="expired_document"))
self.assertCountEqual(draft.action_holders.all(), [])
self.assertIn('Removed all action holders', draft.latest_event(type='changed_action_holders').desc)
self.assertTrue(not os.path.exists(os.path.join(self.id_dir, txt)))
self.assertTrue(os.path.exists(os.path.join(self.archive_dir, txt)))
@ -815,12 +850,14 @@ class ExpireLastCallTests(TestCase):
# expire it
mailbox_before = len(outbox)
events_before = draft.docevent_set.count()
expire_last_call(drafts[0])
draft = Document.objects.get(name=draft.name)
self.assertEqual(draft.get_state_slug("draft-iesg"), "writeupw")
self.assertEqual(draft.docevent_set.count(), events_before + 1)
self.assertEqual(draft.docevent_set.count(), events_before + 2)
self.assertCountEqual(draft.action_holders.all(), [ad])
self.assertIn('Changed action holders', draft.latest_event(type='changed_action_holders').desc)
self.assertEqual(len(outbox), mailbox_before + 1)
self.assertTrue("Last Call Expired" in outbox[-1]["Subject"])
self.assertTrue('iesg-secretary@' in outbox[-1]['Cc'])
@ -844,13 +881,15 @@ class ExpireLastCallTests(TestCase):
drafts = list(get_expired_last_calls())
self.assertEqual(len(drafts), 1)
mailbox_before = len(outbox)
mailbox_before = len(outbox)
expire_last_call(drafts[0])
d = Document.objects.get(name=draft.name)
self.assertEqual(len(outbox), mailbox_before + 2)
self.assertTrue("Review Downrefs From Expired Last Call" in outbox[-1]["Subject"])
self.assertTrue(d.ad.email().address in outbox[-1]['To'])
self.assertCountEqual(d.action_holders.all(), [ad])
self.assertIn('Changed action holders', d.latest_event(type='changed_action_holders').desc)
class IndividualInfoFormsTests(TestCase):
@ -1202,12 +1241,117 @@ class IndividualInfoFormsTests(TestCase):
self.assertEqual(doc.docextresource_set.get(name__slug='github_repo').display_name, 'Some display text')
self.assertIn(doc.docextresource_set.first().name.slug,str(doc.docextresource_set.first()))
# This is in views_doc, not views_draft, but there's already mixing and this keeps it with similar tests
def do_doc_change_action_holders_test(self, username):
# Set up people related to the doc to be sure shortcut buttons appear.
doc = Document.objects.get(name=self.docname)
doc.documentauthor_set.create(person=PersonFactory())
doc.ad = Person.objects.get(user__username='ad')
doc.shepherd = EmailFactory()
doc.save_with_history([DocEvent.objects.create(doc=doc, rev=doc.rev, type="changed_shepherd", by=Person.objects.get(user__username="secretary"), desc="Test")])
RoleFactory(name_id='chair', person=PersonFactory(), group=doc.group)
RoleFactory(name_id='techadv', person=PersonFactory(), group=doc.group)
RoleFactory(name_id='editor', person=PersonFactory(), group=doc.group)
RoleFactory(name_id='secr', person=PersonFactory(), group=doc.group)
url = urlreverse('ietf.doc.views_doc.edit_action_holders', kwargs=dict(name=doc.name))
login_testing_unauthorized(self, username, url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertEqual(len(q('form input[id=id_reason]')), 1)
self.assertEqual(len(q('form input[id=id_action_holders]')), 1)
for role_name in [
'Author',
'Responsible AD',
'Shepherd',
'Group Chair',
'Group Tech Advisor',
'Group Editor',
'Group Secretary',
]:
self.assertEqual(len(q('button:contains("Add %s")' % role_name)), 1,
'Expected "Add %s" button' % role_name)
self.assertEqual(len(q('button:contains("Remove %s")' % role_name)), 1,
'Expected "Remove %s" button for' % role_name)
def _test_changing_ah(action_holders, reason):
r = self.client.post(url, dict(
reason=reason,
action_holders=','.join([str(p.pk) for p in action_holders]),
))
self.assertEqual(r.status_code, 302)
doc = Document.objects.get(name=self.docname)
self.assertCountEqual(doc.action_holders.all(), action_holders)
event = doc.latest_event(type='changed_action_holders')
self.assertIn(reason, event.desc)
if action_holders:
for ah in action_holders:
self.assertIn(ah.plain_name(), event.desc)
else:
self.assertIn('Removed all', event.desc)
_test_changing_ah([doc.ad, doc.shepherd.person], 'this is a first test')
_test_changing_ah([doc.ad], 'this is a second test')
_test_changing_ah(doc.authors(), 'authors can do it, too')
_test_changing_ah([], 'clear it back out')
def test_doc_change_action_holders_as_secretary(self):
self.do_doc_change_action_holders_test('secretary')
def test_doc_change_action_holders_as_ad(self):
self.do_doc_change_action_holders_test('ad')
def do_doc_remind_action_holders_test(self, username):
doc = Document.objects.get(name=self.docname)
doc.action_holders.set(PersonFactory.create_batch(3))
url = urlreverse('ietf.doc.views_doc.remind_action_holders', kwargs=dict(name=doc.name))
login_testing_unauthorized(self, username, url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertEqual(len(q('form textarea[id=id_note]')), 1)
self.assertEqual(len(q('button:contains("Send")')), 1)
for ah in doc.action_holders.all():
self.assertContains(r, ah.plain_name())
empty_outbox()
r = self.client.post(url, dict(note='this is my note')) # note should be < 78 chars to avoid wrapping
self.assertEqual(r.status_code, 302)
self.assertEqual(len(outbox), 1)
for ah in doc.action_holders.all():
self.assertIn('Reminder: action needed', outbox[0]['Subject'])
self.assertIn(ah.email_address(), outbox[0]['To'])
self.assertIn(doc.display_name(), outbox[0].as_string())
self.assertIn(doc.get_absolute_url(), outbox[0].as_string())
self.assertIn('this is my note', outbox[0].as_string())
# check that nothing is sent when no action holders
doc.action_holders.clear()
self.client.post(url)
self.assertEqual(len(outbox), 1) # still 1
def test_doc_remind_action_holders_as_ad(self):
self.do_doc_remind_action_holders_test('ad')
def test_doc_remind_action_holders_as_secretary(self):
self.do_doc_remind_action_holders_test('secretary')
class SubmitToIesgTests(TestCase):
def setUp(self):
role=RoleFactory(group__acronym='mars',name_id='chair',person=PersonFactory(user__username='marschairman'))
doc=WgDraftFactory(name='draft-ietf-mars-test',group=role.group,ad=Person.objects.get(user__username='ad'))
doc=WgDraftFactory(
name='draft-ietf-mars-test',
group=role.group,
ad=Person.objects.get(user__username='ad'),
authors=PersonFactory.create_batch(3),
)
self.docname=doc.name
def test_verify_permissions(self):
@ -1242,6 +1386,7 @@ class SubmitToIesgTests(TestCase):
doc = Document.objects.get(name=self.docname)
self.assertEqual(doc.get_state_slug('draft-iesg'),'idexists')
self.assertCountEqual(doc.action_holders.all(), [])
def test_confirm_submission(self):
url = urlreverse('ietf.doc.views_draft.to_iesg', kwargs=dict(name=self.docname))
@ -1258,16 +1403,14 @@ class SubmitToIesgTests(TestCase):
self.assertTrue(doc.get_state('draft-iesg').slug=='pub-req')
self.assertTrue(doc.get_state('draft-stream-ietf').slug=='sub-pub')
# It's not clear what this testing - the view can certainly
# leave the document without an ad. This line as written only
# checks whether the setup document had an ad or not.
self.assertTrue(doc.ad!=None)
self.assertCountEqual(doc.action_holders.all(), [doc.ad])
new_docevents = set(doc.docevent_set.all()) - docevents_pre
self.assertEqual(len(new_docevents),3)
self.assertEqual(len(new_docevents), 4)
new_docevent_type_count = Counter([e.type for e in new_docevents])
self.assertEqual(new_docevent_type_count['changed_state'],2)
self.assertEqual(new_docevent_type_count['started_iesg_process'],1)
self.assertEqual(new_docevent_type_count['changed_action_holders'], 1)
self.assertEqual(len(outbox), mailbox_before + 1)
self.assertTrue("Publication has been requested" in outbox[-1]['Subject'])

207
ietf/doc/tests_utils.py Normal file
View file

@ -0,0 +1,207 @@
# Copyright The IETF Trust 2020, All Rights Reserved
import datetime
from ietf.group.factories import GroupFactory, RoleFactory
from ietf.name.models import DocTagName
from ietf.person.factories import PersonFactory
from ietf.utils.test_utils import TestCase
from ietf.person.models import Person
from ietf.doc.factories import DocumentFactory
from ietf.doc.models import State, DocumentActionHolder
from ietf.doc.utils import update_action_holders, add_state_change_event
class ActionHoldersTests(TestCase):
def setUp(self):
"""Set up helper for the update_action_holders tests"""
self.authors = PersonFactory.create_batch(3)
self.ad = Person.objects.get(user__username='ad')
self.group = GroupFactory()
RoleFactory(name_id='ad', group=self.group, person=self.ad)
def doc_in_iesg_state(self, slug):
return DocumentFactory(authors=self.authors, group=self.group, ad=self.ad, states=[('draft-iesg', slug)])
def update_doc_state(self, doc, new_state, add_tags=None, remove_tags=None):
"""Update document state/tags, create change event, and save"""
prev_tags = list(doc.tags.all()) # list to make sure we retrieve now
# prev_action_holders = list(doc.action_holders.all())
prev_state = doc.get_state(new_state.type_id)
if new_state != prev_state:
doc.set_state(new_state)
if add_tags:
doc.tags.add(*DocTagName.objects.filter(slug__in=add_tags))
if remove_tags:
doc.tags.remove(*DocTagName.objects.filter(slug__in=remove_tags))
new_tags = list(doc.tags.all())
events = []
e = add_state_change_event(
doc,
Person.objects.get(name='(System)'),
prev_state, new_state,
prev_tags, new_tags)
self.assertIsNotNone(e, 'Test logic error')
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags, new_tags)
if e:
events.append(e)
doc.save_with_history(events)
def test_update_action_holders_by_state(self):
"""Doc action holders should auto-update correctly on state change"""
# Test the transition from every state to each of its 'next_states'
for initial_state in State.objects.filter(type__slug='draft-iesg'):
for next_state in initial_state.next_states.all():
# Test with no action holders initially
doc = DocumentFactory(
authors=self.authors,
group=self.group,
ad=self.ad,
states=[('draft-iesg', initial_state.slug)],
)
docevents_before = set(doc.docevent_set.all())
self.update_doc_state(doc, next_state)
new_docevents = set(doc.docevent_set.all()).difference(docevents_before)
self.assertIn(doc.latest_event(type='changed_state'), new_docevents)
if next_state.slug in DocumentActionHolder.CLEAR_ACTION_HOLDERS_STATES:
self.assertCountEqual(doc.action_holders.all(), [])
self.assertEqual(len(new_docevents), 1)
else:
self.assertCountEqual(
doc.action_holders.all(), [doc.ad],
'AD should be only action holder after transition to %s' % next_state.slug)
self.assertEqual(len(new_docevents), 2)
change_event = doc.latest_event(type='changed_action_holders')
self.assertIn(change_event, new_docevents)
self.assertIn('Changed action holders', change_event.desc)
self.assertIn(doc.ad.name, change_event.desc)
doc.delete() # clean up for next iteration
# Test with action holders initially
doc = DocumentFactory(
authors=self.authors,
group=self.group,
ad=self.ad,
states=[('draft-iesg', initial_state.slug)],
)
doc.action_holders.add(*self.authors) # adds all authors
docevents_before = set(doc.docevent_set.all())
self.update_doc_state(doc, next_state)
new_docevents = set(doc.docevent_set.all()).difference(docevents_before)
self.assertEqual(len(new_docevents), 2)
self.assertIn(doc.latest_event(type='changed_state'), new_docevents)
change_event = doc.latest_event(type='changed_action_holders')
self.assertIn(change_event, new_docevents)
if next_state.slug in DocumentActionHolder.CLEAR_ACTION_HOLDERS_STATES:
self.assertCountEqual(doc.action_holders.all(), [])
self.assertIn('Removed all action holders', change_event.desc)
else:
self.assertCountEqual(
doc.action_holders.all(), [doc.ad],
'AD should be only action holder after transition to %s' % next_state.slug)
self.assertIn('Changed action holders', change_event.desc)
self.assertIn(doc.ad.name, change_event.desc)
doc.delete() # clean up for next iteration
def test_update_action_holders_with_no_ad(self):
"""A document with no AD should be handled gracefully"""
doc = self.doc_in_iesg_state('idexists')
doc.ad = None
doc.save()
docevents_before = set(doc.docevent_set.all())
self.update_doc_state(doc, State.objects.get(slug='pub-req'))
new_docevents = set(doc.docevent_set.all()).difference(docevents_before)
self.assertEqual(len(new_docevents), 1)
self.assertIn(doc.latest_event(type='changed_state'), new_docevents)
self.assertCountEqual(doc.action_holders.all(), [])
def test_update_action_holders_resets_age(self):
"""Action holder age should reset when document state changes"""
doc = self.doc_in_iesg_state('pub-req')
doc.action_holders.set([self.ad])
dah = doc.documentactionholder_set.get(person=self.ad)
dah.time_added = datetime.datetime(2020, 1, 1) # arbitrary date in the past
dah.save()
self.assertNotEqual(doc.documentactionholder_set.get(person=self.ad).time_added.date(), datetime.date.today())
self.update_doc_state(doc, State.objects.get(slug='ad-eval'))
self.assertEqual(doc.documentactionholder_set.get(person=self.ad).time_added.date(), datetime.date.today())
def test_update_action_holders_add_tag_need_rev(self):
"""Adding need-rev tag adds authors as action holders"""
doc = self.doc_in_iesg_state('pub-req')
first_author = self.authors[0]
doc.action_holders.add(first_author)
self.assertCountEqual(doc.action_holders.all(), [first_author])
self.update_doc_state(doc,
doc.get_state('draft-iesg'),
add_tags=['need-rev'],
remove_tags=None)
self.assertCountEqual(doc.action_holders.all(), self.authors)
def test_update_action_holders_add_tag_need_rev_no_dups(self):
"""Adding need-rev tag does not duplicate existing action holders"""
doc = self.doc_in_iesg_state('pub-req')
self.assertCountEqual(doc.action_holders.all(), [])
self.update_doc_state(doc,
doc.get_state('draft-iesg'),
add_tags=['need-rev'],
remove_tags=None)
self.assertCountEqual(doc.action_holders.all(), self.authors)
def test_update_action_holders_remove_tag_need_rev(self):
"""Removing need-rev tag drops authors as action holders"""
doc = self.doc_in_iesg_state('pub-req')
doc.tags.add(DocTagName.objects.get(slug='need-rev'))
self.assertEqual(doc.action_holders.count(), 0)
self.update_doc_state(doc,
doc.get_state('draft-iesg'),
add_tags=None,
remove_tags=['need-rev'])
self.assertEqual(doc.action_holders.count(), 0)
def test_update_action_holders_add_tag_need_rev_ignores_non_authors(self):
"""Adding need-rev tag does not affect existing action holders"""
doc = self.doc_in_iesg_state('pub-req')
doc.action_holders.add(self.ad)
self.assertCountEqual(doc.action_holders.all(),[self.ad])
self.update_doc_state(doc,
doc.get_state('draft-iesg'),
add_tags=['need-rev'],
remove_tags=None)
self.assertCountEqual(doc.action_holders.all(), [self.ad] + self.authors)
def test_update_action_holders_remove_tag_need_rev_ignores_non_authors(self):
"""Removing need-rev tag does not affect non-author action holders"""
doc = self.doc_in_iesg_state('pub-req')
doc.tags.add(DocTagName.objects.get(slug='need-rev'))
doc.action_holders.add(self.ad)
self.assertCountEqual(doc.action_holders.all(), [self.ad])
self.update_doc_state(doc,
doc.get_state('draft-iesg'),
add_tags=None,
remove_tags=['need-rev'])
self.assertCountEqual(doc.action_holders.all(), [self.ad])
def test_doc_action_holders_enabled(self):
"""Action holders should only be enabled in certain states"""
doc = self.doc_in_iesg_state('idexists')
self.assertFalse(doc.action_holders_enabled())
for state in State.objects.filter(type='draft-iesg').exclude(slug='idexists'):
doc.set_state(state)
self.assertTrue(doc.action_holders_enabled())

View file

@ -104,6 +104,8 @@ urlpatterns = [
url(r'^%(name)s/edit/stream/$' % settings.URL_REGEXPS, views_draft.change_stream),
url(r'^%(name)s/edit/replaces/$' % settings.URL_REGEXPS, views_draft.replaces),
url(r'^%(name)s/edit/notify/$' % settings.URL_REGEXPS, views_doc.edit_notify),
url(r'^%(name)s/edit/actionholders/$' % settings.URL_REGEXPS, views_doc.edit_action_holders),
url(r'^%(name)s/edit/remindactionholders/$' % settings.URL_REGEXPS, views_doc.remind_action_holders),
url(r'^%(name)s/edit/suggested-replaces/$' % settings.URL_REGEXPS, views_draft.review_possibly_replaces),
url(r'^%(name)s/edit/status/$' % settings.URL_REGEXPS, views_draft.change_intention),
url(r'^%(name)s/edit/telechat/$' % settings.URL_REGEXPS, views_doc.telechat_date),

View file

@ -26,7 +26,7 @@ from ietf.community.utils import docs_tracked_by_community_list
from ietf.doc.models import Document, DocHistory, State, DocumentAuthor, DocHistoryAuthor
from ietf.doc.models import DocAlias, RelatedDocument, RelatedDocHistory, BallotType, DocReminder
from ietf.doc.models import DocEvent, ConsensusDocEvent, BallotDocEvent, IRSGBallotDocEvent, NewRevisionDocEvent, StateDocEvent
from ietf.doc.models import TelechatDocEvent
from ietf.doc.models import TelechatDocEvent, DocumentActionHolder
from ietf.name.models import DocReminderTypeName, DocRelationshipName
from ietf.group.models import Role, Group
from ietf.ietfauth.utils import has_role
@ -406,11 +406,15 @@ def get_document_content(key, filename, split=True, markup=True):
def tags_suffix(tags):
return ("::" + "::".join(t.name for t in tags)) if tags else ""
def add_state_change_event(doc, by, prev_state, new_state, prev_tags=[], new_tags=[], timestamp=None):
def add_state_change_event(doc, by, prev_state, new_state, prev_tags=None, new_tags=None, timestamp=None):
"""Add doc event to explain that state change just happened."""
if prev_state and new_state:
assert prev_state.type_id == new_state.type_id
# convert default args to empty lists
prev_tags = prev_tags or []
new_tags = new_tags or []
if prev_state == new_state and set(prev_tags) == set(new_tags):
return None
@ -426,6 +430,88 @@ def add_state_change_event(doc, by, prev_state, new_state, prev_tags=[], new_tag
e.save()
return e
def add_action_holder_change_event(doc, by, prev_set, reason=None):
set_changed = False
if doc.documentactionholder_set.exclude(person__in=prev_set).exists():
set_changed = True # doc has an action holder not in the old set
# If set_changed is still False, then all of the current action holders were in
# prev_set. Either the sets are the same or the prev_set contains at least one
# Person not in the current set, so just check length.
if doc.documentactionholder_set.count() != len(prev_set):
set_changed = True
if not set_changed:
return None
if doc.action_holders.exists():
ah_names = [person.plain_name() for person in doc.action_holders.all()]
description = 'Changed action holders to %s' % ', '.join(ah_names)
else:
description = 'Removed all action holders'
if reason:
description += ' (%s)' % reason
return DocEvent.objects.create(
type='changed_action_holders',
doc=doc,
by=by,
rev=doc.rev,
desc=description,
)
def update_action_holders(doc, prev_state=None, new_state=None, prev_tags=None, new_tags=None):
"""Update the action holders for doc based on state transition
Returns an event describing the change which should be passed to doc.save_with_history()
Only cares about draft-iesg state changes. Places where other state types are updated
may not call this method. If you add rules for updating action holders on other state
types, be sure this is called in the places that change that state.
"""
# Should not call this with different state types
if prev_state and new_state:
assert prev_state.type_id == new_state.type_id
# Convert tags to sets of slugs
prev_tag_slugs = {t.slug for t in (prev_tags or [])}
new_tag_slugs = {t.slug for t in (new_tags or [])}
# Do nothing if state / tag have not changed
if (prev_state == new_state) and (prev_tag_slugs == new_tag_slugs):
return None
# Remember original list of action holders to later check if it changed
prev_set = list(doc.action_holders.all())
# Only draft-iesg states are of interest (for now)
if (prev_state != new_state) and (getattr(new_state, 'type_id') == 'draft-iesg'):
# Clear the action_holders list on a state change. This will reset the age of any that get added back.
doc.action_holders.clear()
if doc.ad and new_state.slug not in DocumentActionHolder.CLEAR_ACTION_HOLDERS_STATES:
# Default to responsible AD for states other than these
doc.action_holders.add(doc.ad)
if prev_tag_slugs != new_tag_slugs:
# If we have added or removed the need-rev tag, add or remove authors as action holders
if ('need-rev' in prev_tag_slugs) and ('need-rev' not in new_tag_slugs):
# Removed the 'need-rev' tag - drop authors from the action holders list
DocumentActionHolder.objects.filter(document=doc, person__in=doc.authors()).delete()
elif ('need-rev' not in prev_tag_slugs) and ('need-rev' in new_tag_slugs):
# Added the 'need-rev' tag - add authors to the action holders list
for auth in doc.authors():
if not doc.action_holders.filter(pk=auth.pk).exists():
doc.action_holders.add(auth)
# Now create an event if we changed the set
return add_action_holder_change_event(
doc,
Person.objects.get(name='(System)'),
prev_set,
reason='IESG state changed',
)
def update_reminder(doc, reminder_type_slug, event, due_date):
reminder_type = DocReminderTypeName.objects.get(slug=reminder_type_slug)

View file

@ -22,7 +22,7 @@ from ietf.doc.models import ( Document, State, DocEvent, BallotDocEvent,
IRSGBallotDocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent,
IESG_SUBSTATE_TAGS, RelatedDocument, BallotType )
from ietf.doc.utils import ( add_state_change_event, close_ballot, close_open_ballots,
create_ballot_if_not_open, update_telechat )
create_ballot_if_not_open, update_telechat, update_action_holders )
from ietf.doc.mails import ( email_ballot_deferred, email_ballot_undeferred,
extra_automation_headers, generate_last_call_announcement,
generate_issue_ballot_mail, generate_ballot_writeup, generate_ballot_rfceditornote,
@ -79,9 +79,12 @@ def do_undefer_ballot(request, doc):
doc.tags.remove(*prev_tags)
events = []
state_change_event = add_state_change_event(doc, by, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if state_change_event:
events.append(state_change_event)
e = add_state_change_event(doc, by, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_telechat(request, doc, by, telechat_date)
if e:
@ -453,9 +456,12 @@ def defer_ballot(request, name):
events = []
state_change_event = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if state_change_event:
events.append(state_change_event)
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_telechat(request, doc, login, telechat_date)
if e:
@ -547,10 +553,16 @@ def lastcalltext(request, name):
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
events = []
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
doc.save_with_history([e])
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
if events:
doc.save_with_history(events)
request_last_call(request, doc)
@ -628,9 +640,15 @@ def ballot_writeupnotes(request, name):
doc.set_state(new_state)
doc.tags.remove(*prev_tags)
sce = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if sce:
doc.save_with_history([sce])
events = []
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
if events:
doc.save_with_history(events)
if not ballot_already_approved:
e = create_ballot_if_not_open(request, doc, login, "approve") # pyflakes:ignore
@ -893,9 +911,11 @@ def approve_ballot(request, name):
e.desc = "IESG has approved the document"
e.save()
events.append(e)
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=[])
if e:
events.append(e)
@ -1038,6 +1058,9 @@ def make_last_call(request, name):
doc.tags.remove(*prev_tags)
e = add_state_change_event(doc, login, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
expiration_date = form.cleaned_data['last_call_expiration_date']
@ -1129,9 +1152,12 @@ def issue_irsg_ballot(request, name):
doc.tags.remove(*prev_tags)
events = []
state_change_event = add_state_change_event(doc, by, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if state_change_event:
events.append(state_change_event)
e = add_state_change_event(doc, by, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
if events:
doc.save_with_history(events)

View file

@ -54,21 +54,21 @@ import debug # pyflakes:ignore
from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDocEvent, BallotType,
ConsensusDocEvent, NewRevisionDocEvent, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent,
IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS )
IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder )
from ietf.doc.utils import (add_links_in_new_revision_events, augment_events_with_revision,
can_adopt_draft, can_unadopt_draft, get_chartering_type, 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, make_rev_history, default_consensus,
add_events_message_info, get_unicode_document_content, build_doc_meta_block,
augment_docs_and_user_with_user_info, irsg_needed_ballot_positions )
augment_docs_and_user_with_user_info, irsg_needed_ballot_positions, add_action_holder_change_event )
from ietf.group.models import Role, Group
from ietf.group.utils import can_manage_group_type, can_manage_materials, group_features_role_filter
from ietf.ietfauth.utils import ( has_role, is_authorized_in_doc_stream, user_is_person,
role_required, is_individual_draft_author)
from ietf.name.models import StreamName, BallotPositionName
from ietf.utils.history import find_history_active_at
from ietf.doc.forms import TelechatForm, NotifyForm
from ietf.doc.mails import email_comment
from ietf.doc.forms import TelechatForm, NotifyForm, ActionHoldersForm
from ietf.doc.mails import email_comment, email_remind_action_holders
from ietf.mailtrigger.utils import gather_relevant_expansions
from ietf.meeting.models import Session
from ietf.meeting.utils import group_sessions, get_upcoming_manageable_sessions, sort_sessions, add_event_info_to_session_qs
@ -1275,6 +1275,14 @@ def telechat_date(request, name):
warnings=warnings,
login=login))
def doc_titletext(doc):
if doc.type.slug=='conflrev':
conflictdoc = doc.relateddocument_set.get(relationship__slug='conflrev').target.document
return 'the conflict review of %s' % conflictdoc.canonical_name()
return doc.canonical_name()
def edit_notify(request, name):
"""Change the set of email addresses document change notificaitions go to."""
@ -1311,18 +1319,150 @@ def edit_notify(request, name):
init = { "notify" : doc.notify }
form = NotifyForm(initial=init)
if doc.type.slug=='conflrev':
conflictdoc = doc.relateddocument_set.get(relationship__slug='conflrev').target.document
titletext = 'the conflict review of %s' % conflictdoc.canonical_name()
else:
titletext = '%s' % doc.canonical_name()
return render(request, 'doc/edit_notify.html',
{'form': form,
'doc': doc,
'titletext': titletext,
'titletext': doc_titletext(doc),
},
)
@role_required('Area Director', 'Secretariat')
def edit_action_holders(request, name):
"""Change the set of action holders for a doc"""
doc = get_object_or_404(Document, name=name)
if request.method == 'POST':
form = ActionHoldersForm(request.POST)
if form.is_valid() and 'action_holders' in request.POST:
new_action_holders = form.cleaned_data['action_holders'] # Person queryset
prev_action_holders = list(doc.action_holders.all())
# Now update the action holders. We can't use the simple approach of clearing
# the set and then adding back the entire new_action_holders. If we did that,
# the timestamps that track when each person became an action holder would
# reset every time the list was modified. So we need to be careful only
# to delete the ones that are really being removed.
#
# Also need to take care not to delete the people! doc.action_holders.all()
# (and other querysets) give the Person objects. We only want to add/delete
# the DocumentActionHolder 'through' model objects. That means working directly
# with the model or using doc.action_holders.add() and .remove(), which take
# Person objects as arguments.
existing = DocumentActionHolder.objects.filter(document=doc) # through model
to_remove = existing.exclude(person__in=new_action_holders) # through model
to_remove.delete() # deletes the DocumentActionHolder objects, leaves the Person objects
# Get all the Persons who do not have a DocumentActionHolder for this document
added_people = new_action_holders.exclude(documentactionholder__document=doc)
doc.action_holders.add(*added_people)
add_action_holder_change_event(doc, request.user.person, prev_action_holders,
form.cleaned_data['reason'])
return redirect('ietf.doc.views_doc.document_main', name=doc.name)
# When not a POST
# Data for quick add/remove of various related Persons
doc_role_labels = [] # labels for document-related roles
group_role_labels = [] # labels for group-related roles
role_ids = dict() # maps role slug to list of Person IDs (assumed numeric in the JavaScript)
extra_prefetch = [] # list of Person objects to prefetch for select2 field
if len(doc.authors()) > 0:
doc_role_labels.append(dict(slug='authors', label='Authors'))
authors = doc.authors()
role_ids['authors'] = [p.pk for p in authors]
extra_prefetch += authors
if doc.ad:
doc_role_labels.append(dict(slug='ad', label='Responsible AD'))
role_ids['ad'] = [doc.ad.pk]
extra_prefetch.append(doc.ad)
if doc.shepherd:
# doc.shepherd is an Email, which is allowed not to have a Person.
# The Emails used for shepherds should always have one, though. If not, log the
# event and move on without the shepherd. This just means there will not be
# add/remove shepherd buttons.
log.assertion('doc.shepherd.person',
note="A document's shepherd should always have a Person'. Failed for %s"%doc.name)
if doc.shepherd.person:
doc_role_labels.append(dict(slug='shep', label='Shepherd'))
role_ids['shep'] = [doc.shepherd.person.pk]
extra_prefetch.append(doc.shepherd.person)
if doc.group:
# UI buttons to add / remove will appear in same order as this list
group_roles = doc.group.role_set.filter(
name__in=DocumentActionHolder.GROUP_ROLES_OF_INTEREST,
).select_related('name', 'person') # name is a RoleName
# Gather all the roles for this group
for role in group_roles:
key = 'group_%s' % role.name.slug
existing_list = role_ids.get(key)
if existing_list:
existing_list.append(role.person.pk)
else:
role_ids[key] = [role.person.pk]
group_role_labels.append(dict(
sort_order=DocumentActionHolder.GROUP_ROLES_OF_INTEREST.index(role.name.slug),
slug=key,
label='Group ' + role.name.name, # friendly role name
))
extra_prefetch.append(role.person)
# Ensure group role button order is stable
group_role_labels.sort(key=lambda r: r['sort_order'])
form = ActionHoldersForm(initial={'action_holders': doc.action_holders.all()})
form.fields['action_holders'].extra_prefetch = extra_prefetch
form.fields['action_holders'].widget.attrs["data-role-ids"] = json.dumps(role_ids)
return render(
request,
'doc/edit_action_holders.html',
{
'form': form,
'doc': doc,
'titletext': doc_titletext(doc),
'role_labels': doc_role_labels + group_role_labels,
}
)
class ReminderEmailForm(forms.Form):
note = forms.CharField(
widget=forms.Textarea,
label='Note to action holders',
help_text='Optional message to the action holders',
required=False,
strip=True,
)
@role_required('Area Director', 'Secretariat')
def remind_action_holders(request, name):
doc = get_object_or_404(Document, name=name)
if request.method == 'POST':
form = ReminderEmailForm(request.POST)
if form.is_valid():
email_remind_action_holders(request, doc, form.cleaned_data['note'])
return redirect('ietf.doc.views_doc.document_main', name=doc.canonical_name())
form = ReminderEmailForm()
return render(
request,
'doc/remind_action_holders.html',
{
'form': form,
'doc': doc,
'titletext': doc_titletext(doc),
}
)
def email_aliases(request,name=''):
doc = get_object_or_404(Document, name=name) if name else None
if not name:

View file

@ -33,7 +33,7 @@ from ietf.doc.mails import ( email_pulled_from_rfc_queue, email_resurrect_reques
email_iesg_processing_document, email_ad_approved_doc,
email_iana_expert_review_state_changed )
from ietf.doc.utils import ( add_state_change_event, can_adopt_draft, can_unadopt_draft,
get_tags_for_stream_id, nice_consensus,
get_tags_for_stream_id, nice_consensus, update_action_holders,
update_reminder, update_telechat, make_notify_changed_event, get_initial_notify,
set_replaces_for_document, default_consensus, tags_suffix, )
from ietf.doc.lastcall import request_last_call
@ -115,6 +115,7 @@ def change_state(request, name):
events = []
e = add_state_change_event(doc, login, prev_state, new_state,
prev_tags=prev_tags, new_tags=new_tags)
@ -124,6 +125,10 @@ def change_state(request, name):
events.append(e)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
events.append(e)
if comment:
c = DocEvent(type="added_comment")
c.doc = doc
@ -596,6 +601,9 @@ def to_iesg(request,name):
new_state = target_state[target_map[state_type]]
if not prev_state==new_state:
doc.set_state(new_state)
e = update_action_holders(doc, prev_state, new_state)
if e:
events.append(e)
events.append(add_state_change_event(doc=doc,by=by,prev_state=prev_state,new_state=new_state))
if not doc.ad == ad :
@ -764,6 +772,16 @@ def edit_info(request, name):
doc.save_with_history(events)
if new_document:
# If we created a new doc, update the action holders as though it
# started in idexists and moved to its create_in_state. Do this
# after the doc has been updated so, e.g., doc.ad is set.
update_action_holders(
doc,
State.objects.get(type='draft-iesg', slug='idexists'),
r['create_in_state']
)
if changes:
email_iesg_processing_document(request, doc, changes)

View file

@ -205,19 +205,32 @@ class GroupPagesTests(TestCase):
group = GroupFactory()
setup_default_community_list_for_group(group)
draft = WgDraftFactory(group=group)
draft.action_holders.set([PersonFactory()])
draft2 = WgDraftFactory(group=group)
draft3 = WgDraftFactory(group=group)
draft3.set_state(State.objects.get(type='draft-iesg', slug='pub-req'))
draft3.action_holders.set(PersonFactory.create_batch(2))
old_dah = draft3.documentactionholder_set.first()
old_dah.time_added -= datetime.timedelta(days=173) # make an "old" action holder
old_dah.save()
clist = CommunityList.objects.get(group=group)
related_docs_rule = clist.searchrule_set.get(rule_type='name_contains')
reset_name_contains_index_for_rule(related_docs_rule)
for url in group_urlreverse_list(group, 'ietf.group.views.group_documents'):
r = self.client.get(url)
with self.settings(DOC_ACTION_HOLDER_MAX_AGE_DAYS=20):
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertContains(r, draft.name)
self.assertContains(r, group.name)
self.assertContains(r, group.acronym)
self.assertNotContains(r, draft.action_holders.first().plain_name())
self.assertContains(r, draft2.name)
self.assertContains(r, draft3.name)
for ah in draft3.action_holders.all():
self.assertContains(r, ah.plain_name())
self.assertContains(r, 'for 173 days', count=1) # the old_dah should be tagged
# 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")

View file

@ -0,0 +1,38 @@
# Copyright The IETF Trust 2020 All Rights Reserved
from django.db import migrations
def forward(apps,schema_editor):
MailTrigger = apps.get_model('mailtrigger', 'MailTrigger')
Recipient = apps.get_model('mailtrigger', 'Recipient')
(doc_action_holders, _) = Recipient.objects.get_or_create(
slug='doc_action_holders',
desc='Action holders for a document',
template='{% for action_holder in doc.action_holders.all %}{% if doc.shepherd and action_holder == doc.shepherd.person %}{{ doc.shepherd }}{% else %}{{ action_holder.email }}{% endif %}{% if not forloop.last %},{%endif %}{% endfor %}',
)
(doc_remind_action_holders, _) = MailTrigger.objects.get_or_create(
slug='doc_remind_action_holders',
desc='Recipients when sending a reminder email to action holders for a document',
)
doc_remind_action_holders.to.set([doc_action_holders])
def reverse(apps,schema_editor):
MailTrigger = apps.get_model('mailtrigger', 'MailTrigger')
Recipient = apps.get_model('mailtrigger', 'Recipient')
MailTrigger.objects.filter(slug='doc_remind_action_holders').delete()
Recipient.objects.filter(slug='doc_action_holders').delete()
class Migration(migrations.Migration):
dependencies = [
('mailtrigger', '0020_add_ad_approval_request_mailtriggers'),
]
operations = [
migrations.RunPython(forward, reverse)
]

View file

@ -3573,6 +3573,17 @@
"model": "mailtrigger.mailtrigger",
"pk": "doc_pulled_from_rfc_queue"
},
{
"fields": {
"cc": [],
"desc": "Recipients when sending a reminder email to action holders for a document",
"to": [
"doc_action_holders"
]
},
"model": "mailtrigger.mailtrigger",
"pk": "doc_remind_action_holders"
},
{
"fields": {
"cc": [],
@ -4999,6 +5010,14 @@
"model": "mailtrigger.recipient",
"pk": "conflict_review_stream_manager"
},
{
"fields": {
"desc": "Action holders for a document",
"template": "{% for action_holder in doc.action_holders.all %}{% if doc.shepherd and action_holder == doc.shepherd.person %}{{ doc.shepherd }}{% else %}{{ action_holder.email }}{% endif %}{% if not forloop.last %},{%endif %}{% endfor %}"
},
"model": "mailtrigger.recipient",
"pk": "doc_action_holders"
},
{
"fields": {
"desc": "The document's responsible Area Director",

View file

@ -16,7 +16,7 @@ import debug # pyflakes:ignore
from ietf.person.models import Email, Person
def select2_id_name_json(objs):
def select2_id_name(objs):
def format_email(e):
return escape("%s <%s>" % (e.person.name, e.address))
def format_person(p):
@ -33,10 +33,13 @@ def select2_id_name_json(objs):
for p in objs:
p.name_count = c[p.name]
formatter = format_email if objs and isinstance(objs[0], Email) else format_person
return [{ "id": o.pk, "text": formatter(o) } for o in objs if o]
def select2_id_name_json(objs):
return json.dumps(select2_id_name(objs))
return json.dumps([{ "id": o.pk, "text": formatter(o) } for o in objs if o])
class SearchablePersonsField(forms.CharField):
"""Server-based multi-select field for choosing
@ -48,12 +51,19 @@ class SearchablePersonsField(forms.CharField):
The field uses a comma-separated list of primary keys in a
CharField element as its API with some extra attributes used by
the Javascript part."""
the Javascript part.
If the field will be programmatically updated, any model instances
that may be added to the initial set should be included in the extra_prefetch
list. These can then be added by updating val() and triggering the 'change'
event on the select2 field in JavaScript.
"""
def __init__(self,
max_entries=None, # max number of selected objs
only_users=False, # only select persons who also have a user
all_emails=False, # select only active email addresses
extra_prefetch=None, # extra data records to include in prefetch
model=Person, # or Email
hint_text="Type in name to search for person.",
*args, **kwargs):
@ -70,6 +80,9 @@ class SearchablePersonsField(forms.CharField):
self.widget.attrs["data-placeholder"] = hint_text
if self.max_entries != None:
self.widget.attrs["data-max-entries"] = self.max_entries
self.extra_prefetch = extra_prefetch or []
assert all([isinstance(obj, self.model) for obj in self.extra_prefetch])
def parse_select2_value(self, value):
return [x.strip() for x in value.split(",") if x.strip()]
@ -96,7 +109,12 @@ class SearchablePersonsField(forms.CharField):
if isinstance(value, self.model):
value = [value]
self.widget.attrs["data-pre"] = select2_id_name_json(value)
# data-pre is a map from ID to full data. It includes records needed by the
# initial value of the field plus any added via extra_prefetch.
prefetch_set = set(value).union(set(self.extra_prefetch)) # eliminate duplicates
self.widget.attrs["data-pre"] = json.dumps({
d['id']: d for d in select2_id_name(list(prefetch_set))
})
# doing this in the constructor is difficult because the URL
# patterns may not have been fully constructed there yet

View file

@ -10,11 +10,12 @@ import debug # pyflakes:ignore
from django.urls import reverse
from ietf.doc.factories import WgDraftFactory, IndividualRfcFactory, CharterFactory
from ietf.doc.models import BallotDocEvent, BallotType, BallotPositionDocEvent
from ietf.doc.models import BallotDocEvent, BallotType, BallotPositionDocEvent, State, Document
from ietf.doc.utils import update_telechat, create_ballot_if_not_open
from ietf.utils.test_utils import TestCase
from ietf.iesg.models import TelechatDate
from ietf.person.models import Person
from ietf.person.factories import PersonFactory
from ietf.secr.telechat.views import get_next_telechat_date
SECR_USER='secretary'
@ -180,3 +181,62 @@ class SecrTelechatTestCase(TestCase):
)
self.assertEqual(response.status_code,302)
self.assertEqual(charter.get_state('charter').slug,'notrev')
def test_doc_detail_post_update_state_action_holder_automation(self):
"""Updating IESG state of a draft should update action holders"""
by = Person.objects.get(name='(System)')
draft = WgDraftFactory(
states=[('draft-iesg', 'iesg-eva')],
ad=Person.objects.get(user__username='ad'),
authors=PersonFactory.create_batch(3),
)
last_week = datetime.date.today()-datetime.timedelta(days=7)
BallotDocEvent.objects.create(type='created_ballot',by=by,doc=draft, rev=draft.rev,
ballot_type=BallotType.objects.get(doc_type=draft.type,slug='approve'),
time=last_week)
d = get_next_telechat_date()
date = d.strftime('%Y-%m-%d')
update_telechat(None, draft, by, d)
url = reverse('ietf.secr.telechat.views.doc_detail', kwargs={'date':date, 'name':draft.name})
self.client.login(username="secretary", password="secretary+password")
# Check that there are no action holder DocEvents yet
self.assertEqual(draft.docevent_set.filter(type='changed_action_holders').count(), 0)
# setting to defer should add AD, adding need-rev should add authors
response = self.client.post(url,{
'submit': 'update_state',
'state': State.objects.get(type_id='draft-iesg', slug='defer').pk,
'substate': 'need-rev',
})
self.assertEqual(response.status_code,302)
draft = Document.objects.get(name=draft.name)
self.assertEqual(draft.get_state('draft-iesg').slug,'defer')
self.assertCountEqual(draft.action_holders.all(), [draft.ad] + draft.authors())
self.assertEqual(draft.docevent_set.filter(type='changed_action_holders').count(), 1)
# Removing need-rev should remove authors
response = self.client.post(url,{
'submit': 'update_state',
'state': State.objects.get(type_id='draft-iesg', slug='iesg-eva').pk,
'substate': '',
})
self.assertEqual(response.status_code,302)
draft = Document.objects.get(name=draft.name)
self.assertEqual(draft.get_state('draft-iesg').slug,'iesg-eva')
self.assertCountEqual(draft.action_holders.all(), [draft.ad])
self.assertEqual(draft.docevent_set.filter(type='changed_action_holders').count(), 2)
# Setting to approved should remove all action holders
# noinspection DjangoOrm
draft.action_holders.add(*(draft.authors())) # add() with through model ok in Django 2.2+
response = self.client.post(url,{
'submit': 'update_state',
'state': State.objects.get(type_id='draft-iesg', slug='approved').pk,
'substate': '',
})
self.assertEqual(response.status_code,302)
draft = Document.objects.get(name=draft.name)
self.assertEqual(draft.get_state('draft-iesg').slug,'approved')
self.assertCountEqual(draft.action_holders.all(), [])
self.assertEqual(draft.docevent_set.filter(type='changed_action_holders').count(), 3)

View file

@ -12,7 +12,7 @@ from django.utils.functional import curry
import debug # pyflakes:ignore
from ietf.doc.models import DocEvent, Document, BallotDocEvent, BallotPositionDocEvent, BallotType, WriteupDocEvent
from ietf.doc.utils import add_state_change_event
from ietf.doc.utils import add_state_change_event, update_action_holders
from ietf.person.models import Person
from ietf.doc.lastcall import request_last_call
from ietf.doc.mails import email_state_changed
@ -284,12 +284,18 @@ def doc_detail(request, date, name):
doc.tags.remove(*prev_tags)
doc.tags.add(*new_tags)
e = add_state_change_event(doc, login, prev_state, new_state,
events = []
sce = add_state_change_event(doc, login, prev_state, new_state,
prev_tags=prev_tags, new_tags=new_tags)
if sce:
events.append(sce)
e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags)
if e:
doc.save_with_history([e])
events.append(e)
if events:
doc.save_with_history(events)
email_state_changed(request, doc, e.desc, 'doc_state_edited')
email_state_changed(request, doc, sce.desc, 'doc_state_edited')
if new_state.slug == "lc-req":
request_last_call(request, doc)

View file

@ -705,6 +705,9 @@ DOC_HREFS = {
# e.g. a charter or a review. Must be a tuple, not a list.
DOC_TEXT_FILE_VALID_UPLOAD_MIME_TYPES = ('text/plain', 'text/markdown', 'text/x-rst', 'text/x-markdown', )
# Age limit before action holders are flagged in the document display
DOC_ACTION_HOLDER_AGE_LIMIT_DAYS = 20
# Override this in settings_local.py if needed
CACHE_MIDDLEWARE_SECONDS = 300
CACHE_MIDDLEWARE_KEY_PREFIX = ''

View file

@ -6,7 +6,7 @@ function setupSelect2Field(e) {
return;
var maxEntries = e.data("max-entries");
var multiple = maxEntries != 1;
var multiple = maxEntries !== 1;
var prefetched = e.data("pre");
e.select2({
multiple: multiple,
@ -27,7 +27,7 @@ function setupSelect2Field(e) {
results: function (results) {
return {
results: results,
more: results.length == 10
more: results.length === 10
};
}
},
@ -35,11 +35,27 @@ function setupSelect2Field(e) {
return m;
},
initSelection: function (element, cb) {
if (!multiple && prefetched.length > 0)
cb(prefetched[0]);
else
cb(prefetched);
element = $(element); // jquerify
// The original data set will contain any values looked up via ajax
var data = element.select2('data');
var data_map = {};
// map id to its data representation
for (var ii = 0; ii < data.length; ii++) {
var this_item = data[ii];
data_map[this_item.id] = this_item;
}
// convert values to data objects, letting element data supersede prefetch
var ids = element.val().split(',');
if (!multiple && ids.length > 0) {
cb(data_map[ids[0]] || prefetched[ids[0]]);
} else {
cb(ids.map(function(id) {
return data_map[id] || prefetched[id];
}));
}
},
dropdownCssClass: "bigdrop"
});

View file

@ -18,7 +18,7 @@ import debug # pyflakes:ignore
from ietf.doc.models import ( Document, DocAlias, State, StateType, DocEvent, DocRelationshipName,
DocTagName, DocTypeName, RelatedDocument )
from ietf.doc.expire import move_draft_files_to_archive
from ietf.doc.utils import add_state_change_event, prettify_std_name
from ietf.doc.utils import add_state_change_event, prettify_std_name, update_action_holders
from ietf.group.models import Group
from ietf.name.models import StdLevelName, StreamName
from ietf.person.models import Person
@ -186,6 +186,9 @@ def update_drafts_from_queue(drafts):
d.set_state(next_iesg_state)
e = add_state_change_event(d, system, prev_iesg_state, next_iesg_state)
if e:
events.append(e)
e = update_action_holders(d, prev_iesg_state, next_iesg_state)
if e:
events.append(e)
changed.add(name)
@ -457,12 +460,16 @@ def update_docs_from_rfc_index(index_data, errata_data, skip_older_than_date=Non
rfc_published = True
for t in ("draft-iesg", "draft-stream-iab", "draft-stream-irtf", "draft-stream-ise"):
slug = doc.get_state_slug(t)
if slug and slug not in ("pub", "idexists"):
new_state = State.objects.select_related("type").get(used=True, type=t, slug="pub")
doc.set_state(new_state)
changes.append("changed %s to %s" % (new_state.type.label, new_state))
if t == 'draft-iesg' and not slug:
prev_state = doc.get_state(t)
if prev_state is not None:
if prev_state.slug not in ("pub", "idexists"):
new_state = State.objects.select_related("type").get(used=True, type=t, slug="pub")
doc.set_state(new_state)
changes.append("changed %s to %s" % (new_state.type.label, new_state))
e = update_action_holders(doc, prev_state, new_state)
if e:
events.append(e)
elif t == 'draft-iesg':
doc.set_state(State.objects.get(type_id='draft-iesg', slug='idexists'))
def parse_relation_list(l):

View file

@ -239,9 +239,14 @@ class RFCSyncTests(TestCase):
def test_rfc_index(self):
area = GroupFactory(type_id='area')
doc = WgDraftFactory(group__parent=area,states=[('draft-iesg','rfcqueue'),('draft-stream-ise','rfc-edit')])
doc = WgDraftFactory(
group__parent=area,
states=[('draft-iesg','rfcqueue'),('draft-stream-ise','rfc-edit')],
ad=Person.objects.get(user__username='ad'),
)
# it's a bit strange to have draft-stream-ise set when draft-iesg is set
# too, but for testing purposes ...
doc.action_holders.add(doc.ad) # not normally set, but add to be sure it's cleared
updated_doc = Document.objects.create(name="draft-ietf-something")
DocAlias.objects.create(name=updated_doc.name).docs.add(updated_doc)
@ -358,9 +363,11 @@ class RFCSyncTests(TestCase):
doc = Document.objects.get(name=doc.name)
self.assertEqual(doc.docevent_set.all()[0].type, "sync_from_rfc_editor")
self.assertEqual(doc.docevent_set.all()[1].type, "published_rfc")
self.assertEqual(doc.docevent_set.all()[1].time.date(), today)
events = doc.docevent_set.all()
self.assertEqual(events[0].type, "sync_from_rfc_editor")
self.assertEqual(events[1].type, "changed_action_holders")
self.assertEqual(events[2].type, "published_rfc")
self.assertEqual(events[2].time.date(), today)
self.assertTrue("errata" in doc.tags.all().values_list("slug", flat=True))
self.assertTrue(DocAlias.objects.filter(name="rfc1234", docs=doc))
self.assertTrue(DocAlias.objects.filter(name="bcp1", docs=doc))
@ -371,6 +378,7 @@ class RFCSyncTests(TestCase):
self.assertEqual(doc.abstract, "This is some interesting text.")
self.assertEqual(doc.get_state_slug(), "rfc")
self.assertEqual(doc.get_state_slug("draft-iesg"), "pub")
self.assertCountEqual(doc.action_holders.all(), [])
self.assertEqual(doc.get_state_slug("draft-stream-ise"), "pub")
self.assertEqual(doc.std_level_id, "ps")
self.assertEqual(doc.pages, 42)
@ -413,7 +421,9 @@ class RFCSyncTests(TestCase):
return t
def test_rfc_queue(self):
draft = WgDraftFactory(states=[('draft-iesg','ann')])
draft = WgDraftFactory(states=[('draft-iesg','ann')], ad=Person.objects.get(user__username='ad'))
draft.action_holders.add(draft.ad) # add an action holder so we can test that it's removed later
expected_auth48_url = "http://www.rfc-editor.org/auth48/rfc1234"
t = self._generate_rfc_queue_xml(draft,
state='EDIT*R*A(1G)',
@ -433,10 +443,13 @@ class RFCSyncTests(TestCase):
draft = Document.objects.get(pk=draft.pk)
self.assertEqual(draft.get_state_slug("draft-rfceditor"), "edit")
self.assertEqual(draft.get_state_slug("draft-iesg"), "rfcqueue")
self.assertCountEqual(draft.action_holders.all(), [])
self.assertEqual(set(draft.tags.all()), set(DocTagName.objects.filter(slug__in=("iana", "ref"))))
self.assertEqual(draft.docevent_set.all()[0].type, "changed_state") # changed draft-iesg state
self.assertEqual(draft.docevent_set.all()[1].type, "changed_state") # changed draft-rfceditor state
self.assertEqual(draft.docevent_set.all()[2].type, "rfc_editor_received_announcement")
events = draft.docevent_set.all()
self.assertEqual(events[0].type, "changed_state") # changed draft-iesg state
self.assertEqual(events[1].type, "changed_action_holders")
self.assertEqual(events[2].type, "changed_state") # changed draft-rfceditor state
self.assertEqual(events[3].type, "rfc_editor_received_announcement")
self.assertEqual(len(outbox), mailbox_before + 1)
self.assertTrue("RFC Editor queue" in outbox[-1]["Subject"])

View file

@ -457,6 +457,29 @@
</td>
</tr>
{% if doc.action_holders_enabled %}
<tr>
<th></th>
<th>Action Holders</th>
<td class="edit">
{% if can_edit %}
<a class="btn btn-default btn-xs" href="{% url 'ietf.doc.views_doc.edit_action_holders' name=doc.name %}">Edit</a>
{% endif %}
</td>
<td>
<div>
{% if doc.action_holders.exists %}
{% for action_holder in doc.documentactionholder_set.all %}
<div>{% person_link action_holder.person title=action_holder.role_for_doc %} {{ action_holder|action_holder_badge }}</div>
{% endfor %}
{% if can_edit %}<a class="btn btn-default btn-xs" href="{% url "ietf.doc.views_doc.remind_action_holders" name=doc.name %}"><span class="fa fa-envelope-o"></span> Send reminder email </a>{% endif %}
{% else %}
(None)
{% endif %}
</div>
</td>
{% endif %}
{% if consensus and doc.stream_id == 'ietf' %}
<tr>
<th></th>

View file

@ -2,7 +2,7 @@
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load ietf_filters static %}
{% load textfilters %}
{% load textfilters person_filters %}
{% block pagehead %}
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
@ -43,6 +43,12 @@
<td>
<a href="{% url "ietf.doc.views_doc.document_main" doc.name %}">{{ doc.name }}</a>
<br><b>{{ doc.title }}</b>
{% if doc.action_holders_enabled and doc.action_holders.exists %}
<br>Action holders:
{% for action_holder in doc.documentactionholder_set.all %}
{% person_link action_holder.person title=action_holder.role_for_doc %}{{ action_holder|action_holder_badge }}{% if not forloop.last %},{% endif %}
{% endfor %}
{% endif %}
{% if doc.note %}
<br><i>Note: {{ doc.note|linkify|linebreaksbr }}</i>
{% endif %}

View file

@ -0,0 +1,138 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2020, All Rights Reserved #}
{% load origin %}
{% load static %}
{% load bootstrap3 %}
{% block title %}
Edit action holders for {{ titletext }}
{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{% endblock %}
{% block content %}
{% origin %}
<h1>Edit action holders<br><small>{{titletext}}</small></h1>
<form enctype="multipart/form-data" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group">
<label for="role-toolbar">Related people</label>
<div class="btn-toolbar" role="toolbar" id="role-toolbar-{{ role_type_label|slugify }}">
{% for doc_role in role_labels %}
<div class="btn-group-vertical btn-group-sm" role="group">
<button type="button" class="btn btn-default"
id="add-{{ doc_role.slug }}"
onclick="local_js.add_ah('{{ doc_role.slug }}')">
Add {{ doc_role.label }}
</button>
<button type="button" class="btn btn-default"
id="del-{{ doc_role.slug }}"
onclick="local_js.del_ah('{{ doc_role.slug }}')">
Remove {{ doc_role.label }}
</button>
</div>
{% endfor %}
</div>
</div>
{% buttons %}
<button type="submit" class="btn btn-primary" name="submit" value="Save">Submit</button>
<a class="btn btn-default pull-right" href="{% url "ietf.doc.views_doc.document_main" name=doc.canonical_name %}">Back</a>
{% endbuttons %}
</form>
{% endblock %}
{% block js %}
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
<script type="text/javascript">
local_js = function () {
let select2_elem = $('.select2-field');
let role_ids = select2_elem.data('role-ids');
/* Updates select2 selection in element elem. Data should be an array of
* objects with id and text as keys. */
function update_selection(elem, entries) {
elem.val(entries.join(',')).trigger('change');
}
function add_ah(role) {
if (role_ids[role]) {
let ids;
if (select2_elem.val()) {
ids = select2_elem.val().split(',').map(Number).concat(role_ids[role]);
} else {
ids = role_ids[role];
}
update_selection(select2_elem, ids);
}
}
function del_ah(role) {
if (role_ids[role] && select2_elem.val()) {
update_selection(select2_elem, select2_elem.val().split(',').filter(
function(id){return -1 === role_ids[role].indexOf(Number(id))}
));
}
}
function all_selected(elem, role) {
if (!elem.val()) {return false}
let data_ids = elem.val().split(',').map(Number);
for (let ii=0; ii < role_ids[role].length; ii++) {
if (-1 === data_ids.indexOf(role_ids[role][ii])) {
return false;
}
}
return true;
}
function none_selected(elem, role) {
if (!elem.val()) {return true}
let data_ids = elem.val().split(',').map(Number);
for (let ii=0; ii < role_ids[role].length; ii++) {
if (-1 !== data_ids.indexOf(role_ids[role][ii])) {
return false;
}
}
return true;
}
function update_buttons() {
for (let role_slug in role_ids) {
if (!role_ids.hasOwnProperty(role_slug)) { return };
if (all_selected(select2_elem, role_slug)) {
$('#add-' + role_slug).attr('disabled', true);
} else {
$('#add-' + role_slug).attr('disabled', false);
}
if (none_selected(select2_elem, role_slug)) {
$('#del-' + role_slug).attr('disabled', true);
} else {
$('#del-' + role_slug).attr('disabled', false);
}
}
}
select2_elem.on('change', update_buttons);
$(document).ready(update_buttons);
return {
add_ah: add_ah, del_ah: del_ah
};
}();
</script>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% autoescape off %}
Reminder: your action is needed to allow the publication process for
{{ doc.display_name }}
to proceed. This document can be found at
{{ doc_url }}
{% if note %}
Please note:
{{ note|wordwrap:78 }}
{% endif %}
{% endautoescape %}

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2020, All Rights Reserved #}
{% load origin %}
{% load static %}
{% load bootstrap3 %}
{% load person_filters %}
{% block title %}
Send reminder to action holders for {{ titletext }}
{% endblock %}
{% block content %}
{% origin %}
<h1>Send reminder to action holders<br><small>{{ titletext }}</small></h1>
<form enctype="multipart/form-data" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<p>This reminder will be sent to
{% for person in doc.action_holders.all %}
{% if forloop.last and not forloop.first %} and {% endif %}{% person_link person %}{% if not forloop.last %}, {% endif %}{% endfor %}.</p>
{% buttons %}
<button type="submit" class="btn btn-primary" name="submit" value="Send reminder">Send</button>
<a class="btn btn-default pull-right" href="{% url "ietf.doc.views_doc.document_main" name=doc.canonical_name %}">Cancel</a>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -1,5 +1,5 @@
{# Copyright The IETF Trust 2015, All Rights Reserved #}{% load origin %}{% origin %}
{% load ietf_filters ballot_icon %}
{% load ietf_filters ballot_icon person_filters %}
<td class="status">
<div class="pull-right" id="ballot-icon-{{doc.name}}">
@ -61,6 +61,12 @@
<span title="Part of {{ m.group.acronym }} milestone: {{ m.desc }}" class="milestone">{{ m.due|date:"M Y" }}</span>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% if doc.action_holders_enabled and doc.action_holders.exists %}
<br>Action Holders:
{% for action_holder in doc.documentactionholder_set.all %}
<wbr>{% person_link action_holder.person title=action_holder.role_for_doc %}{{ action_holder|action_holder_badge }}{% if not forloop.last %}, {% endif %}
{% endfor %}
{% endif %}
{% else %}{# RFC #}
{{ doc.std_level|safe }} RFC