465 lines
19 KiB
Python
465 lines
19 KiB
Python
# Copyright The IETF Trust 2012-2020, All Rights Reserved
|
|
# group milestone editing views
|
|
|
|
import datetime
|
|
import calendar
|
|
|
|
from django import forms
|
|
from django.contrib import messages
|
|
from django.http import HttpResponseRedirect, HttpResponseBadRequest, Http404
|
|
from django.shortcuts import render, redirect
|
|
from django.contrib.auth.decorators import login_required
|
|
|
|
import debug # pyflakes:ignore
|
|
|
|
from ietf.doc.models import DocEvent
|
|
from ietf.doc.utils import get_chartering_type
|
|
from ietf.doc.fields import SearchableDocumentsField
|
|
from ietf.group.models import GroupMilestone, MilestoneGroupEvent
|
|
from ietf.group.utils import (save_milestone_in_history, can_manage_all_groups_of_type, can_manage_group,
|
|
milestone_reviewer_for_group_type, get_group_or_404, has_role)
|
|
from ietf.name.models import GroupMilestoneStateName
|
|
from ietf.group.mails import email_milestones_changed
|
|
from ietf.utils.fields import DatepickerDateField
|
|
from ietf.utils.response import permission_denied
|
|
|
|
class MilestoneForm(forms.Form):
|
|
id = forms.IntegerField(required=True, widget=forms.HiddenInput)
|
|
|
|
desc = forms.CharField(max_length=500, label="Milestone", required=True)
|
|
due = DatepickerDateField(date_format="MM yyyy", picker_settings={"min-view-mode": "months", "autoclose": "1", "view-mode": "years" }, required=True)
|
|
order = forms.IntegerField(required=True, widget=forms.HiddenInput)
|
|
docs = SearchableDocumentsField(label="Internet-Drafts", required=False, help_text="Any Internet-Drafts that the milestone concerns.")
|
|
resolved_checkbox = forms.BooleanField(required=False, label="Resolved")
|
|
resolved = forms.CharField(label="Resolved as", max_length=50, required=False)
|
|
|
|
delete = forms.BooleanField(required=False, initial=False)
|
|
|
|
review = forms.ChoiceField(label="Review action", help_text="Choose whether to accept or reject the proposed changes.",
|
|
choices=(("accept", "Accept"), ("reject", "Reject and delete"), ("noaction", "No action")),
|
|
required=False, initial="noaction", widget=forms.RadioSelect)
|
|
|
|
def __init__(self, needs_review, reviewer, desc_editable, *args, **kwargs):
|
|
m = self.milestone = kwargs.pop("instance", None)
|
|
|
|
uses_dates = kwargs.pop("uses_dates", True)
|
|
|
|
can_review = not needs_review
|
|
|
|
if m:
|
|
needs_review = m.state_id == "review"
|
|
|
|
if not "initial" in kwargs:
|
|
kwargs["initial"] = {}
|
|
kwargs["initial"].update(dict(id=m.pk,
|
|
desc=m.desc,
|
|
due=m.due,
|
|
order=m.order,
|
|
resolved_checkbox=bool(m.resolved),
|
|
resolved=m.resolved,
|
|
docs=m.docs.all(),
|
|
delete=False,
|
|
review="noaction" if can_review and needs_review else "",
|
|
))
|
|
|
|
kwargs["prefix"] = "m%s" % m.pk
|
|
|
|
super(MilestoneForm, self).__init__(*args, **kwargs)
|
|
|
|
if not uses_dates:
|
|
self.fields.pop('due')
|
|
else:
|
|
self.fields.pop('order')
|
|
|
|
self.fields["resolved"].widget.attrs["data-default"] = "Done"
|
|
|
|
if needs_review and self.milestone and self.milestone.state_id != "review":
|
|
self.fields["desc"].widget.attrs["readonly"] = True
|
|
|
|
self.changed = False
|
|
|
|
if not (needs_review and can_review):
|
|
self.fields["review"].widget = forms.HiddenInput()
|
|
|
|
self.needs_review = needs_review
|
|
|
|
if not desc_editable:
|
|
self.fields["desc"].widget.attrs["readonly"] = True
|
|
|
|
def clean_resolved(self):
|
|
r = self.cleaned_data["resolved"].strip()
|
|
|
|
if self.cleaned_data["resolved_checkbox"]:
|
|
if not r:
|
|
raise forms.ValidationError('Please provide explanation (like "Done") for why the milestone is no longer due.')
|
|
else:
|
|
r = ""
|
|
|
|
return r
|
|
|
|
@login_required
|
|
def edit_milestones(request, acronym, group_type=None, milestone_set="current"):
|
|
# milestones_set + needs_review: we have several paths into this view
|
|
# management (IRTF chair/AD/...)/Secr. -> all actions on current + add new
|
|
# group chair -> limited actions on current + add new for review
|
|
# (re)charter -> all actions on existing in state charter + add new in state charter
|
|
#
|
|
# For charters we store the history on the charter document to not confuse people.
|
|
group = get_group_or_404(acronym, group_type)
|
|
if not group.features.has_milestones:
|
|
raise Http404
|
|
|
|
needs_review = False
|
|
if can_manage_group(request.user, group):
|
|
can_change_uses_milestone_dates = True
|
|
if not can_manage_all_groups_of_type(request.user, group.type_id):
|
|
# The user is chair or similar, not AD:
|
|
can_change_uses_milestone_dates = False
|
|
if milestone_set == "current":
|
|
needs_review = True
|
|
else:
|
|
permission_denied(request, "You are not authorized to edit the milestones of this group.")
|
|
|
|
desc_editable = has_role(request.user,["Secretariat","Area Director","IRTF Chair"])
|
|
|
|
if milestone_set == "current":
|
|
title = "Edit milestones for %s %s" % (group.acronym, group.type.name)
|
|
milestones = group.groupmilestone_set.filter(state__in=("active", "review"))
|
|
elif milestone_set == "charter":
|
|
title = "Edit charter milestones for %s %s" % (group.acronym, group.type.name)
|
|
milestones = group.groupmilestone_set.filter(state="charter")
|
|
|
|
reviewer = milestone_reviewer_for_group_type(group_type)
|
|
|
|
forms = []
|
|
|
|
milestones_dict = dict((str(m.id), m) for m in milestones)
|
|
|
|
def due_month_year_to_date(c):
|
|
if isinstance(c, dict):
|
|
y = c["due"].year
|
|
m = c["due"].month
|
|
else:
|
|
y = c.year
|
|
m = c.month
|
|
first_day, last_day = calendar.monthrange(y, m)
|
|
return datetime.date(y, m, last_day)
|
|
|
|
def set_attributes_from_form(f, m):
|
|
c = f.cleaned_data
|
|
m.group = group
|
|
if milestone_set == "current":
|
|
if needs_review:
|
|
m.state = GroupMilestoneStateName.objects.get(slug="review")
|
|
else:
|
|
m.state = GroupMilestoneStateName.objects.get(slug="active")
|
|
elif milestone_set == "charter":
|
|
m.state = GroupMilestoneStateName.objects.get(slug="charter")
|
|
|
|
m.desc = c["desc"]
|
|
m.resolved = c["resolved"]
|
|
if 'due' in f.fields:
|
|
m.due = due_month_year_to_date(c)
|
|
else:
|
|
m.order = c["order"]
|
|
|
|
def milestone_changed(f, m):
|
|
# we assume that validation has run
|
|
if not m or not f.is_valid():
|
|
return True
|
|
|
|
c = f.cleaned_data
|
|
|
|
changed = (
|
|
c["desc"] != m.desc or
|
|
c["resolved"] != m.resolved or
|
|
set(c["docs"]) != set(m.docs.all()) or
|
|
c.get("review") in ("accept", "reject")
|
|
)
|
|
if 'due' in f.fields:
|
|
changed = changed or due_month_year_to_date(c) != due_month_year_to_date(m.due)
|
|
else:
|
|
changed = changed or c["order"] != m.order
|
|
return changed
|
|
|
|
def save_milestone_form(f):
|
|
c = f.cleaned_data
|
|
|
|
if f.milestone:
|
|
m = f.milestone
|
|
initial_state = m.state_id
|
|
|
|
named_milestone = 'milestone "%s"' % m.desc
|
|
if milestone_set == "charter":
|
|
named_milestone = "charter " + named_milestone
|
|
|
|
# compute changes
|
|
history = None
|
|
|
|
if c["delete"]:
|
|
history = save_milestone_in_history(m)
|
|
m.state_id = "deleted"
|
|
|
|
changes = ['Deleted %s' % named_milestone]
|
|
else:
|
|
changes = ['Changed %s' % named_milestone]
|
|
|
|
if m.state_id == "review" and not needs_review and c["review"] != "noaction":
|
|
if not history:
|
|
history = save_milestone_in_history(m)
|
|
|
|
if c["review"] == "accept":
|
|
m.state_id = "active"
|
|
changes.append("set state to active from review, accepting new milestone")
|
|
elif c["review"] == "reject":
|
|
m.state_id = "deleted"
|
|
changes.append("set state to deleted from review, rejecting new milestone")
|
|
|
|
|
|
if c["desc"] != m.desc and not needs_review and desc_editable:
|
|
if not history:
|
|
history = save_milestone_in_history(m)
|
|
m.desc = c["desc"]
|
|
changes.append('set description to "%s"' % m.desc)
|
|
|
|
if 'due' in f.fields:
|
|
c_due = due_month_year_to_date(c)
|
|
m_due = due_month_year_to_date(m.due)
|
|
if c_due != m_due:
|
|
if not history:
|
|
history = save_milestone_in_history(m)
|
|
changes.append('set due date to %s from %s' % (c_due.strftime("%B %Y"), m.due.strftime("%B %Y")))
|
|
m.due = c_due
|
|
else:
|
|
order = c["order"]
|
|
if order != m.order:
|
|
if not history:
|
|
history = save_milestone_in_history(m)
|
|
changes.append("Milestone order changed from %s to %s" % ( m.order, order ))
|
|
m.order = order
|
|
|
|
resolved = c["resolved"]
|
|
if resolved != m.resolved:
|
|
if resolved and not m.resolved:
|
|
changes.append('resolved as "%s"' % resolved)
|
|
elif not resolved and m.resolved:
|
|
changes.append("reverted to not being resolved")
|
|
elif resolved and m.resolved:
|
|
changes.append('set resolution to "%s"' % resolved)
|
|
|
|
if not history:
|
|
history = save_milestone_in_history(m)
|
|
|
|
m.resolved = resolved
|
|
|
|
new_docs = set(c["docs"])
|
|
old_docs = set(m.docs.all())
|
|
if new_docs != old_docs:
|
|
added = new_docs - old_docs
|
|
if added:
|
|
changes.append('added %s to milestone' % ", ".join(d.name for d in added))
|
|
|
|
removed = old_docs - new_docs
|
|
if removed:
|
|
changes.append('removed %s from milestone' % ", ".join(d.name for d in removed))
|
|
|
|
if not history:
|
|
history = save_milestone_in_history(m)
|
|
|
|
m.docs.clear()
|
|
m.docs.set(new_docs)
|
|
|
|
if len(changes) > 1:
|
|
if c["delete"]:
|
|
messages.warning(request, "Found conflicting form data: both delete action and milestone changes for '%s'. "
|
|
"Ignoring the delete; if deletion is wanted, please mark for deletion without making other changes." % (m.desc, ))
|
|
m.state_id = initial_state
|
|
changes[0] = 'Changed %s' % named_milestone
|
|
m.save()
|
|
return ", ".join(changes)
|
|
|
|
elif c["delete"]:
|
|
m.save()
|
|
return ", ".join(changes)
|
|
|
|
else: # new milestone
|
|
m = f.milestone = GroupMilestone()
|
|
set_attributes_from_form(f, m)
|
|
m.save()
|
|
|
|
m.docs.set(c["docs"])
|
|
|
|
named_milestone = 'milestone "%s"' % m.desc
|
|
if milestone_set == "charter":
|
|
named_milestone = "charter " + named_milestone
|
|
|
|
desc = 'Added %s' % (named_milestone, )
|
|
if m.state_id == 'review':
|
|
desc += ' for review'
|
|
if 'due' in f.fields:
|
|
desc += ', due %s' % (m.due.strftime("%B %Y"), )
|
|
return desc
|
|
|
|
form_errors = False
|
|
|
|
if request.method == 'POST':
|
|
|
|
action = request.POST.get("action", "review")
|
|
|
|
if action == "switch":
|
|
if can_change_uses_milestone_dates:
|
|
if group.uses_milestone_dates:
|
|
group.uses_milestone_dates=False
|
|
group.save()
|
|
for order, milestone in enumerate(group.groupmilestone_set.filter(state_id='active').order_by('due','id')):
|
|
milestone.order = order
|
|
milestone.save()
|
|
else:
|
|
group.uses_milestone_dates=True
|
|
group.save()
|
|
for m in milestones:
|
|
forms.append(MilestoneForm(needs_review, reviewer, desc_editable, instance=m, uses_dates=group.uses_milestone_dates))
|
|
else:
|
|
permission_denied(request, "You don't have the required permissions to change the 'uses milestone dates' setting")
|
|
else:
|
|
# parse out individual milestone forms
|
|
for prefix in request.POST.getlist("prefix"):
|
|
if not prefix: # empty form
|
|
continue
|
|
|
|
# new milestones have non-existing ids so instance end up as None
|
|
instance = milestones_dict.get(request.POST.get(prefix + "-id", ""), None)
|
|
f = MilestoneForm(needs_review, reviewer, True, request.POST, prefix=prefix, instance=instance, uses_dates=group.uses_milestone_dates)
|
|
forms.append(f)
|
|
|
|
form_errors = form_errors or not f.is_valid()
|
|
|
|
f.changed = milestone_changed(f, f.milestone)
|
|
if f.is_valid() and f.cleaned_data.get("review") in ("accept", "reject"):
|
|
f.needs_review = False
|
|
|
|
if action == "review":
|
|
for f in forms:
|
|
if f.is_valid():
|
|
# let's fill in the form milestone so we can output it in the template
|
|
if not f.milestone:
|
|
f.milestone = GroupMilestone()
|
|
set_attributes_from_form(f, f.milestone)
|
|
elif action == "save" and not form_errors:
|
|
changes = []
|
|
states = []
|
|
for f in forms:
|
|
change = save_milestone_form(f)
|
|
|
|
if not change:
|
|
continue
|
|
|
|
if milestone_set == "charter":
|
|
DocEvent.objects.create(doc=group.charter, rev=group.charter.rev, type="changed_charter_milestone",
|
|
by=request.user.person, desc=change)
|
|
else:
|
|
MilestoneGroupEvent.objects.create(group=group, type="changed_milestone",
|
|
by=request.user.person, desc=change, milestone=f.milestone)
|
|
|
|
changes.append(change)
|
|
states.append(f.milestone.state_id)
|
|
|
|
|
|
if milestone_set == "current":
|
|
email_milestones_changed(request, group, changes, states)
|
|
|
|
if milestone_set == "charter":
|
|
return redirect('ietf.doc.views_doc.document_main', name=group.charter.name)
|
|
else:
|
|
return HttpResponseRedirect(group.about_url())
|
|
else:
|
|
for m in milestones:
|
|
forms.append(MilestoneForm(needs_review, reviewer, desc_editable, instance=m, uses_dates=group.uses_milestone_dates))
|
|
|
|
can_reset = milestone_set == "charter" and get_chartering_type(group.charter) == "rechartering"
|
|
|
|
empty_form = MilestoneForm(needs_review, reviewer, True, uses_dates=group.uses_milestone_dates)
|
|
|
|
if group.uses_milestone_dates:
|
|
forms.sort(key=lambda f: f.milestone.due if f.milestone else datetime.date.max)
|
|
else:
|
|
forms.sort(key=lambda f: (f.milestone is None, f.milestone.order if f.milestone and f.milestone.order is not None else -1) )
|
|
|
|
return render(request, 'group/edit_milestones.html',
|
|
dict(group=group,
|
|
title=title,
|
|
forms=forms,
|
|
form_errors=form_errors,
|
|
empty_form=empty_form,
|
|
all_forms=forms + [empty_form],
|
|
milestone_set=milestone_set,
|
|
needs_review=needs_review,
|
|
reviewer=reviewer,
|
|
can_reset=can_reset,
|
|
can_change_uses_milestone_dates=can_change_uses_milestone_dates))
|
|
|
|
@login_required
|
|
def reset_charter_milestones(request, acronym, group_type=None):
|
|
"""Reset charter milestones to the currently in-use milestones."""
|
|
group = get_group_or_404(acronym, group_type)
|
|
if not group.features.has_milestones:
|
|
raise Http404
|
|
|
|
if not can_manage_group(request.user, group):
|
|
permission_denied(request, "You are not authorized to change the milestones for this group.")
|
|
|
|
current_milestones = group.groupmilestone_set.filter(state="active")
|
|
charter_milestones = group.groupmilestone_set.filter(state="charter")
|
|
|
|
if request.method == 'POST':
|
|
try:
|
|
milestone_ids = [int(v) for v in request.POST.getlist("milestone")]
|
|
except ValueError as e:
|
|
return HttpResponseBadRequest("error in list of ids - %s" % e)
|
|
|
|
# delete existing
|
|
for m in charter_milestones:
|
|
save_milestone_in_history(m)
|
|
|
|
m.state_id = "deleted"
|
|
m.save()
|
|
|
|
DocEvent.objects.create(type="changed_charter_milestone",
|
|
doc=group.charter,
|
|
rev=group.charter.rev,
|
|
desc='Deleted milestone "%s"' % m.desc,
|
|
by=request.user.person,
|
|
)
|
|
|
|
# add current
|
|
for m in current_milestones.filter(id__in=milestone_ids):
|
|
new = GroupMilestone.objects.create(group=m.group,
|
|
state_id="charter",
|
|
desc=m.desc,
|
|
due=m.due,
|
|
order=m.order,
|
|
resolved=m.resolved,
|
|
)
|
|
new.docs.clear()
|
|
new.docs.set(m.docs.all())
|
|
|
|
if group.uses_milestone_dates:
|
|
desc='Added milestone "%s", due %s, from current group milestones' % (new.desc, new.due.strftime("%B %Y"))
|
|
else:
|
|
desc='Added milestone "%s" from current group milestones' % ( new.desc, )
|
|
DocEvent.objects.create(type="changed_charter_milestone",
|
|
doc=group.charter,
|
|
rev=group.charter.rev,
|
|
desc=desc,
|
|
by=request.user.person,
|
|
)
|
|
|
|
|
|
return redirect('ietf.group.milestones.edit_milestones;charter', group_type=group.type_id, acronym=group.acronym)
|
|
|
|
return render(request, 'group/reset_charter_milestones.html',
|
|
dict(group=group,
|
|
charter_milestones=charter_milestones,
|
|
current_milestones=current_milestones,
|
|
))
|