The new API requires at least one event and will automatically save a snapshot of the document and related state. Document.save() will now throw an exception if called directly, as the new API is intended to ensure that documents are saved with both an appropriate snapsnot and relevant history log, both of which are easily defeated by just calling .save() directly. To simplify things, the snapshot is generated after the changes to a document have been made (in anticipation of coming changes), instead of before as was usual. While revising the existing code to work with this API, a couple of missing events was discovered: - In draft expiry, a "Document has expired" event was only generated in case an IESG process had started on the document - now it's always generated, as the document changes its state in any case - Synchronization updates like title and abstract amendmends from the RFC Editor were silently (except for RFC publication) applied and not accompanied by a descriptive event - they now are - do_replace in the Secretariat tools now adds an event - Proceedings post_process in the Secretariat tools now adds an event - do_withdraw in the Secretariat tools now adds an event A migration is needed for snapshotting all documents, takes a while to run. It turns out that a single document had a bad foreign key so the migration fixes that too. - Legacy-Id: 10101
484 lines
22 KiB
Python
484 lines
22 KiB
Python
# edit/create view for groups
|
|
|
|
import re
|
|
import datetime
|
|
|
|
from django import forms
|
|
from django.shortcuts import render, get_object_or_404, redirect
|
|
from django.http import HttpResponse, HttpResponseForbidden, Http404, HttpResponseRedirect
|
|
from django.utils.html import mark_safe
|
|
from django.contrib.auth.decorators import login_required
|
|
|
|
import debug # pyflakes:ignore
|
|
|
|
from ietf.doc.models import Document, DocAlias, DocTagName, State
|
|
from ietf.doc.utils import get_tags_for_stream_id
|
|
from ietf.doc.utils_charter import charter_name_for_group
|
|
from ietf.group.models import ( Group, Role, GroupEvent, GroupHistory, GroupStateName,
|
|
GroupStateTransitions, GroupTypeName, GroupURL, ChangeStateGroupEvent )
|
|
from ietf.group.utils import save_group_in_history, can_manage_group_type
|
|
from ietf.group.utils import get_group_or_404
|
|
from ietf.ietfauth.utils import has_role
|
|
from ietf.person.fields import SearchableEmailsField
|
|
from ietf.person.models import Person, Email
|
|
from ietf.group.mails import ( email_iesg_secretary_re_charter, email_iesg_secretary_personnel_change,
|
|
email_interested_parties_re_changed_delegates )
|
|
from ietf.utils.ordereddict import insert_after_in_ordered_dict
|
|
|
|
MAX_GROUP_DELEGATES = 3
|
|
|
|
class GroupForm(forms.Form):
|
|
name = forms.CharField(max_length=255, label="Name", required=True)
|
|
acronym = forms.CharField(max_length=10, label="Acronym", required=True)
|
|
state = forms.ModelChoiceField(GroupStateName.objects.all(), label="State", required=True)
|
|
chairs = SearchableEmailsField(label="Chairs", required=False, only_users=True)
|
|
secretaries = SearchableEmailsField(label="Secretaries", required=False, only_users=True)
|
|
techadv = SearchableEmailsField(label="Technical Advisors", required=False, only_users=True)
|
|
delegates = SearchableEmailsField(label="Delegates", required=False, only_users=True, max_entries=MAX_GROUP_DELEGATES,
|
|
help_text=mark_safe("Chairs can delegate the authority to update the state of group documents - at most %s persons at a given time." % MAX_GROUP_DELEGATES))
|
|
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=False)
|
|
parent = forms.ModelChoiceField(Group.objects.filter(state="active").order_by('name'), empty_label="(None)", required=False)
|
|
list_email = forms.CharField(max_length=64, required=False)
|
|
list_subscribe = forms.CharField(max_length=255, required=False)
|
|
list_archive = forms.CharField(max_length=255, required=False)
|
|
urls = forms.CharField(widget=forms.Textarea, label="Additional URLs", help_text="Format: https://site/path (Optional description). Separate multiple entries with newline. Prefer HTTPS URLs where possible.", required=False)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.group = kwargs.pop('group', None)
|
|
self.group_type = kwargs.pop('group_type', False)
|
|
|
|
super(self.__class__, self).__init__(*args, **kwargs)
|
|
|
|
if self.group_type == "rg":
|
|
self.fields["state"].queryset = self.fields["state"].queryset.exclude(slug__in=("bof", "bof-conc"))
|
|
|
|
# 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())]
|
|
|
|
if self.group:
|
|
self.fields['acronym'].widget.attrs['readonly'] = ""
|
|
|
|
if self.group_type == "rg":
|
|
self.fields['ad'].widget = forms.HiddenInput()
|
|
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(acronym="irtf")
|
|
self.fields['parent'].initial = self.fields['parent'].queryset.first()
|
|
self.fields['parent'].widget = forms.HiddenInput()
|
|
else:
|
|
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(type="area")
|
|
self.fields['parent'].label = "IETF Area"
|
|
|
|
def clean_acronym(self):
|
|
# Changing the acronym of an already existing group will cause 404s all
|
|
# over the place, loose history, and generally muck up a lot of
|
|
# things, so we don't permit it
|
|
if self.group:
|
|
return self.group.acronym # no change permitted
|
|
|
|
acronym = self.cleaned_data['acronym'].strip().lower()
|
|
|
|
if not re.match(r'^[a-z][a-z0-9]+$', acronym):
|
|
raise forms.ValidationError("Acronym is invalid, must be at least two characters and only contain lowercase letters and numbers starting with a letter.")
|
|
|
|
# be careful with acronyms, requiring confirmation to take existing or override historic
|
|
existing = Group.objects.filter(acronym__iexact=acronym)
|
|
if existing:
|
|
existing = existing[0]
|
|
|
|
confirmed = self.data.get("confirm_acronym", False)
|
|
|
|
def insert_confirm_field(label, initial):
|
|
# set required to false, we don't need it since we do the
|
|
# validation of the field in here, and otherwise the
|
|
# browser and Django may barf
|
|
insert_after_in_ordered_dict(self.fields, "confirm_acronym", forms.BooleanField(label=label, required=False), after="acronym")
|
|
# we can't set initial, it's ignored since the form is bound, instead mutate the data
|
|
self.data = self.data.copy()
|
|
self.data["confirm_acronym"] = initial
|
|
|
|
if existing and existing.type_id == self.group_type:
|
|
if existing.state_id == "bof":
|
|
insert_confirm_field(label="Turn BoF %s into proposed %s and start chartering it" % (existing.acronym, existing.type.name), initial=True)
|
|
if confirmed:
|
|
return acronym
|
|
else:
|
|
raise forms.ValidationError("Warning: Acronym used for an existing BoF (%s)." % existing.name)
|
|
else:
|
|
insert_confirm_field(label="Set state of %s %s to proposed and start chartering it" % (existing.acronym, existing.type.name), initial=False)
|
|
if confirmed:
|
|
return acronym
|
|
else:
|
|
raise forms.ValidationError("Warning: Acronym used for an existing %s (%s, %s)." % (existing.type.name, existing.name, existing.state.name if existing.state else "unknown state"))
|
|
|
|
if existing:
|
|
raise forms.ValidationError("Acronym used for an existing group (%s)." % existing.name)
|
|
|
|
old = GroupHistory.objects.filter(acronym__iexact=acronym, type__in=("wg", "rg"))
|
|
if old:
|
|
insert_confirm_field(label="Confirm reusing acronym %s" % old[0].acronym, initial=False)
|
|
if confirmed:
|
|
return acronym
|
|
else:
|
|
raise forms.ValidationError("Warning: Acronym used for a historic group.")
|
|
|
|
return acronym
|
|
|
|
def clean_urls(self):
|
|
return [x.strip() for x in self.cleaned_data["urls"].splitlines() if x.strip()]
|
|
|
|
def clean_delegates(self):
|
|
if len(self.cleaned_data["delegates"]) > MAX_GROUP_DELEGATES:
|
|
raise forms.ValidationError("At most %s delegates can be appointed at the same time, please remove %s delegates." % (
|
|
MAX_GROUP_DELEGATES, len(self.cleaned_data["delegates"]) - MAX_GROUP_DELEGATES))
|
|
return self.cleaned_data["delegates"]
|
|
|
|
|
|
def format_urls(urls, fs="\n"):
|
|
res = []
|
|
for u in urls:
|
|
if u.name:
|
|
res.append(u"%s (%s)" % (u.url, u.name))
|
|
else:
|
|
res.append(u.url)
|
|
return fs.join(res)
|
|
|
|
def get_or_create_initial_charter(group, group_type):
|
|
charter_name = charter_name_for_group(group)
|
|
|
|
try:
|
|
charter = Document.objects.get(docalias__name=charter_name)
|
|
except Document.DoesNotExist:
|
|
charter = Document.objects.create(
|
|
name=charter_name,
|
|
type_id="charter",
|
|
title=group.name,
|
|
group=group,
|
|
abstract=group.name,
|
|
rev="00-00",
|
|
)
|
|
charter.set_state(State.objects.get(used=True, type="charter", slug="notrev"))
|
|
|
|
# Create an alias as well
|
|
DocAlias.objects.create(name=charter.name, document=charter)
|
|
|
|
return charter
|
|
|
|
@login_required
|
|
def submit_initial_charter(request, group_type=None, acronym=None):
|
|
|
|
# This needs refactoring.
|
|
# The signature assumed you could have groups with the same name, but with different types, which we do not allow.
|
|
# Consequently, this can be called with an existing group acronym and a type
|
|
# that doesn't match the existing group type. The code below essentially ignores the group_type argument.
|
|
#
|
|
# If possible, the use of get_or_create_initial_charter should be moved
|
|
# directly into charter_submit, and this function should go away.
|
|
|
|
if acronym==None:
|
|
raise Http404
|
|
|
|
group = get_object_or_404(Group, acronym=acronym)
|
|
if not group.features.has_chartering_process:
|
|
raise Http404
|
|
|
|
# This is where we start ignoring the passed in group_type
|
|
group_type = group.type_id
|
|
|
|
if not can_manage_group_type(request.user, group_type):
|
|
return HttpResponseForbidden("You don't have permission to access this view")
|
|
|
|
if not group.charter:
|
|
group.charter = get_or_create_initial_charter(group, group_type)
|
|
group.save()
|
|
|
|
return redirect('charter_submit', name=group.charter.name, option="initcharter")
|
|
|
|
@login_required
|
|
def edit(request, group_type=None, acronym=None, action="edit"):
|
|
"""Edit or create a group, notifying parties as
|
|
necessary and logging changes as group events."""
|
|
if not can_manage_group_type(request.user, group_type):
|
|
return HttpResponseForbidden("You don't have permission to access this view")
|
|
|
|
if action == "edit":
|
|
group = get_object_or_404(Group, acronym=acronym)
|
|
new_group = False
|
|
elif action in ("create","charter"):
|
|
group = None
|
|
new_group = True
|
|
else:
|
|
raise Http404
|
|
|
|
if not group_type and group:
|
|
group_type = group.type_id
|
|
|
|
if request.method == 'POST':
|
|
form = GroupForm(request.POST, group=group, group_type=group_type)
|
|
if form.is_valid():
|
|
clean = form.cleaned_data
|
|
if new_group:
|
|
try:
|
|
group = Group.objects.get(acronym=clean["acronym"])
|
|
save_group_in_history(group)
|
|
group.time = datetime.datetime.now()
|
|
group.save()
|
|
except Group.DoesNotExist:
|
|
group = Group.objects.create(name=clean["name"],
|
|
acronym=clean["acronym"],
|
|
type=GroupTypeName.objects.get(slug=group_type),
|
|
state=clean["state"]
|
|
)
|
|
|
|
e = ChangeStateGroupEvent(group=group, type="changed_state")
|
|
e.time = group.time
|
|
e.by = request.user.person
|
|
e.state_id = clean["state"].slug
|
|
e.desc = "Group created in state %s" % clean["state"].name
|
|
e.save()
|
|
else:
|
|
save_group_in_history(group)
|
|
|
|
|
|
if action == "charter" and not group.charter: # make sure we have a charter
|
|
group.charter = get_or_create_initial_charter(group, group_type)
|
|
|
|
changes = []
|
|
|
|
def desc(attr, new, old):
|
|
entry = "%(attr)s changed to <b>%(new)s</b> from %(old)s"
|
|
if new_group:
|
|
entry = "%(attr)s changed to <b>%(new)s</b>"
|
|
|
|
return entry % dict(attr=attr, new=new, old=old)
|
|
|
|
def diff(attr, name):
|
|
v = getattr(group, attr)
|
|
if clean[attr] != v:
|
|
changes.append((attr, clean[attr], desc(name, clean[attr], v)))
|
|
setattr(group, attr, clean[attr])
|
|
|
|
# update the attributes, keeping track of what we're doing
|
|
diff('name', "Name")
|
|
diff('acronym', "Acronym")
|
|
diff('state', "State")
|
|
diff('parent', "IETF Area" if group.type=="wg" else "Group parent")
|
|
diff('list_email', "Mailing list email")
|
|
diff('list_subscribe', "Mailing list subscribe address")
|
|
diff('list_archive', "Mailing list archive")
|
|
|
|
personnel_change_text=""
|
|
# update roles
|
|
for attr, slug, title in [('ad','ad','Shepherding AD'), ('chairs', 'chair', "Chairs"), ('secretaries', 'secr', "Secretaries"), ('techadv', 'techadv', "Tech Advisors"), ('delegates', 'delegate', "Delegates")]:
|
|
new = clean[attr]
|
|
if attr == 'ad':
|
|
new = [ new.role_email('ad'),] if new else []
|
|
old = Email.objects.filter(role__group=group, role__name=slug).select_related("person")
|
|
if set(new) != set(old):
|
|
changes.append((attr, new, desc(title,
|
|
", ".join(x.get_name() for x in new),
|
|
", ".join(x.get_name() for x in old))))
|
|
group.role_set.filter(name=slug).delete()
|
|
for e in new:
|
|
Role.objects.get_or_create(name_id=slug, email=e, group=group, person=e.person)
|
|
added = set(new) - set(old)
|
|
deleted = set(old) - set(new)
|
|
if added:
|
|
change_text=title + ' added: ' + ", ".join(x.formatted_email() for x in added)
|
|
personnel_change_text+=change_text+"\n"
|
|
if deleted:
|
|
change_text=title + ' deleted: ' + ", ".join(x.formatted_email() for x in deleted)
|
|
personnel_change_text+=change_text+"\n"
|
|
email_interested_parties_re_changed_delegates(request, group, title, added, deleted)
|
|
|
|
if personnel_change_text!="":
|
|
email_iesg_secretary_personnel_change(request, group, personnel_change_text)
|
|
|
|
# update urls
|
|
new_urls = clean['urls']
|
|
old_urls = format_urls(group.groupurl_set.order_by('url'), ", ")
|
|
if ", ".join(sorted(new_urls)) != old_urls:
|
|
changes.append(('urls', new_urls, desc('Urls', ", ".join(sorted(new_urls)), old_urls)))
|
|
group.groupurl_set.all().delete()
|
|
# Add new ones
|
|
for u in new_urls:
|
|
m = re.search('(?P<url>[\w\d:#@%/;$()~_?\+-=\\\.&]+)( \((?P<name>.+)\))?', u)
|
|
if m:
|
|
if m.group('name'):
|
|
url = GroupURL(url=m.group('url'), name=m.group('name'), group=group)
|
|
else:
|
|
url = GroupURL(url=m.group('url'), name='', group=group)
|
|
url.save()
|
|
|
|
group.time = datetime.datetime.now()
|
|
|
|
if changes and not new_group:
|
|
for attr, new, desc in changes:
|
|
if attr == 'state':
|
|
ChangeStateGroupEvent.objects.create(group=group, time=group.time, state=new, by=request.user.person, type="changed_state", desc=desc)
|
|
else:
|
|
GroupEvent.objects.create(group=group, time=group.time, by=request.user.person, type="info_changed", desc=desc)
|
|
|
|
group.save()
|
|
|
|
if action=="charter":
|
|
return redirect('charter_submit', name=group.charter.name, option="initcharter")
|
|
|
|
return HttpResponseRedirect(group.about_url())
|
|
else: # form.is_valid()
|
|
if not new_group:
|
|
ad_role = group.ad_role()
|
|
init = dict(name=group.name,
|
|
acronym=group.acronym,
|
|
state=group.state,
|
|
chairs=Email.objects.filter(role__group=group, role__name="chair"),
|
|
secretaries=Email.objects.filter(role__group=group, role__name="secr"),
|
|
techadv=Email.objects.filter(role__group=group, role__name="techadv"),
|
|
delegates=Email.objects.filter(role__group=group, role__name="delegate"),
|
|
ad=ad_role and ad_role.person and ad_role.person.id,
|
|
parent=group.parent.id if group.parent else None,
|
|
list_email=group.list_email if group.list_email else None,
|
|
list_subscribe=group.list_subscribe if group.list_subscribe else None,
|
|
list_archive=group.list_archive if group.list_archive else None,
|
|
urls=format_urls(group.groupurl_set.all()),
|
|
)
|
|
else:
|
|
init = dict(ad=request.user.person.id if group_type == "wg" and has_role(request.user, "Area Director") else None,
|
|
)
|
|
form = GroupForm(initial=init, group=group, group_type=group_type)
|
|
|
|
return render(request, 'group/edit.html',
|
|
dict(group=group,
|
|
form=form,
|
|
action=action))
|
|
|
|
|
|
|
|
class ConcludeForm(forms.Form):
|
|
instructions = forms.CharField(widget=forms.Textarea(attrs={'rows': 30}), required=True)
|
|
|
|
@login_required
|
|
def conclude(request, acronym, group_type=None):
|
|
"""Request the closing of group, prompting for instructions."""
|
|
group = get_group_or_404(acronym, group_type)
|
|
|
|
if not can_manage_group_type(request.user, group.type_id):
|
|
return HttpResponseForbidden("You don't have permission to access this view")
|
|
|
|
if request.method == 'POST':
|
|
form = ConcludeForm(request.POST)
|
|
if form.is_valid():
|
|
instructions = form.cleaned_data['instructions']
|
|
|
|
email_iesg_secretary_re_charter(request, group, "Request closing of group", instructions)
|
|
|
|
e = GroupEvent(group=group, by=request.user.person)
|
|
e.type = "requested_close"
|
|
e.desc = "Requested closing group"
|
|
e.save()
|
|
|
|
return redirect(group.features.about_page, group_type=group_type, acronym=group.acronym)
|
|
else:
|
|
form = ConcludeForm()
|
|
|
|
return render(request, 'group/conclude.html', {
|
|
'form': form,
|
|
'group': group,
|
|
'group_type': group_type,
|
|
})
|
|
|
|
@login_required
|
|
def customize_workflow(request, group_type, acronym):
|
|
group = get_group_or_404(acronym, group_type)
|
|
if not group.features.customize_workflow:
|
|
raise Http404
|
|
|
|
if (not has_role(request.user, "Secretariat") and
|
|
not group.role_set.filter(name="chair", person__user=request.user)):
|
|
return HttpResponseForbidden("You don't have permission to access this view")
|
|
|
|
if group_type == "rg":
|
|
stream_id = "irtf"
|
|
MANDATORY_STATES = ('candidat', 'active', 'rfc-edit', 'pub', 'dead')
|
|
else:
|
|
stream_id = "ietf"
|
|
MANDATORY_STATES = ('c-adopt', 'wg-doc', 'sub-pub')
|
|
|
|
if request.method == 'POST':
|
|
action = request.POST.get("action")
|
|
if action == "setstateactive":
|
|
active = request.POST.get("active") == "1"
|
|
try:
|
|
state = State.objects.exclude(slug__in=MANDATORY_STATES).get(pk=request.POST.get("state"))
|
|
except State.DoesNotExist:
|
|
return HttpResponse("Invalid state %s" % request.POST.get("state"))
|
|
|
|
if active:
|
|
group.unused_states.remove(state)
|
|
else:
|
|
group.unused_states.add(state)
|
|
|
|
# redirect so the back button works correctly, otherwise
|
|
# repeated POSTs fills up the history
|
|
return redirect("ietf.group.edit.customize_workflow", group_type=group.type_id, acronym=group.acronym)
|
|
|
|
if action == "setnextstates":
|
|
try:
|
|
state = State.objects.get(pk=request.POST.get("state"))
|
|
except State.DoesNotExist:
|
|
return HttpResponse("Invalid state %s" % request.POST.get("state"))
|
|
|
|
next_states = State.objects.filter(used=True, type='draft-stream-%s' % stream_id, pk__in=request.POST.getlist("next_states"))
|
|
unused = group.unused_states.all()
|
|
if set(next_states.exclude(pk__in=unused)) == set(state.next_states.exclude(pk__in=unused)):
|
|
# just use the default
|
|
group.groupstatetransitions_set.filter(state=state).delete()
|
|
else:
|
|
transitions, _ = GroupStateTransitions.objects.get_or_create(group=group, state=state)
|
|
transitions.next_states = next_states
|
|
|
|
return redirect("ietf.group.edit.customize_workflow", group_type=group.type_id, acronym=group.acronym)
|
|
|
|
if action == "settagactive":
|
|
active = request.POST.get("active") == "1"
|
|
try:
|
|
tag = DocTagName.objects.get(pk=request.POST.get("tag"))
|
|
except DocTagName.DoesNotExist:
|
|
return HttpResponse("Invalid tag %s" % request.POST.get("tag"))
|
|
|
|
if active:
|
|
group.unused_tags.remove(tag)
|
|
else:
|
|
group.unused_tags.add(tag)
|
|
|
|
return redirect("ietf.group.edit.customize_workflow", group_type=group.type_id, acronym=group.acronym)
|
|
|
|
# put some info for the template on tags and states
|
|
unused_tags = group.unused_tags.all().values_list('slug', flat=True)
|
|
tags = DocTagName.objects.filter(slug__in=get_tags_for_stream_id(stream_id))
|
|
for t in tags:
|
|
t.used = t.slug not in unused_tags
|
|
|
|
unused_states = group.unused_states.all().values_list('slug', flat=True)
|
|
states = State.objects.filter(used=True, type="draft-stream-%s" % stream_id)
|
|
transitions = dict((o.state, o) for o in group.groupstatetransitions_set.all())
|
|
for s in states:
|
|
s.used = s.slug not in unused_states
|
|
s.mandatory = s.slug in MANDATORY_STATES
|
|
|
|
default_n = s.next_states.all()
|
|
if s in transitions:
|
|
n = transitions[s].next_states.all()
|
|
else:
|
|
n = default_n
|
|
|
|
s.next_states_checkboxes = [(x in n, x in default_n, x) for x in states]
|
|
s.used_next_states = [x for x in n if x.slug not in unused_states]
|
|
|
|
return render(request, 'group/customize_workflow.html', {
|
|
'group': group,
|
|
'states': states,
|
|
'tags': tags,
|
|
})
|