datatracker/ietf/doc/forms.py
Jennifer Richards c848a5a00b
feat: async investigate_fragment task; celery results backend (#8428)
* feat: investigate docs asynchronously

* refactor: move script to its own js file

* fix: adjust polling interval/duration

* test: test new task

* fix: extra tag/fix whitespace

* style: restore whitespace (I hope)

* style: black/standard styling

* test: fix test of investigate view

* test: improve/delint tests
2025-01-17 09:16:15 -06:00

291 lines
13 KiB
Python

# Copyright The IETF Trust 2013-2020, All Rights Reserved
# -*- coding: utf-8 -*-
import datetime
import debug #pyflakes:ignore
from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import validate_email
from ietf.doc.fields import SearchableDocumentField, SearchableDocumentsField
from ietf.doc.models import RelatedDocument, DocExtResource
from ietf.iesg.models import TelechatDate
from ietf.iesg.utils import telechat_page_count
from ietf.person.fields import SearchablePersonField, SearchablePersonsField
from ietf.person.models import Email, Person
from ietf.name.models import ExtResourceName
from ietf.utils.timezone import date_today
from ietf.utils.validators import validate_external_resource_value
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.")
returning_item = forms.BooleanField(required=False)
def __init__(self, *args, **kwargs):
super(self.__class__, self).__init__(*args, **kwargs)
dates = [d.date for d in TelechatDate.objects.active().order_by('date')]
init = kwargs['initial'].get("telechat_date")
if init and init not in dates:
dates.insert(0, init)
self.page_count = {}
choice_display = {}
for d in dates:
self.page_count[d] = telechat_page_count(date=d).for_approval
choice_display[d] = '%s (%s pages)' % (d.strftime("%Y-%m-%d"),self.page_count[d])
if d - date_today() < datetime.timedelta(days=13):
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]
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):
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)
def __init__(self, *args, **kwargs):
super(self.__class__, self).__init__(*args, **kwargs)
# if previous AD is now ex-AD, append that person to the list
ad_pk = self.initial.get('ad')
choices = self.fields['ad'].choices
if ad_pk and ad_pk not in [pk for pk, name in choices]:
self.fields['ad'].choices = list(choices) + [("", "-------"), (ad_pk, Person.objects.get(pk=ad_pk).plain_name())]
class NotifyForm(forms.Form):
notify = forms.CharField(
widget=forms.Textarea,
max_length=1023,
help_text="List of email addresses to receive state notifications, separated by comma.",
label="Notification list",
required=False,
)
def clean_notify(self):
# As long as the widget is a Textarea, users will separate addresses with newlines, whether that matches the instructions or not
# We have been allowing nameaddrs for a long time (there are many Documents with namaddrs in their notify field)
# python set doesn't preserve order, so in an attempt to mostly preserve the order of what was entered, we'll use
# a dict (whose keys are guaranteed to be ordered) to cull out duplicates
nameaddrs=dict()
duplicate_addrspecs = set()
bad_nameaddrs = []
for nameaddr in self.cleaned_data["notify"].replace("\n", ",").split(","):
stripped = nameaddr.strip()
if stripped == "":
continue
if "<" in stripped:
if stripped[-1] != ">":
bad_nameaddrs.append(nameaddr)
continue
addrspec = stripped[stripped.find("<")+1:-1]
else:
addrspec = stripped
try:
validate_email(addrspec)
except ValidationError:
bad_nameaddrs.append(nameaddr)
if addrspec in nameaddrs:
duplicate_addrspecs.add(addrspec)
continue
else:
nameaddrs[addrspec] = stripped
error_messages = []
if len(duplicate_addrspecs) != 0:
error_messages.append(f'Duplicate addresses: {", ".join(duplicate_addrspecs)}')
if len(bad_nameaddrs) != 0:
error_messages.append(f'Invalid addresses: {", ".join(bad_nameaddrs)}')
if len(error_messages) != 0:
raise ValidationError(" and ".join(error_messages))
return ", ".join(nameaddrs.values())
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):
rfc = SearchableDocumentField(
label="Referenced RFC",
help_text="The RFC that is approved for downref",
required=True,
doc_type="rfc")
drafts = SearchableDocumentsField(
label="Internet-Drafts that makes the reference",
help_text="The Internet-Drafts that approve the downref in their Last Call",
required=True)
def clean_rfc(self):
if 'rfc' not in self.cleaned_data:
raise forms.ValidationError("Please provide a referenced RFC and a referencing Internet-Draft")
rfc = self.cleaned_data['rfc']
if rfc.type_id != "rfc":
raise forms.ValidationError("Cannot find the RFC: " + rfc.name)
return rfc
def clean_drafts(self):
if 'drafts' not in self.cleaned_data:
raise forms.ValidationError("Please provide a referenced RFC and a referencing Internet-Draft")
v_err_names = []
drafts = self.cleaned_data['drafts']
for d in drafts:
state = d.get_state("draft-iesg")
if not state or state.slug not in IESG_APPROVED_STATE_LIST:
v_err_names.append(d.name)
if v_err_names:
raise forms.ValidationError("Internet-Draft is not yet approved: " + ", ".join(v_err_names))
return drafts
def clean(self):
if 'rfc' not in self.cleaned_data or 'drafts' not in self.cleaned_data:
raise forms.ValidationError("Please provide a referenced RFC and a referencing Internet-Draft")
v_err_pairs = []
rfc = self.cleaned_data['rfc']
drafts = self.cleaned_data['drafts']
for d in drafts:
if RelatedDocument.objects.filter(source=d, target=rfc, relationship_id='downref-approval'):
v_err_pairs.append(f"{d.name} --> RFC {rfc.rfc_number}")
if v_err_pairs:
raise forms.ValidationError("Downref is already in the registry: " + ", ".join(v_err_pairs))
if 'save_downref_anyway' not in self.data:
# this check is skipped if the save_downref_anyway button is used
v_err_refnorm = ""
for d in drafts:
if not RelatedDocument.objects.filter(source=d, target=rfc, relationship_id='refnorm'):
if v_err_refnorm:
v_err_refnorm = v_err_refnorm + " or " + d.name
else:
v_err_refnorm = d.name
if v_err_refnorm:
v_err_refnorm_prefix = f"There does not seem to be a normative reference to RFC {rfc.rfc_number} by "
raise forms.ValidationError(v_err_refnorm_prefix + v_err_refnorm)
class ExtResourceForm(forms.Form):
resources = forms.CharField(widget=forms.Textarea, label="Additional Resources", required=False,
help_text=("Format: 'tag value (Optional description)'."
" Separate multiple entries with newline. When the value is a URL, use https:// where possible.") )
def __init__(self, *args, initial=None, extresource_model=None, **kwargs):
self.extresource_model = extresource_model
if initial:
kwargs = kwargs.copy()
resources = initial.get('resources')
if resources is not None and not isinstance(resources, str):
initial = initial.copy()
# Convert objects to string representation
initial['resources'] = self.format_resources(resources)
kwargs['initial'] = initial
super(ExtResourceForm, self).__init__(*args, **kwargs)
@staticmethod
def format_resources(resources, fs="\n"):
# Might be better to shift to a formset instead of parsing these lines.
return fs.join([r.to_form_entry_str() for r in resources])
def clean_resources(self):
"""Clean the resources field
The resources field is a newline-separated set of resource entries. Each entry
should be "<tag> <value>" or "<tag> <value> (<display name>)" with any whitespace
delimiting the components. This clean only validates that the tag and value are
present and valid - tag must be a recognized ExtResourceName and value is
validated using validate_external_resource_value(). Further interpretation of
the resource is performed int he clean() method.
"""
lines = [x.strip() for x in self.cleaned_data["resources"].splitlines() if x.strip()]
errors = []
for l in lines:
parts = l.split()
if len(parts) == 1:
errors.append("Too few fields: Expected at least tag and value: '%s'" % l)
elif len(parts) >= 2:
name_slug = parts[0]
try:
name = ExtResourceName.objects.get(slug=name_slug)
except ObjectDoesNotExist:
errors.append("Bad tag in '%s': Expected one of %s" % (l, ', '.join([ o.slug for o in ExtResourceName.objects.all() ])))
continue
value = parts[1]
try:
validate_external_resource_value(name, value)
except ValidationError as e:
e.message += " : " + value
errors.append(e)
if errors:
raise ValidationError(errors)
return lines
def clean(self):
"""Clean operations after all other fields are cleaned by clean_<field> methods
Converts resource strings into ExtResource model instances.
"""
cleaned_data = super(ExtResourceForm, self).clean()
cleaned_resources = []
cls = self.extresource_model or DocExtResource
for crs in cleaned_data.get('resources', []):
cleaned_resources.append(
cls.from_form_entry_str(crs)
)
cleaned_data['resources'] = cleaned_resources
@staticmethod
def valid_resource_tags():
return ExtResourceName.objects.all().order_by('slug').values_list('slug', flat=True)
class InvestigateForm(forms.Form):
name_fragment = forms.CharField(
label="File name or fragment to investigate",
required=True,
help_text=(
"Enter a filename such as draft-ietf-some-draft-00.txt or a fragment like draft-ietf-some-draft using at least 8 characters. The search will also work for files that are not necessarily drafts."
),
min_length=8,
)
task_id = forms.CharField(required=False, widget=forms.HiddenInput)
def clean_name_fragment(self):
disallowed_characters = ["%", "/", "\\", "*"]
name_fragment = self.cleaned_data["name_fragment"]
# Manual inspection of the directories at the time of this writing shows
# looking for files with less than 8 characters in the name is not useful
# Requiring this will help protect against the secretariat unintentionally
# matching every draft.
if any(c in name_fragment for c in disallowed_characters):
raise ValidationError(f"The following characters are disallowed: {', '.join(disallowed_characters)}")
return name_fragment