Merged in [16939] from sasha@dashcare.nl:

Fix #2050 - Allow adding review wishes from document and search pages.
On the main page of a document and in document search results, a new
button allows review team members to add a review wish for that document.

For reviewers that are only on one team, this essentially works
identical to tracking a document. Reviewers that are on multiple teams
are lead through an intermediate step to select a review team, and then
returned to their search or document page.
 - Legacy-Id: 16985
Note: SVN reference [16939] has been migrated to Git commit 6e55f26dbd
This commit is contained in:
Henrik Levkowetz 2019-11-11 14:22:54 +00:00
commit 20eb9d8ac1
12 changed files with 232 additions and 35 deletions

View file

@ -58,20 +58,6 @@ def can_manage_community_list(user, clist):
return False return False
def augment_docs_with_tracking_info(docs, user):
"""Add attribute to each document with whether the document is tracked
by the user or not."""
tracked = set()
if user and user.is_authenticated:
clist = CommunityList.objects.filter(user=user).first()
if clist:
tracked.update(docs_tracked_by_community_list(clist).filter(pk__in=[ d.pk for d in docs ]).values_list("pk", flat=True))
for d in docs:
d.tracked_in_personal_community_list = d.pk in tracked
def reset_name_contains_index_for_rule(rule): def reset_name_contains_index_for_rule(rule):
if not rule.rule_type == "name_contains": if not rule.rule_type == "name_contains":
return return

View file

@ -22,7 +22,8 @@ from pyquery import PyQuery
import debug # pyflakes:ignore import debug # pyflakes:ignore
import ietf.review.mailarch import ietf.review.mailarch
from ietf.doc.factories import NewRevisionDocEventFactory, WgDraftFactory, WgRfcFactory, ReviewFactory from ietf.doc.factories import NewRevisionDocEventFactory, WgDraftFactory, WgRfcFactory, \
ReviewFactory, DocumentFactory
from ietf.doc.models import DocumentAuthor, RelatedDocument, DocEvent, ReviewRequestDocEvent, ReviewAssignmentDocEvent from ietf.doc.models import DocumentAuthor, RelatedDocument, DocEvent, ReviewRequestDocEvent, ReviewAssignmentDocEvent
from ietf.group.factories import RoleFactory, ReviewTeamFactory from ietf.group.factories import RoleFactory, ReviewTeamFactory
from ietf.group.models import Group from ietf.group.models import Group
@ -479,7 +480,7 @@ class ReviewTests(TestCase):
login_testing_unauthorized(self, "reviewsecretary", reject_url) login_testing_unauthorized(self, "reviewsecretary", reject_url)
r = self.client.get(reject_url) r = self.client.get(reject_url)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertContains(r, str(assignment.reviewer.person)) self.assertContains(r, assignment.reviewer.person.plain_name())
self.assertNotContains(r, 'can not be rejected') self.assertNotContains(r, 'can not be rejected')
self.assertContains(r, '<button type="submit"') self.assertContains(r, '<button type="submit"')
@ -1165,3 +1166,51 @@ class ReviewTests(TestCase):
assignment = reload_db_objects(assignment) assignment = reload_db_objects(assignment)
self.assertEqual(assignment.state_id, 'withdrawn') self.assertEqual(assignment.state_id, 'withdrawn')
def test_review_wish_add(self):
doc = DocumentFactory()
team = ReviewTeamFactory()
reviewer = RoleFactory(group=team, name_id='reviewer').person
url = urlreverse('ietf.doc.views_review.review_wish_add', kwargs={'name': doc.name})
login_testing_unauthorized(self, reviewer.user.username, url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
# As this reviewer is only on a single team, posting without data should work
r = self.client.post(url + '?next=/redirect-url')
self.assertRedirects(r, '/redirect-url', fetch_redirect_response=False)
self.assertTrue(ReviewWish.objects.get(person=reviewer, doc=doc, team=team))
# Try again with a reviewer on multiple teams, requiring team selection.
# This also uses an invalid redirect URL that should be ignored.
ReviewWish.objects.all().delete()
team2 = ReviewTeamFactory()
RoleFactory(group=team2, person=reviewer, name_id='reviewer')
r = self.client.post(url + '?next=http://example.com/')
self.assertEqual(r.status_code, 200) # Missing team parameter
r = self.client.post(url + '?next=http://example.com/', data={'team': team2.pk})
self.assertRedirects(r, doc.get_absolute_url(), fetch_redirect_response=False)
self.assertTrue(ReviewWish.objects.get(person=reviewer, doc=doc, team=team2))
def test_review_wishes_remove(self):
doc = DocumentFactory()
team = ReviewTeamFactory()
reviewer = RoleFactory(group=team, name_id='reviewer').person
ReviewWish.objects.create(person=reviewer, doc=doc, team=team)
url = urlreverse('ietf.doc.views_review.review_wishes_remove', kwargs={'name': doc.name})
login_testing_unauthorized(self, reviewer.user.username, url)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.client.post(url + '?next=/redirect-url')
self.assertRedirects(r, '/redirect-url', fetch_redirect_response=False)
self.assertFalse(ReviewWish.objects.all())
# Try again with an invalid redirect URL that should be ignored.
ReviewWish.objects.create(person=reviewer, doc=doc, team=team)
r = self.client.post(url + '?next=http://example.com')
self.assertRedirects(r, doc.get_absolute_url(), fetch_redirect_response=False)
self.assertFalse(ReviewWish.objects.all())

View file

@ -20,4 +20,6 @@ urlpatterns = [
url(r'^team/%(acronym)s/searchmailarchive/$' % settings.URL_REGEXPS, views_review.search_mail_archive), url(r'^team/%(acronym)s/searchmailarchive/$' % settings.URL_REGEXPS, views_review.search_mail_archive),
url(r'^(?P<request_id>[0-9]+)/editcomment/$', views_review.edit_comment), url(r'^(?P<request_id>[0-9]+)/editcomment/$', views_review.edit_comment),
url(r'^(?P<request_id>[0-9]+)/editdeadline/$', views_review.edit_deadline), url(r'^(?P<request_id>[0-9]+)/editdeadline/$', views_review.edit_deadline),
url(r'^addreviewwish/$', views_review.review_wish_add),
url(r'^removereviewwishes/$', views_review.review_wishes_remove),
] ]

View file

@ -23,14 +23,18 @@ from django.utils.html import escape
from django.urls import reverse as urlreverse from django.urls import reverse as urlreverse
import debug # pyflakes:ignore import debug # pyflakes:ignore
from ietf.community.models import CommunityList
from ietf.community.utils import docs_tracked_by_community_list
from ietf.doc.models import Document, DocHistory, State, DocumentAuthor, DocHistoryAuthor from ietf.doc.models import Document, DocHistory, State, DocumentAuthor, DocHistoryAuthor
from ietf.doc.models import DocAlias, RelatedDocument, RelatedDocHistory, BallotType, DocReminder from ietf.doc.models import DocAlias, RelatedDocument, RelatedDocHistory, BallotType, DocReminder
from ietf.doc.models import DocEvent, ConsensusDocEvent, BallotDocEvent, NewRevisionDocEvent, StateDocEvent from ietf.doc.models import DocEvent, ConsensusDocEvent, BallotDocEvent, NewRevisionDocEvent, StateDocEvent
from ietf.doc.models import TelechatDocEvent from ietf.doc.models import TelechatDocEvent
from ietf.name.models import DocReminderTypeName, DocRelationshipName from ietf.name.models import DocReminderTypeName, DocRelationshipName
from ietf.group.models import Role from ietf.group.models import Role, Group
from ietf.ietfauth.utils import has_role from ietf.ietfauth.utils import has_role
from ietf.person.models import Person
from ietf.review.models import ReviewWish
from ietf.utils import draft, text from ietf.utils import draft, text
from ietf.utils.mail import send_mail from ietf.utils.mail import send_mail
from ietf.mailtrigger.utils import gather_address_lists from ietf.mailtrigger.utils import gather_address_lists
@ -906,3 +910,31 @@ def build_doc_meta_block(doc, path):
# #
return block return block
def augment_docs_and_user_with_user_info(docs, user):
"""Add attribute to each document with whether the document is tracked
or has a review wish by the user or not, and the review teams the user is on."""
tracked = set()
review_wished = set()
if user and user.is_authenticated:
user.review_teams = Group.objects.filter(
reviewteamsettings__isnull=False, role__person__user=user, role__name='reviewer')
doc_pks = [d.pk for d in docs]
clist = CommunityList.objects.filter(user=user).first()
if clist:
tracked.update(
docs_tracked_by_community_list(clist).filter(pk__in=doc_pks).values_list("pk", flat=True))
try:
wishes = ReviewWish.objects.filter(person=Person.objects.get(user=user))
wishes = wishes.filter(doc__pk__in=doc_pks).values_list("doc__pk", flat=True)
review_wished.update(wishes)
except Person.DoesNotExist:
pass
for d in docs:
d.tracked_in_personal_community_list = d.pk in tracked
d.has_review_wish = d.pk in review_wished

View file

@ -7,9 +7,9 @@ from __future__ import absolute_import, print_function, unicode_literals
import datetime import datetime
import debug # pyflakes:ignore import debug # pyflakes:ignore
from ietf.community.utils import augment_docs_with_tracking_info
from ietf.doc.models import Document, DocAlias, RelatedDocument, DocEvent, TelechatDocEvent, BallotDocEvent from ietf.doc.models import Document, DocAlias, RelatedDocument, DocEvent, TelechatDocEvent, BallotDocEvent
from ietf.doc.expire import expirable_draft from ietf.doc.expire import expirable_draft
from ietf.doc.utils import augment_docs_and_user_with_user_info
from ietf.meeting.models import SessionPresentation, Meeting, Session from ietf.meeting.models import SessionPresentation, Meeting, Session
def wrap_value(v): def wrap_value(v):
@ -162,7 +162,7 @@ def prepare_document_table(request, docs, query=None, max_results=200):
docs = list(docs) docs = list(docs)
fill_in_document_table_attributes(docs) fill_in_document_table_attributes(docs)
augment_docs_with_tracking_info(docs, request.user) augment_docs_and_user_with_user_info(docs, request.user)
meta = {} meta = {}

View file

@ -58,12 +58,12 @@ import debug # pyflakes:ignore
from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDocEvent, from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDocEvent,
ConsensusDocEvent, NewRevisionDocEvent, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent, ConsensusDocEvent, NewRevisionDocEvent, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent,
IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS ) IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS )
from ietf.doc.utils import ( add_links_in_new_revision_events, augment_events_with_revision, from ietf.doc.utils import (add_links_in_new_revision_events, augment_events_with_revision,
can_adopt_draft, can_unadopt_draft, get_chartering_type, get_tags_for_stream_id, can_adopt_draft, can_unadopt_draft, get_chartering_type, get_tags_for_stream_id,
needed_ballot_positions, nice_consensus, prettify_std_name, update_telechat, has_same_ballot, needed_ballot_positions, nice_consensus, prettify_std_name, update_telechat, has_same_ballot,
get_initial_notify, make_notify_changed_event, make_rev_history, default_consensus, get_initial_notify, make_notify_changed_event, make_rev_history, default_consensus,
add_events_message_info, get_unicode_document_content, build_doc_meta_block) add_events_message_info, get_unicode_document_content, build_doc_meta_block,
from ietf.community.utils import augment_docs_with_tracking_info augment_docs_and_user_with_user_info)
from ietf.group.models import Role, Group from ietf.group.models import Role, Group
from ietf.group.utils import can_manage_group_type, can_manage_materials, group_features_role_filter from ietf.group.utils import can_manage_group_type, can_manage_materials, group_features_role_filter
from ietf.ietfauth.utils import ( has_role, is_authorized_in_doc_stream, user_is_person, from ietf.ietfauth.utils import ( has_role, is_authorized_in_doc_stream, user_is_person,
@ -390,7 +390,7 @@ def document_main(request, name, rev=None):
elif can_edit_stream_info and (iesg_state.slug in ('idexists','watching')): elif can_edit_stream_info and (iesg_state.slug in ('idexists','watching')):
actions.append(("Submit to IESG for Publication", urlreverse('ietf.doc.views_draft.to_iesg', kwargs=dict(name=doc.name)))) actions.append(("Submit to IESG for Publication", urlreverse('ietf.doc.views_draft.to_iesg', kwargs=dict(name=doc.name))))
augment_docs_with_tracking_info([doc], request.user) augment_docs_and_user_with_user_info([doc], request.user)
replaces = [d.name for d in doc.related_that_doc("replaces")] replaces = [d.name for d in doc.related_that_doc("replaces")]
replaced_by = [d.name for d in doc.related_that("replaces")] replaced_by = [d.name for d in doc.related_that("replaces")]

View file

@ -5,14 +5,17 @@
from __future__ import absolute_import, print_function, unicode_literals from __future__ import absolute_import, print_function, unicode_literals
import io import io
import json
import os import os
import datetime import datetime
import requests import requests
import email.utils import email.utils
from django.utils.http import is_safe_url
import debug # pyflakes:ignore import debug # pyflakes:ignore
from django.http import HttpResponseForbidden, JsonResponse, Http404 from django.http import HttpResponseForbidden, JsonResponse, Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django import forms from django import forms
from django.conf import settings from django.conf import settings
@ -27,7 +30,7 @@ from ietf.doc.models import (Document, NewRevisionDocEvent, State, DocAlias,
from ietf.name.models import ReviewRequestStateName, ReviewAssignmentStateName, ReviewResultName, \ from ietf.name.models import ReviewRequestStateName, ReviewAssignmentStateName, ReviewResultName, \
DocTypeName, ReviewTypeName DocTypeName, ReviewTypeName
from ietf.person.models import Person from ietf.person.models import Person
from ietf.review.models import ReviewRequest, ReviewAssignment from ietf.review.models import ReviewRequest, ReviewAssignment, ReviewWish
from ietf.group.models import Group from ietf.group.models import Group
from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role from ietf.ietfauth.utils import is_authorized_in_doc_stream, user_is_person, has_role
from ietf.message.models import Message from ietf.message.models import Message
@ -970,3 +973,64 @@ def edit_deadline(request, name, request_id):
'review_req': review_req, 'review_req': review_req,
'form' : form, 'form' : form,
}) })
class ReviewWishAddForm(forms.Form):
team = forms.ModelChoiceField(queryset=Group.objects.filter(reviewteamsettings__isnull=False),
widget=forms.RadioSelect, empty_label=None, required=True)
def __init__(self, user, doc, *args, **kwargs):
super(ReviewWishAddForm, self).__init__(*args, **kwargs)
self.person = get_object_or_404(Person, user=user)
self.doc = doc
self.fields['team'].queryset = self.fields['team'].queryset.filter(role__person=self.person,
role__name='reviewer')
if len(self.fields['team'].queryset) == 1:
self.team = self.fields['team'].queryset.get()
del self.fields['team']
def save(self):
team = self.team if hasattr(self, 'team') else self.cleaned_data['team']
ReviewWish.objects.get_or_create(person=self.person, team=team, doc=self.doc)
@login_required
def review_wish_add(request, name):
doc = get_object_or_404(Document, docalias__name=name)
if request.method == "POST":
form = ReviewWishAddForm(request.user, doc, request.POST)
if form.is_valid():
form.save()
return _generate_ajax_or_redirect_response(request, doc)
else:
form = ReviewWishAddForm(request.user, doc)
return render(request, "doc/review/review_wish_add.html", {
"doc": doc,
"form": form,
})
@login_required
def review_wishes_remove(request, name):
doc = get_object_or_404(Document, docalias__name=name)
person = get_object_or_404(Person, user=request.user)
if request.method == "POST":
ReviewWish.objects.filter(person=person, doc=doc).delete()
return _generate_ajax_or_redirect_response(request, doc)
return render(request, "doc/review/review_wishes_remove.html", {
"name": doc.name,
})
def _generate_ajax_or_redirect_response(request, doc):
redirect_url = request.GET.get('next')
url_is_safe = is_safe_url(url=redirect_url, allowed_hosts=request.get_host(),
require_https=request.is_secure())
if request.is_ajax():
return HttpResponse(json.dumps({'success': True}), content_type='application/json')
elif url_is_safe:
return HttpResponseRedirect(redirect_url)
else:
return HttpResponseRedirect(doc.get_absolute_url())

View file

@ -98,10 +98,9 @@ $(document).ready(function () {
updateAdvanced(); updateAdvanced();
} }
// search results $('.review-wish-add-remove-doc.ajax, .track-untrack-doc').click(function(e) {
$('.track-untrack-doc').click(function(e) { e.preventDefault();
e.preventDefault();
var trigger = $(this); var trigger = $(this);
$.ajax({ $.ajax({
url: trigger.attr('href'), url: trigger.attr('href'),
@ -109,13 +108,21 @@ $(document).ready(function () {
cache: false, cache: false,
dataType: 'json', dataType: 'json',
success: function(response){ success: function(response){
if (response.success) { if (response.success) {
trigger.parent().find(".tooltip").remove(); trigger.parent().find(".tooltip").remove();
trigger.addClass("hide"); trigger.addClass("hide");
trigger.parent().find(".track-untrack-doc").not(trigger).removeClass("hide");
var target_unhide = null;
if(trigger.hasClass('review-wish-add-remove-doc')) {
target_unhide = '.review-wish-add-remove-doc';
} else if(trigger.hasClass('track-untrack-doc')) {
target_unhide = '.track-untrack-doc';
} }
} if(target_unhide) {}
}); trigger.parent().find(target_unhide).not(trigger).removeClass("hide");
}
}
});
}); });
}); });

View file

@ -622,6 +622,10 @@
<a class="btn btn-default btn-xs track-untrack-doc {% if not doc.tracked_in_personal_community_list %}hide{% endif %}" href="{% url "ietf.community.views.untrack_document" username=user.username name=doc.name %}" title="Remove from your personal ID list"><span class="fa fa-bookmark"></span> Untrack</a> <a class="btn btn-default btn-xs track-untrack-doc {% if not doc.tracked_in_personal_community_list %}hide{% endif %}" href="{% url "ietf.community.views.untrack_document" username=user.username name=doc.name %}" title="Remove from your personal ID list"><span class="fa fa-bookmark"></span> Untrack</a>
<a class="btn btn-default btn-xs track-untrack-doc {% if doc.tracked_in_personal_community_list %}hide{% endif %}" href="{% url "ietf.community.views.track_document" username=user.username name=doc.name %}" title="Add to your personal ID list"><span class="fa fa-bookmark-o"></span> Track</a> <a class="btn btn-default btn-xs track-untrack-doc {% if doc.tracked_in_personal_community_list %}hide{% endif %}" href="{% url "ietf.community.views.track_document" username=user.username name=doc.name %}" title="Add to your personal ID list"><span class="fa fa-bookmark-o"></span> Track</a>
{% endif %} {% endif %}
{% if user.review_teams %}
<a class="btn btn-default btn-xs review-wish-add-remove-doc ajax {% if not doc.has_review_wish %}hide{% endif %}" href="{% url "ietf.doc.views_review.review_wishes_remove" name=doc.name %}?next={{ request.get_full_path|urlencode }}" title="Remove from your review wishes for all teams"><span class="fa fa-comments"></span> Remove review wishes</a>
<a class="btn btn-default btn-xs review-wish-add-remove-doc {% if user.review_teams|length_is:"1" %}ajax {% endif %}{% if doc.has_review_wish %}hide{% endif %}" href="{% url "ietf.doc.views_review.review_wish_add" name=doc.name %}?next={{ request.get_full_path|urlencode }}" title="Add to your review wishes"><span class="fa fa-comments-o"></span> Add review wish</a>
{% endif %}
{% if can_edit and iesg_state.slug != 'idexists' %} {% if can_edit and iesg_state.slug != 'idexists' %}
<a class="btn btn-default btn-xs" href="{% url 'ietf.doc.views_ballot.lastcalltext' name=doc.name %}">Last call text</a> <a class="btn btn-default btn-xs" href="{% url 'ietf.doc.views_ballot.lastcalltext' name=doc.name %}">Last call text</a>

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Add {{ doc.name }} to your review wishes{% endblock %}
{% block content %}
{% origin %}
<h1>Add {{ doc.name }} to your review wishes
</h1>
<p>You are a reviewer for multiple teams, and need to select a team first.</p>
<form class="form-horizontal" method="post">
{% csrf_token %}
{% bootstrap_form form layout="horizontal" %}
{% buttons %}
<button type="submit" class="btn btn-primary">Add to review wishes</button>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2016, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Remove {{ doc.name }} from your review wishes{% endblock %}
{% block content %}
{% origin %}
<h1>Remove {{ doc.name }} from your review wishes
</h1>
<form class="form-horizontal" method="post">
{% csrf_token %}
{% buttons %}
<button type="submit" class="btn btn-primary">Remove from review wishes</button>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -21,6 +21,16 @@
</a> </a>
<br> <br>
{% endif %} {% endif %}
{% if user.review_teams %}
<a class="review-wish-add-remove-doc ajax {% if not doc.has_review_wish %}hide{% endif %}" href="{% url "ietf.doc.views_review.review_wishes_remove" name=doc.name %}?next={{ request.get_full_path|urlencode }}" title="Remove from your review wishes for all teams">
<span class="fa fa-comments"></span>
</a>
<a class="review-wish-add-remove-doc {% if user.review_teams|length_is:"1" %}ajax {% endif %}{% if doc.has_review_wish %}hide{% endif %}" href="{% url "ietf.doc.views_review.review_wish_add" name=doc.name %}?next={{ request.get_full_path|urlencode }}" title="Add to your review wishes">
<span class="fa fa-comments-o"></span>
</a>
<br>
{% endif %}
{% for session in doc.sessions %} {% for session in doc.sessions %}
<a href="{% url 'ietf.meeting.views.ical_agenda' num=session.meeting.number session_id=session.id %}" <a href="{% url 'ietf.meeting.views.ical_agenda' num=session.meeting.number session_id=session.id %}"
title="Calendar entry: document is on the agenda for {{ session.group.acronym }}@{{ session.meeting }}"> title="Calendar entry: document is on the agenda for {{ session.group.acronym }}@{{ session.meeting }}">