Allow secretariat to edit document author list. Fixes #3185. Commit ready for merge.

- Legacy-Id: 18989
This commit is contained in:
Jennifer Richards 2021-05-11 18:40:28 +00:00
parent a16f8c44d4
commit 6cf9eb8dd1
15 changed files with 1001 additions and 30 deletions

View file

@ -373,6 +373,9 @@ class DocumentAuthorFactory(factory.DjangoModelFactory):
document = factory.SubFactory(DocumentFactory) document = factory.SubFactory(DocumentFactory)
person = factory.SubFactory('ietf.person.factories.PersonFactory') person = factory.SubFactory('ietf.person.factories.PersonFactory')
email = factory.LazyAttribute(lambda obj: obj.person.email()) email = factory.LazyAttribute(lambda obj: obj.person.email())
affiliation = factory.Faker('company')
country = factory.Faker('country')
order = factory.LazyAttribute(lambda o: o.document.documentauthor_set.count() + 1)
class WgDocumentAuthorFactory(DocumentAuthorFactory): class WgDocumentAuthorFactory(DocumentAuthorFactory):
document = factory.SubFactory(WgDraftFactory) document = factory.SubFactory(WgDraftFactory)

View file

@ -11,7 +11,8 @@ from ietf.doc.fields import SearchableDocAliasesField, SearchableDocAliasField
from ietf.doc.models import RelatedDocument, DocExtResource from ietf.doc.models import RelatedDocument, DocExtResource
from ietf.iesg.models import TelechatDate from ietf.iesg.models import TelechatDate
from ietf.iesg.utils import telechat_page_count from ietf.iesg.utils import telechat_page_count
from ietf.person.fields import SearchablePersonsField from ietf.person.fields import SearchablePersonField, SearchablePersonsField
from ietf.person.models import Email, Person
from ietf.name.models import ExtResourceName from ietf.name.models import ExtResourceName
from ietf.utils.validators import validate_external_resource_value from ietf.utils.validators import validate_external_resource_value
@ -37,8 +38,28 @@ class TelechatForm(forms.Form):
choice_display[d] += ' : WARNING - this may not leave enough time for directorate reviews!' choice_display[d] += ' : WARNING - this may not leave enough time for directorate reviews!'
self.fields['telechat_date'].choices = [("", "(not on agenda)")] + [(d, choice_display[d]) for d in dates] self.fields['telechat_date'].choices = [("", "(not on agenda)")] + [(d, choice_display[d]) for d in dates]
from ietf.person.models import Person
class DocAuthorForm(forms.Form):
person = SearchablePersonField()
email = forms.ModelChoiceField(queryset=Email.objects.none(), required=False)
affiliation = forms.CharField(max_length=100, required=False)
country = forms.CharField(max_length=255, required=False)
def __init__(self, *args, **kwargs):
super(DocAuthorForm, self).__init__(*args, **kwargs)
person = self.data.get(
self.add_prefix('person'),
self.get_initial_for_field(self.fields['person'], 'person')
)
if person:
self.fields['email'].queryset = Email.objects.filter(person=person)
class DocAuthorChangeBasisForm(forms.Form):
basis = forms.CharField(max_length=255,
label='Reason for change',
help_text='What is the source or reasoning for the changes to the author list?')
class AdForm(forms.Form): class AdForm(forms.Form):
ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active", role__group__type='area').order_by('name'), ad = forms.ModelChoiceField(Person.objects.filter(role__name="ad", role__group__state="active", role__group__type='area').order_by('name'),
label="Shepherding AD", empty_label="(None)", required=True) label="Shepherding AD", empty_label="(None)", required=True)

View file

@ -10,6 +10,7 @@ import lxml
import bibtexparser import bibtexparser
import mock import mock
import json import json
import copy
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from pyquery import PyQuery from pyquery import PyQuery
@ -27,11 +28,12 @@ from tastypie.test import ResourceTestCaseMixin
import debug # pyflakes:ignore import debug # pyflakes:ignore
from ietf.doc.models import ( Document, DocAlias, DocRelationshipName, RelatedDocument, State, from ietf.doc.models import ( Document, DocAlias, DocRelationshipName, RelatedDocument, State,
DocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent, NewRevisionDocEvent, BallotType ) DocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent, NewRevisionDocEvent, BallotType,
EditedAuthorsDocEvent )
from ietf.doc.factories import ( DocumentFactory, DocEventFactory, CharterFactory, from ietf.doc.factories import ( DocumentFactory, DocEventFactory, CharterFactory,
ConflictReviewFactory, WgDraftFactory, IndividualDraftFactory, WgRfcFactory, ConflictReviewFactory, WgDraftFactory, IndividualDraftFactory, WgRfcFactory,
IndividualRfcFactory, StateDocEventFactory, BallotPositionDocEventFactory, IndividualRfcFactory, StateDocEventFactory, BallotPositionDocEventFactory,
BallotDocEventFactory ) BallotDocEventFactory, DocumentAuthorFactory )
from ietf.doc.fields import SearchableDocumentsField from ietf.doc.fields import SearchableDocumentsField
from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_name from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_name
from ietf.group.models import Group from ietf.group.models import Group
@ -41,7 +43,7 @@ from ietf.meeting.models import Meeting, Session, SessionPresentation, Schedulin
from ietf.meeting.factories import MeetingFactory, SessionFactory from ietf.meeting.factories import MeetingFactory, SessionFactory
from ietf.name.models import SessionStatusName, BallotPositionName from ietf.name.models import SessionStatusName, BallotPositionName
from ietf.person.models import Person from ietf.person.models import Person
from ietf.person.factories import PersonFactory from ietf.person.factories import PersonFactory, EmailFactory
from ietf.utils.mail import outbox from ietf.utils.mail import outbox
from ietf.utils.test_utils import login_testing_unauthorized, unicontent from ietf.utils.test_utils import login_testing_unauthorized, unicontent
from ietf.utils.test_utils import TestCase from ietf.utils.test_utils import TestCase
@ -786,6 +788,447 @@ 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), msg_prefix='Non-WG-like group %s (%s) should not include group type in link' % (group.acronym, group.type),
) )
def login(self, username):
self.client.login(username=username, password=username + '+password')
def test_edit_authors_permissions(self):
"""Only the secretariat may edit authors"""
draft = WgDraftFactory(authors=PersonFactory.create_batch(3))
RoleFactory(group=draft.group, name_id='chair')
RoleFactory(group=draft.group, name_id='ad', person=Person.objects.get(user__username='ad'))
url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
# Relevant users not authorized to edit authors
unauthorized_usernames = [
'plain',
*[author.user.username for author in draft.authors()],
draft.group.get_chair().person.user.username,
'ad'
]
# First, check that only the secretary can even see the edit page.
# Each call checks that currently-logged in user is refused, then logs in as the named user.
for username in unauthorized_usernames:
login_testing_unauthorized(self, username, url)
login_testing_unauthorized(self, 'secretary', url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.client.logout()
# Try to add an author via POST - still only the secretary should be able to do this.
orig_authors = draft.authors()
post_data = self.make_edit_authors_post_data(
basis='permission test',
authors=draft.documentauthor_set.all(),
)
new_auth_person = PersonFactory()
self.add_author_to_edit_authors_post_data(
post_data,
dict(
person=str(new_auth_person.pk),
email=str(new_auth_person.email()),
affiliation='affil',
country='USA',
),
)
for username in unauthorized_usernames:
login_testing_unauthorized(self, username, url, method='post', request_kwargs=dict(data=post_data))
draft = Document.objects.get(pk=draft.pk)
self.assertEqual(draft.authors(), orig_authors) # ensure draft author list was not modified
login_testing_unauthorized(self, 'secretary', url, method='post', request_kwargs=dict(data=post_data))
r = self.client.post(url, post_data)
self.assertEqual(r.status_code, 302)
draft = Document.objects.get(pk=draft.pk)
self.assertEqual(draft.authors(), orig_authors + [new_auth_person])
def make_edit_authors_post_data(self, basis, authors):
"""Helper to generate edit_authors POST data for a set of authors"""
def _add_prefix(s):
# The prefix here needs to match the formset prefix in the edit_authors() view
return 'author-{}'.format(s)
data = {
'basis': basis,
# management form
_add_prefix('TOTAL_FORMS'): '1', # just the empty form so far
_add_prefix('INITIAL_FORMS'): str(len(authors)),
_add_prefix('MIN_NUM_FORMS'): '0',
_add_prefix('MAX_NUM_FORMS'): '1000',
# empty form
_add_prefix('__prefix__-person'): '',
_add_prefix('__prefix__-email'): '',
_add_prefix('__prefix__-affiliation'): '',
_add_prefix('__prefix__-country'): '',
_add_prefix('__prefix__-ORDER'): '',
}
for index, auth in enumerate(authors):
self.add_author_to_edit_authors_post_data(
data,
dict(
person=str(auth.person.pk),
email=auth.email,
affiliation=auth.affiliation,
country=auth.country
)
)
return data
def add_author_to_edit_authors_post_data(self, post_data, new_author, insert_order=-1, prefix='author'):
"""Helper to insert an author in the POST data for the edit_authors view
The insert_order parameter is 0-indexed (i.e., it's the Django formset ORDER field, not the
DocumentAuthor order property, which is 1-indexed)
"""
def _add_prefix(s):
return '{}-{}'.format(prefix, s)
total_forms = int(post_data[_add_prefix('TOTAL_FORMS')]) - 1 # subtract 1 for empty form
if insert_order < 0:
insert_order = total_forms
else:
# Make a map from order to the data key that has that order value
order_key = dict()
for order in range(insert_order, total_forms):
key = _add_prefix(str(order) + '-ORDER')
order_key[int(post_data[key])] = key
# now increment all orders at or above where new element will be inserted
for order in range(insert_order, total_forms):
post_data[order_key[order]] = str(order + 1)
form_index = total_forms # regardless of insert order, new data has next unused form index
total_forms += 1 # new form
post_data[_add_prefix('TOTAL_FORMS')] = total_forms + 1 # add 1 for empty form
for prop in ['person', 'email', 'affiliation', 'country']:
post_data[_add_prefix(str(form_index) + '-' + prop)] = str(new_author[prop])
post_data[_add_prefix(str(form_index) + '-ORDER')] = str(insert_order)
def test_edit_authors_missing_basis(self):
draft = WgDraftFactory()
DocumentAuthorFactory.create_batch(3, document=draft)
url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
self.login('secretary')
post_data = self.make_edit_authors_post_data(
authors = draft.documentauthor_set.all(),
basis='delete me'
)
post_data.pop('basis')
r = self.client.post(url, post_data)
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'This field is required.')
def test_edit_authors_no_change(self):
draft = WgDraftFactory()
DocumentAuthorFactory.create_batch(3, document=draft)
url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
change_reason = 'no change'
before = list(draft.documentauthor_set.values('person', 'email', 'affiliation', 'country', 'order'))
post_data = self.make_edit_authors_post_data(
authors = draft.documentauthor_set.all(),
basis=change_reason
)
self.login('secretary')
r = self.client.post(url, post_data)
self.assertEqual(r.status_code, 302)
draft = Document.objects.get(pk=draft.pk)
after = list(draft.documentauthor_set.values('person', 'email', 'affiliation', 'country', 'order'))
self.assertCountEqual(after, before, 'Unexpected change to an author')
self.assertEqual(EditedAuthorsDocEvent.objects.filter(basis=change_reason).count(), 0)
def do_edit_authors_append_authors_test(self, new_author_count):
"""Can add author at the end of the list"""
draft = WgDraftFactory()
starting_author_count = 3
DocumentAuthorFactory.create_batch(starting_author_count, document=draft)
url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
change_reason = 'add a new author'
compare_props = 'person', 'email', 'affiliation', 'country', 'order'
before = list(draft.documentauthor_set.values(*compare_props))
events_before = EditedAuthorsDocEvent.objects.count()
post_data = self.make_edit_authors_post_data(
authors=draft.documentauthor_set.all(),
basis=change_reason
)
new_authors = PersonFactory.create_batch(new_author_count, default_emails=True)
new_author_data = [
dict(
person=new_author.pk,
email=str(new_author.email()),
affiliation='University of Somewhere',
country='Botswana',
)
for new_author in new_authors
]
for index, auth_dict in enumerate(new_author_data):
self.add_author_to_edit_authors_post_data(post_data, auth_dict)
auth_dict['order'] = starting_author_count + index + 1 # for comparison later
self.login('secretary')
r = self.client.post(url, post_data)
self.assertEqual(r.status_code, 302)
draft = Document.objects.get(pk=draft.pk)
after = list(draft.documentauthor_set.values(*compare_props))
self.assertEqual(len(after), len(before) + new_author_count)
for b, a in zip(before + new_author_data, after):
for prop in compare_props:
self.assertEqual(a[prop], b[prop],
'Unexpected change: "{}" was "{}", changed to "{}"'.format(
prop, b[prop], a[prop]
))
self.assertEqual(EditedAuthorsDocEvent.objects.count(), events_before + new_author_count)
change_events = EditedAuthorsDocEvent.objects.filter(basis=change_reason)
self.assertEqual(change_events.count(), new_author_count)
# The events are most-recent first, so first author added is last event in the list.
# Reverse the author list with [::-1]
for evt, auth in zip(change_events, new_authors[::-1]):
self.assertIn('added', evt.desc.lower())
self.assertIn(auth.name, evt.desc)
def test_edit_authors_append_author(self):
self.do_edit_authors_append_authors_test(1)
def test_edit_authors_append_authors(self):
self.do_edit_authors_append_authors_test(3)
def test_edit_authors_insert_author(self):
"""Can add author in the middle of the list"""
draft = WgDraftFactory()
DocumentAuthorFactory.create_batch(3, document=draft)
url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
change_reason = 'add a new author'
compare_props = 'person', 'email', 'affiliation', 'country', 'order'
before = list(draft.documentauthor_set.values(*compare_props))
events_before = EditedAuthorsDocEvent.objects.count()
post_data = self.make_edit_authors_post_data(
authors = draft.documentauthor_set.all(),
basis=change_reason
)
new_author = PersonFactory(default_emails=True)
new_author_data = dict(
person=new_author.pk,
email=str(new_author.email()),
affiliation='University of Somewhere',
country='Botswana',
)
self.add_author_to_edit_authors_post_data(post_data, new_author_data, insert_order=1)
self.login('secretary')
r = self.client.post(url, post_data)
self.assertEqual(r.status_code, 302)
draft = Document.objects.get(pk=draft.pk)
after = list(draft.documentauthor_set.values(*compare_props))
new_author_data['order'] = 2 # corresponds to insert_order == 1
expected = copy.deepcopy(before)
expected.insert(1, new_author_data)
expected[2]['order'] = 3
expected[3]['order'] = 4
self.assertEqual(len(after), len(expected))
for b, a in zip(expected, after):
for prop in compare_props:
self.assertEqual(a[prop], b[prop],
'Unexpected change: "{}" was "{}", changed to "{}"'.format(
prop, b[prop], a[prop]
))
# 3 changes: new author, plus two order changes
self.assertEqual(EditedAuthorsDocEvent.objects.count(), events_before + 3)
change_events = EditedAuthorsDocEvent.objects.filter(basis=change_reason)
self.assertEqual(change_events.count(), 3)
add_event = change_events.filter(desc__icontains='added').first()
reorder_events = change_events.filter(desc__icontains='changed order')
self.assertIsNotNone(add_event)
self.assertEqual(reorder_events.count(), 2)
def test_edit_authors_remove_author(self):
draft = WgDraftFactory()
DocumentAuthorFactory.create_batch(3, document=draft)
url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
change_reason = 'remove an author'
compare_props = 'person', 'email', 'affiliation', 'country', 'order'
before = list(draft.documentauthor_set.values(*compare_props))
events_before = EditedAuthorsDocEvent.objects.count()
post_data = self.make_edit_authors_post_data(
authors = draft.documentauthor_set.all(),
basis=change_reason
)
# delete the second author (index == 1)
deleted_author_data = before.pop(1)
post_data['author-1-DELETE'] = 'on' # delete box checked
self.login('secretary')
r = self.client.post(url, post_data)
self.assertEqual(r.status_code, 302)
draft = Document.objects.get(pk=draft.pk)
after = list(draft.documentauthor_set.values(*compare_props))
before[1]['order'] = 2 # was 3, but should have been decremented
self.assertEqual(len(after), len(before))
for b, a in zip(before, after):
for prop in compare_props:
self.assertEqual(a[prop], b[prop],
'Unexpected change: "{}" was "{}", changed to "{}"'.format(
prop, b[prop], a[prop]
))
# expect 2 events: one for removing author, another for reordering the later author
self.assertEqual(EditedAuthorsDocEvent.objects.count(), events_before + 2)
change_events = EditedAuthorsDocEvent.objects.filter(basis=change_reason)
self.assertEqual(change_events.count(), 2)
removed_event = change_events.filter(desc__icontains='removed').first()
self.assertIsNotNone(removed_event)
deleted_person = Person.objects.get(pk=deleted_author_data['person'])
self.assertIn(deleted_person.name, removed_event.desc)
reordered_event = change_events.filter(desc__icontains='changed order').first()
reordered_person = Person.objects.get(pk=after[1]['person'])
self.assertIsNotNone(reordered_event)
self.assertIn(reordered_person.name, reordered_event.desc)
def test_edit_authors_reorder_authors(self):
draft = WgDraftFactory()
DocumentAuthorFactory.create_batch(3, document=draft)
url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
change_reason = 'reorder the authors'
compare_props = 'person', 'email', 'affiliation', 'country', 'order'
before = list(draft.documentauthor_set.values(*compare_props))
events_before = EditedAuthorsDocEvent.objects.count()
post_data = self.make_edit_authors_post_data(
authors = draft.documentauthor_set.all(),
basis=change_reason
)
# swap first two authors
post_data['author-0-ORDER'] = 1
post_data['author-1-ORDER'] = 0
self.login('secretary')
r = self.client.post(url, post_data)
self.assertEqual(r.status_code, 302)
draft = Document.objects.get(pk=draft.pk)
after = list(draft.documentauthor_set.values(*compare_props))
# swap the 'before' record order
tmp = before[0]
before[0] = before[1]
before[0]['order'] = 1
before[1] = tmp
before[1]['order'] = 2
for b, a in zip(before, after):
for prop in compare_props:
self.assertEqual(a[prop], b[prop],
'Unexpected change: "{}" was "{}", changed to "{}"'.format(
prop, b[prop], a[prop]
))
# expect 2 events: one for each changed author
self.assertEqual(EditedAuthorsDocEvent.objects.count(), events_before + 2)
change_events = EditedAuthorsDocEvent.objects.filter(basis=change_reason)
self.assertEqual(change_events.count(), 2)
self.assertEqual(change_events.filter(desc__icontains='changed order').count(), 2)
self.assertIsNotNone(
change_events.filter(
desc__contains=Person.objects.get(pk=before[0]['person']).name
).first()
)
self.assertIsNotNone(
change_events.filter(
desc__contains=Person.objects.get(pk=before[1]['person']).name
).first()
)
def test_edit_authors_edit_fields(self):
draft = WgDraftFactory()
DocumentAuthorFactory.create_batch(3, document=draft)
url = urlreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
change_reason = 'reorder the authors'
compare_props = 'person', 'email', 'affiliation', 'country', 'order'
before = list(draft.documentauthor_set.values(*compare_props))
events_before = EditedAuthorsDocEvent.objects.count()
post_data = self.make_edit_authors_post_data(
authors = draft.documentauthor_set.all(),
basis=change_reason
)
new_email = EmailFactory(person=draft.authors()[0])
post_data['author-0-email'] = new_email.address
post_data['author-1-affiliation'] = 'University of Nowhere'
post_data['author-2-country'] = 'Chile'
self.login('secretary')
r = self.client.post(url, post_data)
self.assertEqual(r.status_code, 302)
draft = Document.objects.get(pk=draft.pk)
after = list(draft.documentauthor_set.values(*compare_props))
expected = copy.deepcopy(before)
expected[0]['email'] = new_email.address
expected[1]['affiliation'] = 'University of Nowhere'
expected[2]['country'] = 'Chile'
for b, a in zip(expected, after):
for prop in compare_props:
self.assertEqual(a[prop], b[prop],
'Unexpected change: "{}" was "{}", changed to "{}"'.format(
prop, b[prop], a[prop]
))
# expect 3 events: one for each changed author
self.assertEqual(EditedAuthorsDocEvent.objects.count(), events_before + 3)
change_events = EditedAuthorsDocEvent.objects.filter(basis=change_reason)
self.assertEqual(change_events.count(), 3)
email_event = change_events.filter(desc__icontains='changed email').first()
affiliation_event = change_events.filter(desc__icontains='changed affiliation').first()
country_event = change_events.filter(desc__icontains='changed country').first()
self.assertIsNotNone(email_event)
self.assertIn(draft.authors()[0].name, email_event.desc)
self.assertIn(before[0]['email'], email_event.desc)
self.assertIn(after[0]['email'], email_event.desc)
self.assertIsNotNone(affiliation_event)
self.assertIn(draft.authors()[1].name, affiliation_event.desc)
self.assertIn(before[1]['affiliation'], affiliation_event.desc)
self.assertIn(after[1]['affiliation'], affiliation_event.desc)
self.assertIsNotNone(country_event)
self.assertIn(draft.authors()[2].name, country_event.desc)
self.assertIn(before[2]['country'], country_event.desc)
self.assertIn(after[2]['country'], country_event.desc)
@staticmethod @staticmethod
def _pyquery_select_action_holder_string(q, s): def _pyquery_select_action_holder_string(q, s):
"""Helper to use PyQuery to find an action holder in the draft HTML""" """Helper to use PyQuery to find an action holder in the draft HTML"""

144
ietf/doc/tests_js.py Normal file
View file

@ -0,0 +1,144 @@
# Copyright The IETF Trust 2021, All Rights Reserved
# -*- coding: utf-8 -*-
import debug # pyflakes:ignore
from ietf.doc.factories import WgDraftFactory, DocumentAuthorFactory
from ietf.person.factories import PersonFactory
from ietf.person.models import Person
from ietf.utils.jstest import IetfSeleniumTestCase, ifSeleniumEnabled, selenium_enabled
if selenium_enabled():
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions
class presence_of_element_child_by_css_selector:
"""Wait for presence of a child of a WebElement matching a CSS selector
This is a condition class for use with WebDriverWait.
"""
def __init__(self, element, child_selector):
self.element = element
self.child_selector = child_selector
def __call__(self, driver):
child = self.element.find_element_by_css_selector(self.child_selector)
return child if child is not None else False
@ifSeleniumEnabled
class EditAuthorsTests(IetfSeleniumTestCase):
def setUp(self):
super(EditAuthorsTests, self).setUp()
self.wait = WebDriverWait(self.driver, 2)
def test_add_author_forms(self):
def _fill_in_author_form(form_elt, name, email, affiliation, country):
"""Fill in an author form on the edit authors page
The form_elt input should be an element containing all the relevant inputs.
"""
# To enter the person, type their name in the select2 search box, wait for the
# search to offer the result, then press 'enter' to accept the result and close
# the search input.
person_span = form_elt.find_element_by_class_name('select2-chosen')
self.scroll_to_element(person_span)
person_span.click()
input = self.driver.switch_to.active_element
input.send_keys(name)
result_selector = 'ul.select2-results > li > div.select2-result-label'
self.wait.until(
expected_conditions.text_to_be_present_in_element(
(By.CSS_SELECTOR, result_selector),
name
))
input.send_keys('\n') # select the object
# After the author is selected, the email select options will be populated.
# Wait for that, then click on the option corresponding to the requested email.
# This will only work if the email matches an address for the selected person.
email_select = form_elt.find_element_by_css_selector('select[name$="email"]')
email_option = self.wait.until(
presence_of_element_child_by_css_selector(email_select, 'option[value="{}"]'.format(email))
)
email_option.click() # select the email
# Fill in the affiliation and country. Finally, simple text inputs!
affil_input = form_elt.find_element_by_css_selector('input[name$="affiliation"]')
affil_input.send_keys(affiliation)
country_input = form_elt.find_element_by_css_selector('input[name$="country"]')
country_input.send_keys(country)
def _read_author_form(form_elt):
"""Read values from an author form
Note: returns the Person instance named in the person field, not just their name.
"""
hidden_person_input = form_elt.find_element_by_css_selector('input[type="text"][name$="person"]')
email_select = form_elt.find_element_by_css_selector('select[name$="email"]')
affil_input = form_elt.find_element_by_css_selector('input[name$="affiliation"]')
country_input = form_elt.find_element_by_css_selector('input[name$="country"]')
return (
Person.objects.get(pk=hidden_person_input.get_attribute('value')),
email_select.get_attribute('value'),
affil_input.get_attribute('value'),
country_input.get_attribute('value'),
)
# Create testing resources
draft = WgDraftFactory()
DocumentAuthorFactory(document=draft)
authors = PersonFactory.create_batch(2) # authors we will add
orgs = ['some org', 'some other org'] # affiliations for the authors
countries = ['France', 'Uganda'] # countries for the authors
url = self.absreverse('ietf.doc.views_doc.edit_authors', kwargs=dict(name=draft.name))
# Star the test by logging in with appropriate permissions and retrieving the edit page
self.login('secretary')
self.driver.get(url)
# The draft has one author to start with. Find the list and check the count.
authors_list = self.driver.find_element_by_id('authors-list')
author_forms = authors_list.find_elements_by_class_name('author-panel')
self.assertEqual(len(author_forms), 1)
# get the "add author" button so we can add blank author forms
add_author_button = self.driver.find_element_by_id('add-author-button')
for index, auth in enumerate(authors):
self.scroll_to_element(add_author_button) # Can only click if it's in view!
add_author_button.click() # Create a new form. Automatically scrolls to it.
author_forms = authors_list.find_elements_by_class_name('author-panel')
authors_added = index + 1
self.assertEqual(len(author_forms), authors_added + 1) # Started with 1 author, hence +1
_fill_in_author_form(author_forms[index + 1], auth.name, str(auth.email()), orgs[index], countries[index])
# Check that the author forms have correct (and distinct) values
first_auth = draft.documentauthor_set.first()
self.assertEqual(
_read_author_form(author_forms[0]),
(first_auth.person, str(first_auth.email), first_auth.affiliation, first_auth.country),
)
for index, auth in enumerate(authors):
self.assertEqual(
_read_author_form(author_forms[index + 1]),
(auth, str(auth.email()), orgs[index], countries[index]),
)
# Must provide a "basis" (change reason)
self.driver.find_element_by_id('id_basis').send_keys('change testing')
# Now click the 'submit' button and check that the update was accepted.
submit_button = self.driver.find_element_by_css_selector('button[type="submit"]')
self.scroll_to_element(submit_button)
submit_button.click()
# Wait for redirect to the document_main view
self.wait.until(
expected_conditions.url_to_be(
self.absreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=draft.name))
))
# Just a basic check that the expected authors show up. Details of the updates
# are tested separately.
self.assertEqual(
list(draft.documentauthor_set.values_list('person', flat=True)),
[first_auth.person.pk] + [auth.pk for auth in authors]
)

View file

@ -113,6 +113,7 @@ urlpatterns = [
url(r'^%(name)s/edit/telechat/$' % settings.URL_REGEXPS, views_doc.telechat_date), url(r'^%(name)s/edit/telechat/$' % settings.URL_REGEXPS, views_doc.telechat_date),
url(r'^%(name)s/edit/iesgnote/$' % settings.URL_REGEXPS, views_draft.edit_iesg_note), url(r'^%(name)s/edit/iesgnote/$' % settings.URL_REGEXPS, views_draft.edit_iesg_note),
url(r'^%(name)s/edit/ad/$' % settings.URL_REGEXPS, views_draft.edit_ad), url(r'^%(name)s/edit/ad/$' % settings.URL_REGEXPS, views_draft.edit_ad),
url(r'^%(name)s/edit/authors/$' % settings.URL_REGEXPS, views_doc.edit_authors),
url(r'^%(name)s/edit/consensus/$' % settings.URL_REGEXPS, views_draft.edit_consensus), url(r'^%(name)s/edit/consensus/$' % settings.URL_REGEXPS, views_draft.edit_consensus),
url(r'^%(name)s/edit/shepherd/$' % settings.URL_REGEXPS, views_draft.edit_shepherd), url(r'^%(name)s/edit/shepherd/$' % settings.URL_REGEXPS, views_draft.edit_shepherd),
url(r'^%(name)s/edit/shepherdemail/$' % settings.URL_REGEXPS, views_draft.change_shepherd_email), url(r'^%(name)s/edit/shepherdemail/$' % settings.URL_REGEXPS, views_draft.change_shepherd_email),

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 Document, DocHistory, State, DocumentAuthor, DocHistoryAuthor
from ietf.doc.models import DocAlias, RelatedDocument, RelatedDocHistory, BallotType, DocReminder 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 DocEvent, ConsensusDocEvent, BallotDocEvent, IRSGBallotDocEvent, NewRevisionDocEvent, StateDocEvent
from ietf.doc.models import TelechatDocEvent, DocumentActionHolder from ietf.doc.models import TelechatDocEvent, DocumentActionHolder, EditedAuthorsDocEvent
from ietf.name.models import DocReminderTypeName, DocRelationshipName from ietf.name.models import DocReminderTypeName, DocRelationshipName
from ietf.group.models import Role, Group from ietf.group.models import Role, Group
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, is_individual_draft_author 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,
) )
def update_documentauthors(doc, new_docauthors, by=None, basis=None):
"""Update the list of authors for a document
Returns an iterable of events describing the change. These must be saved by the caller if
they are to be kept.
The new_docauthors argument should be an iterable containing objects that
have person, email, affiliation, and country attributes. An easy way to create
these objects is to use DocumentAuthor(), but e.g., a named tuple could be
used. These objects will not be saved, their attributes will be used to create new
DocumentAuthor instances. (The document and order fields will be ignored.)
"""
def _change_field_and_describe(auth, field, newval):
# make the change
oldval = getattr(auth, field)
setattr(auth, field, newval)
was_empty = oldval is None or len(str(oldval)) == 0
now_empty = newval is None or len(str(oldval)) == 0
# describe the change
if oldval == newval:
return None
else:
if was_empty and not now_empty:
return 'set {field} to "{new}"'.format(field=field, new=newval)
elif now_empty and not was_empty:
return 'cleared {field} (was "{old}")'.format(field=field, old=oldval)
else:
return 'changed {field} from "{old}" to "{new}"'.format(
field=field, old=oldval, new=newval
)
persons = []
changes = [] # list of change descriptions
for order, docauthor in enumerate(new_docauthors):
# If an existing DocumentAuthor matches, use that
auth = doc.documentauthor_set.filter(person=docauthor.person).first()
is_new_auth = auth is None
if is_new_auth:
# None exists, so create a new one (do not just use docauthor here because that
# will modify the input and might cause side effects)
auth = DocumentAuthor(document=doc, person=docauthor.person)
changes.append('Added "{name}" as author'.format(name=auth.person.name))
author_changes = []
# Now fill in other author details
author_changes.append(_change_field_and_describe(auth, 'email', docauthor.email))
author_changes.append(_change_field_and_describe(auth, 'affiliation', docauthor.affiliation))
author_changes.append(_change_field_and_describe(auth, 'country', docauthor.country))
author_changes.append(_change_field_and_describe(auth, 'order', order + 1))
auth.save()
log.assertion('auth.email_id != "none"')
persons.append(docauthor.person)
if not is_new_auth:
all_author_changes = ', '.join([ch for ch in author_changes if ch is not None])
if len(all_author_changes) > 0:
changes.append('Changed author "{name}": {changes}'.format(
name=auth.person.name, changes=all_author_changes
))
# Finally, remove any authors no longer in the list
removed_authors = doc.documentauthor_set.exclude(person__in=persons)
changes.extend(['Removed "{name}" as author'.format(name=auth.person.name)
for auth in removed_authors])
removed_authors.delete()
# Create change events - one event per author added/changed/removed.
# Caller must save these if they want them persisted.
return [
EditedAuthorsDocEvent(
type='edited_authors', by=by, doc=doc, rev=doc.rev, desc=change, basis=basis
) for change in changes
]
def update_reminder(doc, reminder_type_slug, event, due_date): def update_reminder(doc, reminder_type_slug, event, due_date):
reminder_type = DocReminderTypeName.objects.get(slug=reminder_type_slug) reminder_type = DocReminderTypeName.objects.get(slug=reminder_type_slug)

View file

@ -54,20 +54,21 @@ import debug # pyflakes:ignore
from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDocEvent, BallotType, from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDocEvent, BallotType,
ConsensusDocEvent, NewRevisionDocEvent, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent, ConsensusDocEvent, NewRevisionDocEvent, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent,
IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder ) IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder, DocumentAuthor )
from ietf.doc.utils import (add_links_in_new_revision_events, augment_events_with_revision, 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, 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, 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, get_initial_notify, make_notify_changed_event, make_rev_history, default_consensus,
add_events_message_info, get_unicode_document_content, build_doc_meta_block, add_events_message_info, get_unicode_document_content, build_doc_meta_block,
augment_docs_and_user_with_user_info, irsg_needed_ballot_positions, add_action_holder_change_event, build_doc_supermeta_block, build_file_urls ) augment_docs_and_user_with_user_info, irsg_needed_ballot_positions, add_action_holder_change_event,
build_doc_supermeta_block, build_file_urls, update_documentauthors )
from ietf.group.models import Role, Group 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.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, from ietf.ietfauth.utils import ( has_role, is_authorized_in_doc_stream, user_is_person,
role_required, is_individual_draft_author) role_required, is_individual_draft_author)
from ietf.name.models import StreamName, BallotPositionName from ietf.name.models import StreamName, BallotPositionName
from ietf.utils.history import find_history_active_at from ietf.utils.history import find_history_active_at
from ietf.doc.forms import TelechatForm, NotifyForm, ActionHoldersForm from ietf.doc.forms import TelechatForm, NotifyForm, ActionHoldersForm, DocAuthorForm, DocAuthorChangeBasisForm
from ietf.doc.mails import email_comment, email_remind_action_holders from ietf.doc.mails import email_comment, email_remind_action_holders
from ietf.mailtrigger.utils import gather_relevant_expansions from ietf.mailtrigger.utils import gather_relevant_expansions
from ietf.meeting.models import Session from ietf.meeting.models import Session
@ -185,6 +186,8 @@ def document_main(request, name, rev=None):
irsg_state = doc.get_state("draft-stream-irtf") irsg_state = doc.get_state("draft-stream-irtf")
can_edit = has_role(request.user, ("Area Director", "Secretariat")) can_edit = has_role(request.user, ("Area Director", "Secretariat"))
can_edit_authors = has_role(request.user, ("Secretariat"))
stream_slugs = StreamName.objects.values_list("slug", flat=True) stream_slugs = StreamName.objects.values_list("slug", flat=True)
# For some reason, AnonymousUser has __iter__, but is not iterable, # For some reason, AnonymousUser has __iter__, but is not iterable,
# which causes problems in the filter() below. Work around this: # which causes problems in the filter() below. Work around this:
@ -424,6 +427,7 @@ def document_main(request, name, rev=None):
latest_revision=latest_revision, latest_revision=latest_revision,
latest_rev=latest_rev, latest_rev=latest_rev,
can_edit=can_edit, can_edit=can_edit,
can_edit_authors=can_edit_authors,
can_change_stream=can_change_stream, can_change_stream=can_change_stream,
can_edit_stream_info=can_edit_stream_info, can_edit_stream_info=can_edit_stream_info,
can_edit_individual=can_edit_individual, can_edit_individual=can_edit_individual,
@ -1349,6 +1353,76 @@ def edit_notify(request, name):
) )
@role_required('Secretariat')
def edit_authors(request, name):
"""Edit the authors of a doc"""
class _AuthorsBaseFormSet(forms.BaseFormSet):
HIDE_FIELDS = ['ORDER']
def __init__(self, *args, **kwargs):
kwargs['prefix'] = 'author'
super(_AuthorsBaseFormSet, self).__init__(*args, **kwargs)
def add_fields(self, form, index):
super(_AuthorsBaseFormSet, self).add_fields(form, index)
for fh in self.HIDE_FIELDS:
if fh in form.fields:
form.fields[fh].widget = forms.HiddenInput()
AuthorFormSet = forms.formset_factory(DocAuthorForm,
formset=_AuthorsBaseFormSet,
can_delete=True,
can_order=True,
extra=0)
doc = get_object_or_404(Document, name=name)
if request.method == 'POST':
change_basis_form = DocAuthorChangeBasisForm(request.POST)
author_formset = AuthorFormSet(request.POST)
if change_basis_form.is_valid() and author_formset.is_valid():
docauthors = []
for form in author_formset.ordered_forms:
if not form.cleaned_data['DELETE']:
docauthors.append(
DocumentAuthor(
# update_documentauthors() will fill in document and order for us
person=form.cleaned_data['person'],
email=form.cleaned_data['email'],
affiliation=form.cleaned_data['affiliation'],
country=form.cleaned_data['country']
)
)
change_events = update_documentauthors(
doc,
docauthors,
request.user.person,
change_basis_form.cleaned_data['basis']
)
for event in change_events:
event.save()
return redirect('ietf.doc.views_doc.document_main', name=doc.name)
else:
change_basis_form = DocAuthorChangeBasisForm()
author_formset = AuthorFormSet(
initial=[{
'person': author.person,
'email': author.email,
'affiliation': author.affiliation,
'country': author.country,
'order': author.order,
} for author in doc.documentauthor_set.all()]
)
return render(
request,
'doc/edit_authors.html',
{
'doc': doc,
'change_basis_form': change_basis_form,
'formset': author_formset,
'titletext': doc_titletext(doc),
})
@role_required('Area Director', 'Secretariat') @role_required('Area Director', 'Secretariat')
def edit_action_holders(request, name): def edit_action_holders(request, name):
"""Change the set of action holders for a doc""" """Change the set of action holders for a doc"""

View file

@ -3,6 +3,7 @@ import json
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.http import HttpResponse from django.http import HttpResponse
from ietf.ietfauth.utils import role_required
from ietf.person.models import Person from ietf.person.models import Person
def person_json(request, personid): def person_json(request, personid):
@ -12,3 +13,10 @@ def person_json(request, personid):
sort_keys=True, indent=2), sort_keys=True, indent=2),
content_type="application/json") content_type="application/json")
@role_required('Secretariat')
def person_email_json(request, personid):
person = get_object_or_404(Person, pk=personid)
addresses = person.email_set.order_by('-primary').values('address', 'primary')
return HttpResponse(json.dumps(list(addresses)), content_type='application/json')

View file

@ -3,6 +3,7 @@
import datetime import datetime
import json
from io import StringIO, BytesIO from io import StringIO, BytesIO
from PIL import Image from PIL import Image
@ -47,6 +48,29 @@ class PersonTests(TestCase):
data = r.json() data = r.json()
self.assertEqual(data[0]["id"], person.email_address()) self.assertEqual(data[0]["id"], person.email_address())
def test_ajax_person_email_json(self):
person = PersonFactory()
EmailFactory.create_batch(5, person=person)
primary_email = person.email()
primary_email.primary = True
primary_email.save()
bad_url = urlreverse('ietf.person.ajax.person_email_json', kwargs=dict(personid=12345))
url = urlreverse('ietf.person.ajax.person_email_json', kwargs=dict(personid=person.pk))
login_testing_unauthorized(self, 'secretary', bad_url)
r = self.client.get(bad_url)
self.assertEqual(r.status_code, 404)
self.client.logout()
login_testing_unauthorized(self, 'secretary', url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertCountEqual(
json.loads(r.content),
[dict(address=email.address, primary=email.primary) for email in person.email_set.all()],
)
def test_default_email(self): def test_default_email(self):
person = PersonFactory() person = PersonFactory()
primary = EmailFactory(person=person, primary=True, active=True) primary = EmailFactory(person=person, primary=True, active=True)

View file

@ -5,6 +5,7 @@ urlpatterns = [
url(r'^merge/$', views.merge), url(r'^merge/$', views.merge),
url(r'^search/(?P<model_name>(person|email))/$', views.ajax_select2_search), url(r'^search/(?P<model_name>(person|email))/$', views.ajax_select2_search),
url(r'^(?P<personid>[a-z0-9]+).json$', ajax.person_json), url(r'^(?P<personid>[a-z0-9]+).json$', ajax.person_json),
url(r'^(?P<personid>[a-z0-9]+)/email.json$', ajax.person_email_json),
url(r'^(?P<email_or_name>[^/]+)$', views.profile), url(r'^(?P<email_or_name>[^/]+)$', views.profile),
url(r'^(?P<email_or_name>[^/]+)/photo/?$', views.photo), url(r'^(?P<email_or_name>[^/]+)/photo/?$', views.photo),
] ]

View file

@ -25,7 +25,8 @@ from ietf.doc.models import ( Document, State, DocAlias, DocEvent, SubmissionDoc
from ietf.doc.models import NewRevisionDocEvent from ietf.doc.models import NewRevisionDocEvent
from ietf.doc.models import RelatedDocument, DocRelationshipName, DocExtResource from ietf.doc.models import RelatedDocument, DocRelationshipName, DocExtResource
from ietf.doc.utils import add_state_change_event, rebuild_reference_relations from ietf.doc.utils import add_state_change_event, rebuild_reference_relations
from ietf.doc.utils import set_replaces_for_document, prettify_std_name, update_doc_extresources, can_edit_docextresources from ietf.doc.utils import ( set_replaces_for_document, prettify_std_name,
update_doc_extresources, can_edit_docextresources, update_documentauthors )
from ietf.doc.mails import send_review_possibly_replaces_request, send_external_resource_change_request from ietf.doc.mails import send_review_possibly_replaces_request, send_external_resource_change_request
from ietf.group.models import Group from ietf.group.models import Group
from ietf.ietfauth.utils import has_role from ietf.ietfauth.utils import has_role
@ -572,24 +573,21 @@ def ensure_person_email_info_exists(name, email, docname):
return person, email return person, email
def update_authors(draft, submission): def update_authors(draft, submission):
persons = [] docauthors = []
for order, author in enumerate(submission.authors): for author in submission.authors:
person, email = ensure_person_email_info_exists(author["name"], author.get("email"), submission.name) person, email = ensure_person_email_info_exists(author["name"], author.get("email"), submission.name)
docauthors.append(
a = DocumentAuthor.objects.filter(document=draft, person=person).first() DocumentAuthor(
if not a: # update_documentauthors() will fill in document and order for us
a = DocumentAuthor(document=draft, person=person) person=person,
email=email,
a.email = email affiliation=author.get("affiliation", ""),
a.affiliation = author.get("affiliation") or "" country=author.get("country", "")
a.country = author.get("country") or "" )
a.order = order )
a.save() # The update_documentauthors() method returns a list of unsaved author edit events for the draft.
log.assertion('a.email_id != "none"') # Discard these because the existing logging is already adequate.
update_documentauthors(draft, docauthors)
persons.append(person)
draft.documentauthor_set.exclude(person__in=persons).delete()
def cancel_submission(submission): def cancel_submission(submission):
submission.state = DraftSubmissionStateName.objects.get(slug="cancel") submission.state = DraftSubmissionStateName.objects.get(slug="cancel")

View file

@ -75,7 +75,11 @@
<tr> <tr>
<th></th> <th></th>
<th>Author{{doc.authors|pluralize}}</th> <th>Author{{doc.authors|pluralize}}</th>
<td class="edit"></td> <td class="edit">
{% if can_edit_authors %}
<a class="btn btn-default btn-xs" href="{% url 'ietf.doc.views_doc.edit_authors' name=doc.name %}">Edit</a>
{% endif %}
</td>
<td> <td>
{# Implementation that uses the current primary email for each author #} {# Implementation that uses the current primary email for each author #}
{% for author in doc.authors %} {% for author in doc.authors %}

View file

@ -0,0 +1,160 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin %}
{% load static %}
{% load bootstrap3 %}
{% block pagehead %}
<link rel="stylesheet" href="{% static 'select2/select2.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-css/select2-bootstrap.min.css' %}">
{% endblock %}
{% block morecss %}
#empty-author-form {
display: none;
}
{% endblock %}
{% block title %}
Edit authors for {{ titletext }}
{% endblock %}
{% block content %}
{% origin %}
<h1>Edit authors<br><small>{{ titletext }}</small></h1>
<form enctype="multipart/form-data" method="post" id="authors-form">
{% csrf_token %}
{% bootstrap_form change_basis_form %}
{% buttons %}
<button id="add-author-button" type="button" class="btn btn-default" onclick="local_js.add_author()">Add new author</button>
{% endbuttons %}
{% bootstrap_form formset.management_form %}
<div id="authors-list" class="well">
{% for form in formset %}
<div class="panel panel-default author-panel">
<div class="panel-body draggable">
<span class="handle fa fa-reorder"></span>
<div class="form-horizontal">
{% bootstrap_form form layout='horizontal' %}
</div>
</div>
</div>
{% endfor %}
</div>
<div id="empty-author-form" class="template">
<div class="panel panel-default author-panel">
<div class="panel-body draggable">
<span class="handle fa fa-reorder"></span>
<div class="form-horizontal">
{% bootstrap_form formset.empty_form layout='horizontal' %}
</div>
</div>
</div>
</div>
{% buttons %}
<button type="submit" class="btn btn-primary">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 'Sortable/Sortable.min.js' %}"></script>
<script src="{% static 'select2/select2.min.js' %}"></script>
<script src="{% static 'ietf/js/select2-field.js' %}"></script>
<script type="text/javascript">
const local_js = (
function () {
const sortable_list_id = 'authors-list';{# id of the container element for Sortable #}
const prefix = 'author'; {# formset prefix - must match the prefix in the edit_authors() view #}
var list_container;
var form_counter;
var author_template;
var ajax_url = '{% url "ietf.person.ajax.person_email_json" personid="0000placeholder0000" %}';
var person_select2_input_selector = 'input.select2-field[name^="author-"][name$="-person"]';
function handle_drag_end() {
// after dragging, set order inputs to match new positions in list
$(list_container).find('.draggable input[name^="' + prefix + '"][name$="ORDER"]').each(
function (index, elt) {
$(elt).val(index + 1);
})
}
function add_author() {
// __prefix__ is the unique prefix for each list item, indexed from 0
var new_html = $(author_template).html().replaceAll('__prefix__', form_counter.value);
var new_elt = $(new_html)
$(list_container).append(new_elt);
var new_person_select = new_elt.find(person_select2_input_selector);
setupSelect2Field(new_person_select);
new_person_select.on('change', person_changed);
var form_count = Number(form_counter.value);
form_counter.value = String(form_count + 1);
new_elt[0].scrollIntoView(true);
}
function update_email_options_cb_factory(email_select) {
// factory method creates a closure for the callback
return function(ajax_data) {
// keep the first item - it's the 'blank' option
$(email_select).children().not(':first').remove();
$.each(ajax_data, function(index, email) {
$(email_select).append(
$('<option></option>')
.attr('value', email.address)
.text(email.address)
);
});
if (ajax_data.length > 0) {
$(email_select).val(ajax_data[0].address);
}
}
}
function person_changed(event) {
var person_elt = $(this);
var email_select = $('#' + person_elt.attr('id').replace(/-person$/, '-email'));
$.get(
ajax_url.replace('0000placeholder0000', $(this).val()),
null,
update_email_options_cb_factory(email_select)
);
}
function initialize() {
list_container = document.getElementById(sortable_list_id)
form_counter = document.getElementsByName(prefix + '-TOTAL_FORMS')[0];
author_template = document.getElementById('empty-author-form');
Sortable.create(
list_container,
{
handle: '.handle',
onEnd: handle_drag_end
});
// register handler
$(person_select2_input_selector).on('change', person_changed);
}
return {
add_author: add_author,
initialize: initialize
}
}
)()
$(document).ready(local_js.initialize);
</script>
{% endblock %}

View file

@ -8,6 +8,7 @@ skip_selenium = False
skip_message = "" skip_message = ""
try: try:
from selenium import webdriver from selenium import webdriver
from selenium.webdriver.common.action_chains import ActionChains
except ImportError as e: except ImportError as e:
skip_selenium = True skip_selenium = True
skip_message = "Skipping selenium tests: %s" % e skip_message = "Skipping selenium tests: %s" % e
@ -72,3 +73,8 @@ class IetfSeleniumTestCase(IetfLiveServerTestCase):
self.driver.find_element_by_name('password').send_keys(password) self.driver.find_element_by_name('password').send_keys(password)
self.driver.find_element_by_xpath('//button[@type="submit"]').click() self.driver.find_element_by_xpath('//button[@type="submit"]').click()
def scroll_to_element(self, element):
"""Scroll an element into view"""
actions = ActionChains(self.driver)
actions.move_to_element(element).perform()

View file

@ -63,8 +63,16 @@ def split_url(url):
args = {} args = {}
return url, args return url, args
def login_testing_unauthorized(test_case, username, url, password=None): def login_testing_unauthorized(test_case, username, url, password=None, method='get', request_kwargs=None):
r = test_case.client.get(url) """Test that a request is refused or redirected for login, then log in as the named user
Defaults to making a 'get'. Set method to one of the other django.test.Client request method names
(e.g., 'post') to change that. If that request needs arguments, pass these in request_kwargs.
"""
request_method = getattr(test_case.client, method)
if request_kwargs is None:
request_kwargs = dict()
r = request_method(url, **request_kwargs)
test_case.assertIn(r.status_code, (302, 403)) test_case.assertIn(r.status_code, (302, 403))
if r.status_code == 302: if r.status_code == 302:
test_case.assertTrue("/accounts/login" in r['Location']) test_case.assertTrue("/accounts/login" in r['Location'])