Liaison changes from IETF 94 demo. Includes tab for Action Needed statements and ability to add comments to history. Commit ready for merge

- Legacy-Id: 10464
This commit is contained in:
Ryan Cross 2015-11-12 00:26:52 +00:00
parent 3f1b281a74
commit 1ea5dcf907
14 changed files with 221 additions and 52 deletions

View file

@ -117,6 +117,9 @@ def validate_emails(value):
# -------------------------------------------------
# Form Classes
# -------------------------------------------------
class AddCommentForm(forms.Form):
comment = forms.CharField(required=True, widget=forms.Textarea)
private = forms.BooleanField(label="Private comment", required=False,help_text="If this box is checked the comment will not appear in the statement's public history view.")
class RadioRenderer(RadioFieldRenderer):
def render(self):
@ -127,6 +130,7 @@ class RadioRenderer(RadioFieldRenderer):
class SearchLiaisonForm(forms.Form):
'''Expects initial keyword argument queryset which then gets filtered based on form data'''
text = forms.CharField(required=False)
scope = forms.ChoiceField(choices=(("all", "All text fields"), ("title", "Title field")), required=False, initial='title', widget=forms.RadioSelect(renderer=RadioRenderer))
source = forms.CharField(required=False)
@ -135,11 +139,11 @@ class SearchLiaisonForm(forms.Form):
end_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='End date', required=False)
def __init__(self, *args, **kwargs):
self.state = kwargs.pop('state')
self.queryset = kwargs.pop('queryset')
super(SearchLiaisonForm, self).__init__(*args, **kwargs)
def get_results(self):
results = LiaisonStatement.objects.filter(state=self.state)
results = self.queryset
if self.is_bound:
query = self.cleaned_data.get('text')
if query:
@ -157,11 +161,19 @@ class SearchLiaisonForm(forms.Form):
source = self.cleaned_data.get('source')
if source:
results = results.filter(Q(from_groups__name__icontains=source) | Q(from_groups__acronym__iexact=source))
source_list = source.split(',')
if len(source_list) > 1:
results = results.filter(Q(from_groups__acronym__in=source_list))
else:
results = results.filter(Q(from_groups__name__icontains=source) | Q(from_groups__acronym__iexact=source))
destination = self.cleaned_data.get('destination')
if destination:
results = results.filter(Q(to_groups__name__icontains=destination) | Q(to_groups__acronym__iexact=destination))
destination_list = destination.split(',')
if len(destination_list) > 1:
results = results.filter(Q(to_groups__acronym__in=destination_list))
else:
results = results.filter(Q(to_groups__name__icontains=destination) | Q(to_groups__acronym__iexact=destination))
start_date = self.cleaned_data.get('start_date')
end_date = self.cleaned_data.get('end_date')
@ -192,10 +204,13 @@ class CustomModelMultipleChoiceField(forms.ModelMultipleChoiceField):
class LiaisonModelForm(BetterModelForm):
'''Specify fields which require a custom widget or that are not part of the model'''
from_groups = forms.ModelMultipleChoiceField(queryset=Group.objects.all(),label=u'Groups')
'''Specify fields which require a custom widget or that are not part of the model.
NOTE: from_groups and to_groups are marked as not required because select2 has
a problem with validating
'''
from_groups = forms.ModelMultipleChoiceField(queryset=Group.objects.all(),label=u'Groups',required=False)
from_contact = forms.EmailField()
to_groups = forms.ModelMultipleChoiceField(queryset=Group.objects,label=u'Groups')
to_groups = forms.ModelMultipleChoiceField(queryset=Group.objects,label=u'Groups',required=False)
deadline = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='Deadline', required=True)
related_to = SearchableLiaisonStatementsField(label=u'Related Liaison Statement', required=False)
submitted_date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1" }, label='Submission date', required=True, initial=datetime.date.today())
@ -221,6 +236,7 @@ class LiaisonModelForm(BetterModelForm):
def __init__(self, user, *args, **kwargs):
super(LiaisonModelForm, self).__init__(*args, **kwargs)
self.user = user
self.edit = False
self.person = get_person_for_user(user)
self.is_new = not self.instance.pk
@ -237,6 +253,18 @@ class LiaisonModelForm(BetterModelForm):
self.set_from_fields()
self.set_to_fields()
def clean_from_groups(self):
from_groups = self.cleaned_data.get('from_groups')
if not from_groups:
raise forms.ValidationError('You must specify a From Group')
return from_groups
def clean_to_groups(self):
to_groups = self.cleaned_data.get('to_groups')
if not to_groups:
raise forms.ValidationError('You must specify a To Group')
return to_groups
def clean_from_contact(self):
contact = self.cleaned_data.get('from_contact')
try:
@ -294,6 +322,7 @@ class LiaisonModelForm(BetterModelForm):
self.save_related_liaisons()
self.save_attachments()
self.save_tags()
return self.instance
@ -350,6 +379,11 @@ class LiaisonModelForm(BetterModelForm):
if related.target not in new_related:
related.delete()
def save_tags(self):
'''Create tags as needed'''
if self.instance.deadline and not self.instance.tags.filter(slug='taken'):
self.instance.tags.add('required')
def set_from_fields(self):
assert NotImplemented
@ -488,9 +522,9 @@ class EditLiaisonForm(LiaisonModelForm):
self.fields['from_groups'].choices = get_internal_choices(self.user)
else:
if has_role(self.user, "Secretariat"):
queryset = Group.objects.filter(type="sdo", state="active").order_by('name')
queryset = Group.objects.filter(type="sdo").order_by('name')
else:
queryset = Group.objects.filter(type="sdo", state="active", role__person=self.person, role__name__in=("liaiman", "auth")).distinct().order_by('name')
queryset = Group.objects.filter(type="sdo", role__person=self.person, role__name__in=("liaiman", "auth")).distinct().order_by('name')
self.fields['from_contact'].widget.attrs['readonly'] = True
self.fields['from_groups'].queryset = queryset
@ -501,10 +535,10 @@ class EditLiaisonForm(LiaisonModelForm):
if self.instance.is_outgoing():
# if the user is a Liaison Manager and nothing more, reduce to set to his SDOs
if has_role(self.user, "Liaison Manager") and not self.person.role_set.filter(name__in=('ad','chair'),group__state='active'):
queryset = Group.objects.filter(type="sdo", state="active", role__person=self.person, role__name="liaiman").distinct().order_by('name')
queryset = Group.objects.filter(type="sdo", role__person=self.person, role__name="liaiman").distinct().order_by('name')
else:
# get all outgoing entities
queryset = Group.objects.filter(type="sdo", state="active").order_by('name')
queryset = Group.objects.filter(type="sdo").order_by('name')
self.fields['to_groups'].queryset = queryset
else:
self.fields['to_groups'].choices = get_internal_choices(None)

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
def create_required_tags(apps, schema_editor):
LiaisonStatement = apps.get_model("liaisons", "LiaisonStatement")
for s in LiaisonStatement.objects.filter(deadline__isnull=False):
if not s.tags.filter(slug='taken'):
s.tags.add('required')
class Migration(migrations.Migration):
dependencies = [
('liaisons', '0007_auto_20151009_1220'),
]
operations = [
migrations.RunPython(create_required_tags),
]

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('liaisons', '0008_auto_20151110_1352'),
]
operations = [
migrations.RemoveField(
model_name='liaisonstatement',
name='from_name',
),
migrations.RemoveField(
model_name='liaisonstatement',
name='to_name',
),
]

View file

@ -28,7 +28,7 @@ class LiaisonStatement(models.Model):
from_groups = models.ManyToManyField(Group, blank=True, related_name='liaisonstatement_from_set')
from_contact = models.ForeignKey(Email, blank=True, null=True)
to_groups = models.ManyToManyField(Group, blank=True, related_name='liaisonstatement_to_set')
to_contacts = models.CharField(max_length=255, help_text="Contacts at recipient body")
to_contacts = models.CharField(max_length=255, help_text="Contacts at recipient group")
response_contacts = models.CharField(blank=True, max_length=255, help_text="Where to send a response") # RFC4053
technical_contacts = models.CharField(blank=True, max_length=255, help_text="Who to contact for clarification") # RFC4053
@ -44,10 +44,6 @@ class LiaisonStatement(models.Model):
attachments = models.ManyToManyField(Document, through='LiaisonStatementAttachment', blank=True)
state = models.ForeignKey(LiaisonStatementState, default='pending')
# remove these fields post upgrade
from_name = models.CharField(max_length=255, help_text="Name of the sender body")
to_name = models.CharField(max_length=255, help_text="Name of the recipient body")
def __unicode__(self):
return self.title or u"<no title>"

View file

@ -338,6 +338,35 @@ class LiaisonManagementTests(TestCase):
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
def test_add_comment(self):
make_test_data()
liaison = make_liaison_models()
# test unauthorized
url = urlreverse('ietf.liaisons.views.liaison_history',kwargs=dict(object_id=liaison.pk))
addurl = urlreverse('ietf.liaisons.views.add_comment',kwargs=dict(object_id=liaison.pk))
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertEqual(len(q("a.btn:contains('Add Comment')")), 0)
login_testing_unauthorized(self, "secretary", addurl)
# public comment
self.client.login(username="secretary", password="secretary+password")
comment = 'Test comment'
r = self.client.post(addurl, dict(comment=comment))
self.assertEqual(r.status_code,302)
qs = liaison.liaisonstatementevent_set.filter(type='comment',desc=comment)
self.assertTrue(qs.count(),1)
# private comment
r = self.client.post(addurl, dict(comment='Private comment',private=True),follow=True)
self.assertEqual(r.status_code,200)
self.assertTrue('Private comment' in r.content)
self.client.logout()
r = self.client.get(url)
self.assertFalse('Private comment' in r.content)
def test_taken_care_of(self):
make_test_data()
liaison = make_liaison_models()
@ -721,7 +750,7 @@ class LiaisonManagementTests(TestCase):
r = self.client.post(url,
dict(from_groups=from_groups,
from_contact=submitter.email_address(),
to_groups=str(to_group.pk),
to_groups=[str(to_group.pk)],
to_contacts='to_contacts@example.com',
technical_contacts="technical_contact@example.com",
action_holder_contacts="action_holder_contacts@example.com",

View file

@ -20,8 +20,9 @@ urlpatterns += patterns('ietf.liaisons.views',
# Views
urlpatterns += patterns('ietf.liaisons.views',
(r'^$', 'liaison_list'),
(r'^(?P<state>(posted|pending|dead))/$', 'liaison_list'),
(r'^(?P<state>(posted|pending|dead))/', 'liaison_list'),
(r'^(?P<object_id>\d+)/$', 'liaison_detail'),
(r'^(?P<object_id>\d+)/addcomment/$', 'add_comment'),
(r'^(?P<object_id>\d+)/edit/$', 'liaison_edit'),
(r'^(?P<object_id>\d+)/edit-attachment/(?P<doc_id>[A-Za-z0-9._+-]+)$', 'liaison_edit_attachment'),
(r'^(?P<object_id>\d+)/delete-attachment/(?P<attach_id>[A-Za-z0-9._+-]+)$', 'liaison_delete_attachment'),

View file

@ -18,7 +18,7 @@ from ietf.liaisons.models import (LiaisonStatement,LiaisonStatementEvent,
from ietf.liaisons.utils import (get_person_for_user, can_add_outgoing_liaison,
can_add_incoming_liaison, can_edit_liaison,can_submit_liaison_required,
can_add_liaison)
from ietf.liaisons.forms import liaison_form_factory, SearchLiaisonForm, EditAttachmentForm
from ietf.liaisons.forms import liaison_form_factory, SearchLiaisonForm, EditAttachmentForm, AddCommentForm
from ietf.liaisons.mails import notify_pending_by_email, send_liaison_by_email
from ietf.liaisons.fields import select2_id_liaison_json
from ietf.name.models import LiaisonStatementTagName
@ -288,6 +288,32 @@ def redirect_for_approval(request, object_id=None):
# -------------------------------------------------
# View Functions
# -------------------------------------------------
@role_required('Secretariat',)
def add_comment(request, object_id):
"""Add comment to history"""
statement = get_object_or_404(LiaisonStatement, id=object_id)
login = request.user.person
if request.method == 'POST':
form = AddCommentForm(request.POST)
if form.is_valid():
if form.cleaned_data.get('private'):
type_id = 'private_comment'
else:
type_id = 'comment'
LiaisonStatementEvent.objects.create(
by=login,
type_id=type_id,
statement=statement,
desc=form.cleaned_data['comment']
)
messages.success(request, 'Comment added.')
return redirect("ietf.liaisons.views.liaison_history", object_id=statement.id)
else:
form = AddCommentForm()
return render(request, 'liaisons/add_comment.html',dict(liaison=statement,form=form))
@can_submit_liaison_required
def liaison_add(request, type=None, **kwargs):
@ -330,6 +356,8 @@ def liaison_history(request, object_id):
"""Show the history for a specific liaison statement"""
liaison = get_object_or_404(LiaisonStatement, id=object_id)
events = liaison.liaisonstatementevent_set.all().order_by("-time", "-id").select_related("by")
if not has_role(request.user, "Secretariat"):
events = events.exclude(type='private_comment')
return render(request, "liaisons/detail_history.html", {
'events':events,
@ -439,6 +467,7 @@ def liaison_edit_attachment(request, object_id, doc_id):
def liaison_list(request, state='posted'):
"""A generic list view with tabs for different states: posted, pending, dead"""
# use prefetch to speed up main liaison page load
selected_menu_entry = state
liaisons = LiaisonStatement.objects.filter(state=state).prefetch_related(
Prefetch('from_groups',queryset=Group.objects.order_by('acronym').select_related('type'),to_attr='prefetched_from_groups'),
Prefetch('to_groups',queryset=Group.objects.order_by('acronym').select_related('type'),to_attr='prefetched_to_groups'),
@ -451,15 +480,20 @@ def liaison_list(request, state='posted'):
msg = "Restricted to participants who are authorized to submit liaison statements on behalf of the various IETF entities"
return HttpResponseForbidden(msg)
if 'tags' in request.GET:
value = request.GET.get('tags')
liaisons = liaisons.filter(tags__slug=value)
selected_menu_entry = 'action needed'
# perform search / filter
if 'text' in request.GET:
form = SearchLiaisonForm(data=request.GET,state=state)
form = SearchLiaisonForm(data=request.GET,queryset=liaisons)
search_conducted = True
if form.is_valid():
results = form.get_results()
liaisons = results
else:
form = SearchLiaisonForm(state=state)
form = SearchLiaisonForm(queryset=liaisons)
search_conducted = False
# perform sort
@ -482,6 +516,7 @@ def liaison_list(request, state='posted'):
entries = []
entries.append(("Posted", urlreverse("ietf.liaisons.views.liaison_list", kwargs={'state':'posted'})))
if can_add_liaison(request.user):
entries.append(("Action Needed", urlreverse("ietf.liaisons.views.liaison_list", kwargs={'state':'posted'}) + '?tags=required'))
entries.append(("Pending", urlreverse("ietf.liaisons.views.liaison_list", kwargs={'state':'pending'})))
entries.append(("Dead", urlreverse("ietf.liaisons.views.liaison_list", kwargs={'state':'dead'})))
@ -494,7 +529,7 @@ def liaison_list(request, state='posted'):
return render(request, 'liaisons/liaison_base.html', {
'liaisons':liaisons,
'selected_menu_entry':state,
'selected_menu_entry':selected_menu_entry,
'menu_entries':entries,
'menu_actions':actions,
'sort':sort,

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
def populate_names(apps, schema_editor):
LiaisonStatementEventTypeName = apps.get_model("name", "LiaisonStatementEventTypeName")
LiaisonStatementEventTypeName.objects.create(slug="private_comment", order=10, name="Private Comment")
class Migration(migrations.Migration):
dependencies = [
('name', '0009_auto_20151021_1102'),
]
operations = [
migrations.RunPython(populate_names),
]

View file

@ -124,6 +124,7 @@ var attachmentWidget = {
var liaisonForm = {
initVariables : function() {
liaisonForm.is_edit_form = liaisonForm.form.attr("data-edit-form") == "True"
liaisonForm.from_groups = liaisonForm.form.find('#id_from_groups');
liaisonForm.from_contact = liaisonForm.form.find('#id_from_contact');
liaisonForm.response_contacts = liaisonForm.form.find('#id_response_contacts');
@ -189,6 +190,11 @@ var liaisonForm = {
},
updateInfo : function(first_time, sender) {
// don't overwrite fields when editing existing liaison
if(liaisonForm.is_edit_form){
return false;
}
var from_ids = liaisonForm.from_groups.val();
var to_ids = liaisonForm.to_groups.val();
var url = liaisonForm.form.data("ajaxInfoUrl");
@ -278,28 +284,8 @@ var searchForm = {
// search form, based on doc search feature
init : function() {
searchForm.form = $(this);
searchForm.form.find(".search_field input,select").change(searchForm.toggleSubmit).click(searchForm.toggleSubmit).keyup(searchForm.toggleSubmit);
$("#search-clear-btn").bind("click", searchForm.clearForm);
},
anyAdvancedActive : function() {
var advanced = false;
var by = searchForm.form.find("input[name=by]:checked");
if (by.length > 0) {
by.closest(".search_field").find("input,select").not("input[name=by]").each(function () {
if ($.trim(this.value)) {
advanced = true;
}
});
}
return advanced;
},
toggleSubmit : function() {
var textSearch = $.trim($("#id_text").val());
searchForm.form.find("button[type=submit]").get(0).disabled = !textSearch && !searchForm.anyAdvancedActive();
},
clearForm : function() {
var form = $(this).parents("form");
@ -313,5 +299,5 @@ $(document).ready(function () {
$.ajaxSetup({ traditional: true });
$('form.liaisons-form').each(liaisonForm.init);
$('#search_form').each(searchForm.init);
$('#liaison_search_form').each(searchForm.init);
});

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load bootstrap3 %}
{% block title %}Add comment on {{ liaison.title }}{% endblock %}
{% block content %}
{% origin %}
<h1>Add comment<br><small>{{ liaison.title }}</small></h1>
<p>The comment will be added to the history trail of the statement.</p>
{% bootstrap_messages %}
<form class="add-comment" method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-primary">Add Comment</button>
<a class="btn btn-default pull-right" href="{% url "ietf.liaisons.views.liaison_history" object_id=liaison.id %}">Back</a>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -10,14 +10,11 @@
{% include "liaisons/detail_tabs.html" %}
{% comment %}
{% if user|has_role:"Area Director,Secretariat,IANA,RFC Editor" %}
<p class="buttonlist">
<a class="btn btn-default" href="{% url "ipr_add_comment" id=ipr.id %}" title="Add comment to history">Add comment</a>
<a class="btn btn-default" href="{% url "ipr_add_email" id=ipr.id %}" title="Add email to history">Add email</a>
<a class="btn btn-default" href="{% url "ietf.liaisons.views.add_comment" object_id=liaison.id %}" title="Add comment to history">Add comment</a>
</p>
{% endif %}
{% endcomment %}
<table class="table table-condensed table-striped history">
<thead>

View file

@ -43,7 +43,7 @@
<p class="help-block">Fields marked with <label class="required"></label> are required. For detailed descriptions of the fields see the <a href="{% url "liaisons_field_help" %}">field help</a>.</p>
{% endif %}
<form role="form" class="liaisons-form form-horizontal show-required" method="post" enctype="multipart/form-data" data-ajax-info-url="{% url "ietf.liaisons.views.ajax_get_liaison_info" %}">{% csrf_token %}
<form role="form" class="liaisons-form form-horizontal show-required" method="post" enctype="multipart/form-data" data-edit-form="{{ form.edit }}" data-ajax-info-url="{% url "ietf.liaisons.views.ajax_get_liaison_info" %}">{% csrf_token %}
{% for fieldset in form.fieldsets %}
{% if forloop.first and user|has_role:"Secretariat" %}

View file

@ -29,7 +29,10 @@
<td class="text-nowrap">{{ liaison.sort_date|date:"Y-m-d" }}</td>
<td class="liaison-group-col">{{ liaison.from_groups_display }}</td>
<td class="liaison-group-col">{{ liaison.to_groups_display }}</td>
<td class="text-nowrap">{{ liaison.deadline|default:"-"|date:"Y-m-d" }}{% if liaison.deadline and not liaison.action_taken %}<br><span class="label label-warning">Action Needed</span>{% endif %}</td>
<td class="text-nowrap">{{ liaison.deadline|default:"-"|date:"Y-m-d" }}
{% if liaison.deadline and not liaison.action_taken %}
<br><span class="label {% if liaison.is_outgoing %}label-warning{% else %}label-info{% endif %}">Action Needed</span>
{% endif %}</td>
<td><a href="{% url "ietf.liaisons.views.liaison_detail" object_id=liaison.pk %}">{{ liaison.title }}</a></td>
</tr>
{% endfor %}

View file

@ -3,7 +3,7 @@
{% load ietf_filters %}
{% load bootstrap3 %}
<form id="search_form" class="form-horizontal" action="{% url "ietf.liaisons.views.liaison_list" state=state %}" method="get">
<form id="liaison_search_form" class="form-horizontal" action="{% url "ietf.liaisons.views.liaison_list" state=state %}" method="get">
<div class="input-group search_field">
{{ form.text|add_class:"form-control"|attr:"placeholder:Title, body, identifiers, etc." }}
@ -32,7 +32,7 @@
<div class="form-group search_field">
<div class="col-sm-4">
<label for="id_source" class="control-label">Source</label>
<label for="id_source" class="control-label">From Group(s)</label>
</div>
<div class="col-sm-8">
{{ form.source|add_class:"form-control" }}
@ -41,7 +41,7 @@
<div class="form-group search_field">
<div class="col-sm-4">
<label for="id_destination" class="control-label">Destination</label>
<label for="id_destination" class="control-label">To Group(s)</label>
</div>
<div class="col-sm-8">
{{ form.destination|add_class:"form-control" }}