Skip to content

Commit 11551c8

Browse files
committed
Merged in [18989] from jennifer@painless-security.com:
Allow secretariat to edit document author list. Fixes ietf-tools#3185. - Legacy-Id: 19004 Note: SVN reference [18989] has been migrated to Git commit 6cf9eb8
2 parents 59483d6 + c09cd8b commit 11551c8

15 files changed

Lines changed: 1001 additions & 30 deletions

File tree

ietf/doc/factories.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,9 @@ class Meta:
373373
document = factory.SubFactory(DocumentFactory)
374374
person = factory.SubFactory('ietf.person.factories.PersonFactory')
375375
email = factory.LazyAttribute(lambda obj: obj.person.email())
376+
affiliation = factory.Faker('company')
377+
country = factory.Faker('country')
378+
order = factory.LazyAttribute(lambda o: o.document.documentauthor_set.count() + 1)
376379

377380
class WgDocumentAuthorFactory(DocumentAuthorFactory):
378381
document = factory.SubFactory(WgDraftFactory)

ietf/doc/forms.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
from ietf.doc.models import RelatedDocument, DocExtResource
1212
from ietf.iesg.models import TelechatDate
1313
from ietf.iesg.utils import telechat_page_count
14-
from ietf.person.fields import SearchablePersonsField
14+
from ietf.person.fields import SearchablePersonField, SearchablePersonsField
15+
from ietf.person.models import Email, Person
1516

1617
from ietf.name.models import ExtResourceName
1718
from ietf.utils.validators import validate_external_resource_value
@@ -37,8 +38,28 @@ def __init__(self, *args, **kwargs):
3738
choice_display[d] += ' : WARNING - this may not leave enough time for directorate reviews!'
3839
self.fields['telechat_date'].choices = [("", "(not on agenda)")] + [(d, choice_display[d]) for d in dates]
3940

40-
from ietf.person.models import Person
4141

42+
class DocAuthorForm(forms.Form):
43+
person = SearchablePersonField()
44+
email = forms.ModelChoiceField(queryset=Email.objects.none(), required=False)
45+
affiliation = forms.CharField(max_length=100, required=False)
46+
country = forms.CharField(max_length=255, required=False)
47+
48+
def __init__(self, *args, **kwargs):
49+
super(DocAuthorForm, self).__init__(*args, **kwargs)
50+
51+
person = self.data.get(
52+
self.add_prefix('person'),
53+
self.get_initial_for_field(self.fields['person'], 'person')
54+
)
55+
if person:
56+
self.fields['email'].queryset = Email.objects.filter(person=person)
57+
58+
class DocAuthorChangeBasisForm(forms.Form):
59+
basis = forms.CharField(max_length=255,
60+
label='Reason for change',
61+
help_text='What is the source or reasoning for the changes to the author list?')
62+
4263
class AdForm(forms.Form):
4364
ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active", role__group__type='area').order_by('name'),
4465
label="Shepherding AD", empty_label="(None)", required=True)

ietf/doc/tests.py

Lines changed: 446 additions & 3 deletions
Large diffs are not rendered by default.

ietf/doc/tests_js.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Copyright The IETF Trust 2021, All Rights Reserved
2+
# -*- coding: utf-8 -*-
3+
4+
import debug # pyflakes:ignore
5+
6+
from ietf.doc.factories import WgDraftFactory, DocumentAuthorFactory
7+
from ietf.person.factories import PersonFactory
8+
from ietf.person.models import Person
9+
from ietf.utils.jstest import IetfSeleniumTestCase, ifSeleniumEnabled, selenium_enabled
10+
11+
if selenium_enabled():
12+
from selenium.webdriver.common.by import By
13+
from selenium.webdriver.support.ui import WebDriverWait
14+
from selenium.webdriver.support import expected_conditions
15+
16+
17+
class presence_of_element_child_by_css_selector:
18+
"""Wait for presence of a child of a WebElement matching a CSS selector
19+
20+
This is a condition class for use with WebDriverWait.
21+
"""
22+
def __init__(self, element, child_selector):
23+
self.element = element
24+
self.child_selector = child_selector
25+
26+
def __call__(self, driver):
27+
child = self.element.find_element_by_css_selector(self.child_selector)
28+
return child if child is not None else False
29+
30+
@ifSeleniumEnabled
31+
class EditAuthorsTests(IetfSeleniumTestCase):
32+
def setUp(self):
33+
super(EditAuthorsTests, self).setUp()
34+
self.wait = WebDriverWait(self.driver, 2)
35+
36+
def test_add_author_forms(self):
37+
def _fill_in_author_form(form_elt, name, email, affiliation, country):
38+
"""Fill in an author form on the edit authors page
39+
40+
The form_elt input should be an element containing all the relevant inputs.
41+
"""
42+
# To enter the person, type their name in the select2 search box, wait for the
43+
# search to offer the result, then press 'enter' to accept the result and close
44+
# the search input.
45+
person_span = form_elt.find_element_by_class_name('select2-chosen')
46+
self.scroll_to_element(person_span)
47+
person_span.click()
48+
input = self.driver.switch_to.active_element
49+
input.send_keys(name)
50+
result_selector = 'ul.select2-results > li > div.select2-result-label'
51+
self.wait.until(
52+
expected_conditions.text_to_be_present_in_element(
53+
(By.CSS_SELECTOR, result_selector),
54+
name
55+
))
56+
input.send_keys('\n') # select the object
57+
58+
# After the author is selected, the email select options will be populated.
59+
# Wait for that, then click on the option corresponding to the requested email.
60+
# This will only work if the email matches an address for the selected person.
61+
email_select = form_elt.find_element_by_css_selector('select[name$="email"]')
62+
email_option = self.wait.until(
63+
presence_of_element_child_by_css_selector(email_select, 'option[value="{}"]'.format(email))
64+
)
65+
email_option.click() # select the email
66+
67+
# Fill in the affiliation and country. Finally, simple text inputs!
68+
affil_input = form_elt.find_element_by_css_selector('input[name$="affiliation"]')
69+
affil_input.send_keys(affiliation)
70+
country_input = form_elt.find_element_by_css_selector('input[name$="country"]')
71+
country_input.send_keys(country)
72+
73+
def _read_author_form(form_elt):
74+
"""Read values from an author form
75+
76+
Note: returns the Person instance named in the person field, not just their name.
77+
"""
78+
hidden_person_input = form_elt.find_element_by_css_selector('input[type="text"][name$="person"]')
79+
email_select = form_elt.find_element_by_css_selector('select[name$="email"]')
80+
affil_input = form_elt.find_element_by_css_selector('input[name$="affiliation"]')
81+
country_input = form_elt.find_element_by_css_selector('input[name$="country"]')
82+
return (
83+
Person.objects.get(pk=hidden_person_input.get_attribute('value')),
84+
email_select.get_attribute('value'),
85+
affil_input.get_attribute('value'),
86+
country_input.get_attribute('value'),
87+
)
88+
89+
# Create testing resources
90+
draft = WgDraftFactory()
91+
DocumentAuthorFactory(document=draft)
92+
authors = PersonFactory.create_batch(2) # authors we will add
93+
orgs = ['some org', 'some other org'] # affiliations for the authors
94+
countries = ['France', 'Uganda'] # countries for the authors
95+
url = self.absreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
96+
97+
# Star the test by logging in with appropriate permissions and retrieving the edit page
98+
self.login('secretary')
99+
self.driver.get(url)
100+
101+
# The draft has one author to start with. Find the list and check the count.
102+
authors_list = self.driver.find_element_by_id('authors-list')
103+
author_forms = authors_list.find_elements_by_class_name('author-panel')
104+
self.assertEqual(len(author_forms), 1)
105+
106+
# get the "add author" button so we can add blank author forms
107+
add_author_button = self.driver.find_element_by_id('add-author-button')
108+
for index, auth in enumerate(authors):
109+
self.scroll_to_element(add_author_button) # Can only click if it's in view!
110+
add_author_button.click() # Create a new form. Automatically scrolls to it.
111+
author_forms = authors_list.find_elements_by_class_name('author-panel')
112+
authors_added = index + 1
113+
self.assertEqual(len(author_forms), authors_added + 1) # Started with 1 author, hence +1
114+
_fill_in_author_form(author_forms[index + 1], auth.name, str(auth.email()), orgs[index], countries[index])
115+
116+
# Check that the author forms have correct (and distinct) values
117+
first_auth = draft.documentauthor_set.first()
118+
self.assertEqual(
119+
_read_author_form(author_forms[0]),
120+
(first_auth.person, str(first_auth.email), first_auth.affiliation, first_auth.country),
121+
)
122+
for index, auth in enumerate(authors):
123+
self.assertEqual(
124+
_read_author_form(author_forms[index + 1]),
125+
(auth, str(auth.email()), orgs[index], countries[index]),
126+
)
127+
128+
# Must provide a "basis" (change reason)
129+
self.driver.find_element_by_id('id_basis').send_keys('change testing')
130+
# Now click the 'submit' button and check that the update was accepted.
131+
submit_button = self.driver.find_element_by_css_selector('button[type="submit"]')
132+
self.scroll_to_element(submit_button)
133+
submit_button.click()
134+
# Wait for redirect to the document_main view
135+
self.wait.until(
136+
expected_conditions.url_to_be(
137+
self.absreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=draft.name))
138+
))
139+
# Just a basic check that the expected authors show up. Details of the updates
140+
# are tested separately.
141+
self.assertEqual(
142+
list(draft.documentauthor_set.values_list('person', flat=True)),
143+
[first_auth.person.pk] + [auth.pk for auth in authors]
144+
)

ietf/doc/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
url(r'^%(name)s/edit/telechat/$' % settings.URL_REGEXPS, views_doc.telechat_date),
114114
url(r'^%(name)s/edit/iesgnote/$' % settings.URL_REGEXPS, views_draft.edit_iesg_note),
115115
url(r'^%(name)s/edit/ad/$' % settings.URL_REGEXPS, views_draft.edit_ad),
116+
url(r'^%(name)s/edit/authors/$' % settings.URL_REGEXPS, views_doc.edit_authors),
116117
url(r'^%(name)s/edit/consensus/$' % settings.URL_REGEXPS, views_draft.edit_consensus),
117118
url(r'^%(name)s/edit/shepherd/$' % settings.URL_REGEXPS, views_draft.edit_shepherd),
118119
url(r'^%(name)s/edit/shepherdemail/$' % settings.URL_REGEXPS, views_draft.change_shepherd_email),

ietf/doc/utils.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ietf.doc.models import Document, DocHistory, State, DocumentAuthor, DocHistoryAuthor
2727
from ietf.doc.models import DocAlias, RelatedDocument, RelatedDocHistory, BallotType, DocReminder
2828
from ietf.doc.models import DocEvent, ConsensusDocEvent, BallotDocEvent, IRSGBallotDocEvent, NewRevisionDocEvent, StateDocEvent
29-
from ietf.doc.models import TelechatDocEvent, DocumentActionHolder
29+
from ietf.doc.models import TelechatDocEvent, DocumentActionHolder, EditedAuthorsDocEvent
3030
from ietf.name.models import DocReminderTypeName, DocRelationshipName
3131
from ietf.group.models import Role, Group
3232
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, is_individual_draft_author
@@ -517,6 +517,82 @@ def update_action_holders(doc, prev_state=None, new_state=None, prev_tags=None,
517517
)
518518

519519

520+
def update_documentauthors(doc, new_docauthors, by=None, basis=None):
521+
"""Update the list of authors for a document
522+
523+
Returns an iterable of events describing the change. These must be saved by the caller if
524+
they are to be kept.
525+
526+
The new_docauthors argument should be an iterable containing objects that
527+
have person, email, affiliation, and country attributes. An easy way to create
528+
these objects is to use DocumentAuthor(), but e.g., a named tuple could be
529+
used. These objects will not be saved, their attributes will be used to create new
530+
DocumentAuthor instances. (The document and order fields will be ignored.)
531+
"""
532+
def _change_field_and_describe(auth, field, newval):
533+
# make the change
534+
oldval = getattr(auth, field)
535+
setattr(auth, field, newval)
536+
537+
was_empty = oldval is None or len(str(oldval)) == 0
538+
now_empty = newval is None or len(str(oldval)) == 0
539+
540+
# describe the change
541+
if oldval == newval:
542+
return None
543+
else:
544+
if was_empty and not now_empty:
545+
return 'set {field} to "{new}"'.format(field=field, new=newval)
546+
elif now_empty and not was_empty:
547+
return 'cleared {field} (was "{old}")'.format(field=field, old=oldval)
548+
else:
549+
return 'changed {field} from "{old}" to "{new}"'.format(
550+
field=field, old=oldval, new=newval
551+
)
552+
553+
persons = []
554+
changes = [] # list of change descriptions
555+
556+
for order, docauthor in enumerate(new_docauthors):
557+
# If an existing DocumentAuthor matches, use that
558+
auth = doc.documentauthor_set.filter(person=docauthor.person).first()
559+
is_new_auth = auth is None
560+
if is_new_auth:
561+
# None exists, so create a new one (do not just use docauthor here because that
562+
# will modify the input and might cause side effects)
563+
auth = DocumentAuthor(document=doc, person=docauthor.person)
564+
changes.append('Added "{name}" as author'.format(name=auth.person.name))
565+
566+
author_changes = []
567+
# Now fill in other author details
568+
author_changes.append(_change_field_and_describe(auth, 'email', docauthor.email))
569+
author_changes.append(_change_field_and_describe(auth, 'affiliation', docauthor.affiliation))
570+
author_changes.append(_change_field_and_describe(auth, 'country', docauthor.country))
571+
author_changes.append(_change_field_and_describe(auth, 'order', order + 1))
572+
auth.save()
573+
log.assertion('auth.email_id != "none"')
574+
persons.append(docauthor.person)
575+
if not is_new_auth:
576+
all_author_changes = ', '.join([ch for ch in author_changes if ch is not None])
577+
if len(all_author_changes) > 0:
578+
changes.append('Changed author "{name}": {changes}'.format(
579+
name=auth.person.name, changes=all_author_changes
580+
))
581+
582+
# Finally, remove any authors no longer in the list
583+
removed_authors = doc.documentauthor_set.exclude(person__in=persons)
584+
changes.extend(['Removed "{name}" as author'.format(name=auth.person.name)
585+
for auth in removed_authors])
586+
removed_authors.delete()
587+
588+
# Create change events - one event per author added/changed/removed.
589+
# Caller must save these if they want them persisted.
590+
return [
591+
EditedAuthorsDocEvent(
592+
type='edited_authors', by=by, doc=doc, rev=doc.rev, desc=change, basis=basis
593+
) for change in changes
594+
]
595+
520596
def update_reminder(doc, reminder_type_slug, event, due_date):
521597
reminder_type = DocReminderTypeName.objects.get(slug=reminder_type_slug)
522598

0 commit comments

Comments
 (0)