Skip to content

Commit b08110b

Browse files
Allow external resources to be set/suggested during submission process. Fixes ietf-tools#3068. Commit ready for merge.
- Legacy-Id: 18960
1 parent 445f98d commit b08110b

17 files changed

Lines changed: 909 additions & 102 deletions

ietf/doc/forms.py

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
import datetime
66
import debug #pyflakes:ignore
77
from django import forms
8+
from django.core.exceptions import ObjectDoesNotExist, ValidationError
89

910
from ietf.doc.fields import SearchableDocAliasesField, SearchableDocAliasField
10-
from ietf.doc.models import RelatedDocument
11+
from ietf.doc.models import RelatedDocument, DocExtResource
1112
from ietf.iesg.models import TelechatDate
1213
from ietf.iesg.utils import telechat_page_count
1314
from ietf.person.fields import SearchablePersonsField
1415

16+
from ietf.name.models import ExtResourceName
17+
from ietf.utils.validators import validate_external_resource_value
1518

1619
class TelechatForm(forms.Form):
1720
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.")
@@ -57,7 +60,7 @@ def clean_notify(self):
5760
return ', '.join(addrspecs)
5861

5962
class ActionHoldersForm(forms.Form):
60-
action_holders = SearchablePersonsField(required=False)
63+
action_holders = SearchablePersonsField(required=False)
6164
reason = forms.CharField(
6265
label='Reason for change',
6366
required=False,
@@ -125,3 +128,77 @@ def clean(self):
125128
if v_err_refnorm:
126129
v_err_refnorm_prefix = "There does not seem to be a normative reference to RFC " + rfc.document.rfc_number() + " by "
127130
raise forms.ValidationError(v_err_refnorm_prefix + v_err_refnorm)
131+
132+
133+
class ExtResourceForm(forms.Form):
134+
resources = forms.CharField(widget=forms.Textarea, label="Additional Resources", required=False,
135+
help_text=("Format: 'tag value (Optional description)'."
136+
" Separate multiple entries with newline. When the value is a URL, use https:// where possible.") )
137+
138+
def __init__(self, *args, initial=None, extresource_model=None, **kwargs):
139+
self.extresource_model = extresource_model
140+
if initial:
141+
kwargs = kwargs.copy()
142+
resources = initial.get('resources')
143+
if resources is not None and not isinstance(resources, str):
144+
initial = initial.copy()
145+
# Convert objects to string representation
146+
initial['resources'] = self.format_resources(resources)
147+
kwargs['initial'] = initial
148+
super(ExtResourceForm, self).__init__(*args, **kwargs)
149+
150+
@staticmethod
151+
def format_resources(resources, fs="\n"):
152+
# Might be better to shift to a formset instead of parsing these lines.
153+
return fs.join([r.to_form_entry_str() for r in resources])
154+
155+
def clean_resources(self):
156+
"""Clean the resources field
157+
158+
The resources field is a newline-separated set of resource entries. Each entry
159+
should be "<tag> <value>" or "<tag> <value> (<display name>)" with any whitespace
160+
delimiting the components. This clean only validates that the tag and value are
161+
present and valid - tag must be a recognized ExtResourceName and value is
162+
validated using validate_external_resource_value(). Further interpretation of
163+
the resource is performed int he clean() method.
164+
"""
165+
lines = [x.strip() for x in self.cleaned_data["resources"].splitlines() if x.strip()]
166+
errors = []
167+
for l in lines:
168+
parts = l.split()
169+
if len(parts) == 1:
170+
errors.append("Too few fields: Expected at least tag and value: '%s'" % l)
171+
elif len(parts) >= 2:
172+
name_slug = parts[0]
173+
try:
174+
name = ExtResourceName.objects.get(slug=name_slug)
175+
except ObjectDoesNotExist:
176+
errors.append("Bad tag in '%s': Expected one of %s" % (l, ', '.join([ o.slug for o in ExtResourceName.objects.all() ])))
177+
continue
178+
value = parts[1]
179+
try:
180+
validate_external_resource_value(name, value)
181+
except ValidationError as e:
182+
e.message += " : " + value
183+
errors.append(e)
184+
if errors:
185+
raise ValidationError(errors)
186+
return lines
187+
188+
def clean(self):
189+
"""Clean operations after all other fields are cleaned by clean_<field> methods
190+
191+
Converts resource strings into ExtResource model instances.
192+
"""
193+
cleaned_data = super(ExtResourceForm, self).clean()
194+
cleaned_resources = []
195+
cls = self.extresource_model or DocExtResource
196+
for crs in cleaned_data.get('resources', []):
197+
cleaned_resources.append(
198+
cls.from_form_entry_str(crs)
199+
)
200+
cleaned_data['resources'] = cleaned_resources
201+
202+
@staticmethod
203+
def valid_resource_tags():
204+
return ExtResourceName.objects.all().order_by('slug').values_list('slug', flat=True)

ietf/doc/mails.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,3 +672,20 @@ def email_iana_expert_review_state_changed(request, events):
672672
dict(event=events[0], url=settings.IDTRACKER_BASE_URL + events[0].doc.get_absolute_url() ),
673673
cc = addrs.cc,
674674
)
675+
676+
def send_external_resource_change_request(request, doc, submitter_info, requested_resources):
677+
"""Send an email to requesting changes to a draft's external resources"""
678+
addrs = gather_address_lists('doc_external_resource_change_requested', doc=doc)
679+
to = set(addrs.to)
680+
cc = set(addrs.cc)
681+
682+
send_mail(request, list(to), settings.DEFAULT_FROM_EMAIL,
683+
'External resource change requested for %s' % doc.name,
684+
'doc/mail/external_resource_change_request.txt',
685+
dict(
686+
doc=doc,
687+
submitter_info=submitter_info,
688+
requested_resources=requested_resources,
689+
doc_url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(),
690+
),
691+
cc=list(cc),)

ietf/doc/models.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -922,15 +922,57 @@ class DocumentURL(models.Model):
922922
desc = models.CharField(max_length=255, default='', blank=True)
923923
url = models.URLField(max_length=2083) # 2083 is the legal max for URLs
924924

925-
class DocExtResource(models.Model):
926-
doc = ForeignKey(Document) # Should this really be to DocumentInfo rather than Document?
925+
class ExtResource(models.Model):
927926
name = models.ForeignKey(ExtResourceName, on_delete=models.CASCADE)
928927
display_name = models.CharField(max_length=255, default='', blank=True)
929928
value = models.CharField(max_length=2083) # 2083 is the maximum legal URL length
930929
def __str__(self):
931930
priority = self.display_name or self.name.name
932931
return u"%s (%s) %s" % (priority, self.name.slug, self.value)
933932

933+
class Meta:
934+
abstract = True
935+
936+
# The to_form_entry_str() and matching from_form_entry_str() class method are
937+
# defined here to ensure that change request emails suggest resources in the
938+
# correct format to cut-and-paste into the current textarea on the external
939+
# resource form. If that is changed to a formset or other non-text entry field,
940+
# these methods really should not be needed.
941+
def to_form_entry_str(self):
942+
"""Serialize as a string suitable for entry in a form"""
943+
if self.display_name:
944+
return "%s %s (%s)" % (self.name.slug, self.value, self.display_name.strip('()'))
945+
else:
946+
return "%s %s" % (self.name.slug, self.value)
947+
948+
@classmethod
949+
def from_form_entry_str(cls, s):
950+
"""Create an instance from the form_entry_str format
951+
952+
Expected format is "<tag> <value>[ (<display name>)]"
953+
Any text after the value is treated as the display name, with whitespace replaced by
954+
spaces and leading/trailing parentheses stripped.
955+
"""
956+
parts = s.split(None, 2)
957+
display_name = ' '.join(parts[2:]).strip('()')
958+
kwargs = dict(name_id=parts[0], value=parts[1])
959+
if display_name:
960+
kwargs['display_name'] = display_name
961+
return cls(**kwargs)
962+
963+
@classmethod
964+
def from_sibling_class(cls, sib):
965+
"""Create an instance with same base attributes as another subclass instance"""
966+
kwargs = dict()
967+
for field in ExtResource._meta.get_fields():
968+
value = getattr(sib, field.name, None)
969+
if value:
970+
kwargs[field.name] = value
971+
return cls(**kwargs)
972+
973+
class DocExtResource(ExtResource):
974+
doc = ForeignKey(Document) # Should this really be to DocumentInfo rather than Document?
975+
934976
class RelatedDocHistory(models.Model):
935977
source = ForeignKey('DocHistory')
936978
target = ForeignKey('DocAlias', related_name="reversely_related_document_history_set")

ietf/doc/utils.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from ietf.doc.models import TelechatDocEvent, DocumentActionHolder
3030
from ietf.name.models import DocReminderTypeName, DocRelationshipName
3131
from ietf.group.models import Role, Group
32-
from ietf.ietfauth.utils import has_role
32+
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, is_individual_draft_author
3333
from ietf.person.models import Person
3434
from ietf.review.models import ReviewWish
3535
from ietf.utils import draft, text
@@ -149,6 +149,11 @@ def can_unadopt_draft(user, doc):
149149
else:
150150
return False
151151

152+
def can_edit_docextresources(user, doc):
153+
return (has_role(user, ("Secretariat", "Area Director"))
154+
or is_authorized_in_doc_stream(user, doc)
155+
or is_individual_draft_author(user, doc))
156+
152157
def two_thirds_rule( recused=0 ):
153158
# For standards-track, need positions from 2/3 of the non-recused current IESG.
154159
active = Role.objects.filter(name="ad",group__type="area",group__state="active").count()
@@ -1131,3 +1136,22 @@ def augment_docs_and_user_with_user_info(docs, user):
11311136
for d in docs:
11321137
d.tracked_in_personal_community_list = d.pk in tracked
11331138
d.has_review_wish = d.pk in review_wished
1139+
1140+
1141+
def update_doc_extresources(doc, new_resources, by):
1142+
old_res_strs = '\n'.join(sorted(r.to_form_entry_str() for r in doc.docextresource_set.all()))
1143+
new_res_strs = '\n'.join(sorted(r.to_form_entry_str() for r in new_resources))
1144+
1145+
if old_res_strs == new_res_strs:
1146+
return False # no change
1147+
1148+
doc.docextresource_set.all().delete()
1149+
for new_res in new_resources:
1150+
new_res.doc = doc
1151+
new_res.save()
1152+
e = DocEvent(doc=doc, rev=doc.rev, by=by, type='changed_document')
1153+
e.desc = "Changed document external resources from:\n\n%s\n\nto:\n\n%s" % (
1154+
old_res_strs, new_res_strs)
1155+
e.save()
1156+
doc.save_with_history([e])
1157+
return True

ietf/doc/views_draft.py

Lines changed: 10 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from django.conf import settings
1414
from django.contrib import messages
1515
from django.contrib.auth.decorators import login_required
16-
from django.core.exceptions import ValidationError, ObjectDoesNotExist
1716
from django.db.models import Q
1817
from django.http import HttpResponseRedirect, Http404
1918
from django.shortcuts import render, get_object_or_404, redirect
@@ -35,21 +34,22 @@
3534
from ietf.doc.utils import ( add_state_change_event, can_adopt_draft, can_unadopt_draft,
3635
get_tags_for_stream_id, nice_consensus, update_action_holders,
3736
update_reminder, update_telechat, make_notify_changed_event, get_initial_notify,
38-
set_replaces_for_document, default_consensus, tags_suffix, )
37+
set_replaces_for_document, default_consensus, tags_suffix, can_edit_docextresources,
38+
update_doc_extresources )
3939
from ietf.doc.lastcall import request_last_call
4040
from ietf.doc.fields import SearchableDocAliasesField
41+
from ietf.doc.forms import ExtResourceForm
4142
from ietf.group.models import Group, Role, GroupFeatures
4243
from ietf.iesg.models import TelechatDate
43-
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, user_is_person, is_individual_draft_author
44+
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, user_is_person
4445
from ietf.ietfauth.utils import role_required
4546
from ietf.mailtrigger.utils import gather_address_lists
4647
from ietf.message.models import Message
47-
from ietf.name.models import IntendedStdLevelName, DocTagName, StreamName, ExtResourceName
48+
from ietf.name.models import IntendedStdLevelName, DocTagName, StreamName
4849
from ietf.person.fields import SearchableEmailField
4950
from ietf.person.models import Person, Email
5051
from ietf.utils.mail import send_mail, send_mail_message, on_behalf_of
5152
from ietf.utils.textupload import get_cleaned_text_file_content
52-
from ietf.utils.validators import validate_external_resource_value
5353
from ietf.utils import log
5454
from ietf.utils.response import permission_denied
5555

@@ -1205,81 +1205,23 @@ def edit_consensus(request, name):
12051205

12061206

12071207
def edit_doc_extresources(request, name):
1208-
class DocExtResourceForm(forms.Form):
1209-
resources = forms.CharField(widget=forms.Textarea, label="Additional Resources", required=False,
1210-
help_text=("Format: 'tag value (Optional description)'."
1211-
" Separate multiple entries with newline. When the value is a URL, use https:// where possible.") )
1212-
1213-
def clean_resources(self):
1214-
lines = [x.strip() for x in self.cleaned_data["resources"].splitlines() if x.strip()]
1215-
errors = []
1216-
for l in lines:
1217-
parts = l.split()
1218-
if len(parts) == 1:
1219-
errors.append("Too few fields: Expected at least tag and value: '%s'" % l)
1220-
elif len(parts) >= 2:
1221-
name_slug = parts[0]
1222-
try:
1223-
name = ExtResourceName.objects.get(slug=name_slug)
1224-
except ObjectDoesNotExist:
1225-
errors.append("Bad tag in '%s': Expected one of %s" % (l, ', '.join([ o.slug for o in ExtResourceName.objects.all() ])))
1226-
continue
1227-
value = parts[1]
1228-
try:
1229-
validate_external_resource_value(name, value)
1230-
except ValidationError as e:
1231-
e.message += " : " + value
1232-
errors.append(e)
1233-
if errors:
1234-
raise ValidationError(errors)
1235-
return lines
1236-
1237-
def format_resources(resources, fs="\n"):
1238-
res = []
1239-
for r in resources:
1240-
if r.display_name:
1241-
res.append("%s %s (%s)" % (r.name.slug, r.value, r.display_name.strip('()')))
1242-
else:
1243-
res.append("%s %s" % (r.name.slug, r.value))
1244-
# TODO: This is likely problematic if value has spaces. How then to delineate value and display_name? Perhaps in the short term move to comma or pipe separation.
1245-
# Might be better to shift to a formset instead of parsing these lines.
1246-
return fs.join(res)
1247-
12481208
doc = get_object_or_404(Document, name=name)
12491209

1250-
if not (has_role(request.user, ("Secretariat", "Area Director"))
1251-
or is_authorized_in_doc_stream(request.user, doc)
1252-
or is_individual_draft_author(request.user, doc)):
1210+
if not can_edit_docextresources(request.user, doc):
12531211
permission_denied(request, "You do not have the necessary permissions to view this page.")
12541212

1255-
old_resources = format_resources(doc.docextresource_set.all())
1256-
12571213
if request.method == 'POST':
1258-
form = DocExtResourceForm(request.POST)
1214+
form = ExtResourceForm(request.POST)
12591215
if form.is_valid():
1260-
old_resources = sorted(old_resources.splitlines())
1261-
new_resources = sorted(form.cleaned_data['resources'])
1262-
if old_resources != new_resources:
1263-
doc.docextresource_set.all().delete()
1264-
for u in new_resources:
1265-
parts = u.split(None, 2)
1266-
name = parts[0]
1267-
value = parts[1]
1268-
display_name = ' '.join(parts[2:]).strip('()')
1269-
doc.docextresource_set.create(value=value, name_id=name, display_name=display_name)
1270-
new_resources = format_resources(doc.docextresource_set.all())
1271-
e = DocEvent(doc=doc, rev=doc.rev, by=request.user.person, type='changed_document')
1272-
e.desc = "Changed document external resources from:\n\n%s\n\nto:\n\n%s" % (old_resources, new_resources)
1273-
e.save()
1274-
doc.save_with_history([e])
1216+
if update_doc_extresources(doc, form.cleaned_data['resources'], by=request.user.person):
12751217
messages.success(request,"Document resources updated.")
12761218
else:
12771219
messages.info(request,"No change in Document resources.")
12781220
return redirect('ietf.doc.views_doc.document_main', name=doc.name)
12791221
else:
1280-
form = DocExtResourceForm(initial={'resources': old_resources, })
1222+
form = ExtResourceForm(initial={'resources': doc.docextresource_set.all()})
12811223

1282-
info = "Valid tags:<br><br> %s" % ', '.join([ o.slug for o in ExtResourceName.objects.all().order_by('slug') ])
1224+
info = "Valid tags:<br><br> %s" % ', '.join(form.valid_resource_tags())
12831225
# May need to explain the tags more - probably more reason to move to a formset.
12841226
title = "Additional document resources"
12851227
return render(request, 'doc/edit_field.html',dict(doc=doc, form=form, title=title, info=info) )

ietf/name/fixtures/names.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3507,6 +3507,21 @@
35073507
"model": "mailtrigger.mailtrigger",
35083508
"pk": "doc_expires_soon"
35093509
},
3510+
{
3511+
"fields": {
3512+
"cc": [],
3513+
"desc": "Recipients when a change to the external resources for a document is requested.",
3514+
"to": [
3515+
"doc_ad",
3516+
"doc_group_chairs",
3517+
"doc_group_delegates",
3518+
"doc_shepherd",
3519+
"doc_stream_manager"
3520+
]
3521+
},
3522+
"model": "mailtrigger.mailtrigger",
3523+
"pk": "doc_external_resource_change_requested"
3524+
},
35103525
{
35113526
"fields": {
35123527
"cc": [],

0 commit comments

Comments
 (0)