diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index 718726ce8c66c685eb8b2195a17c02d54d2579fd..c8e6c41b1d5c79129c119f0670e628e5d479722b 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -49,6 +49,7 @@ neat_related = { MESSAGES.EDIT: 'revision', MESSAGES.COMMENT: 'comment', MESSAGES.SCREENING: 'old_status', + MESSAGES.REVIEW_OPINION: 'opinion', } @@ -202,7 +203,8 @@ class ActivityAdapter(AdapterBase): MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated', MESSAGES.NEW_REVIEW: 'Submitted a review', MESSAGES.OPENED_SEALED: 'Opened the submission while still sealed', - MESSAGES.SCREENING: 'Screening status from {old_status} to {submission.screening_status}' + MESSAGES.SCREENING: 'Screening status from {old_status} to {submission.screening_status}', + MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {submission}' } def recipients(self, message_type, **kwargs): @@ -311,8 +313,10 @@ class SlackAdapter(AdapterBase): MESSAGES.INVITED_TO_PROPOSAL: '<{link}|{submission.title}> by {submission.user} has been invited to submit a proposal', MESSAGES.NEW_REVIEW: '{user} has submitted a review for <{link}|{submission.title}>. Outcome: {review.outcome}, Score: {review.score}', MESSAGES.READY_FOR_REVIEW: 'notify_reviewers', + MESSAGES.OPENED_SEALED: '{user} has opened the sealed submission: <{link}|{submission.title}>', + MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {submission}', MESSAGES.BATCH_READY_FOR_REVIEW: 'batch_notify_reviewers', - MESSAGES.OPENED_SEALED: '{user} has opened the sealed submission: <{link}|{submission.title}>' + } def __init__(self): diff --git a/opentech/apply/activity/migrations/0017_add_review_opinion.py b/opentech/apply/activity/migrations/0017_add_review_opinion.py new file mode 100644 index 0000000000000000000000000000000000000000..c59a1458a577d05df0686a5b52dd5d9cfbb954c7 --- /dev/null +++ b/opentech/apply/activity/migrations/0017_add_review_opinion.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.9 on 2019-02-18 08:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0016_add_batch_ready'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index d1817bbfc52d4f7bbe858b8c3eeef37b0152b94e..7041ce9e343aa4bbf7782417d2c4786e5cbc9e7e 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -19,6 +19,7 @@ class MESSAGES(Enum): COMMENT = 'Comment' PROPOSAL_SUBMITTED = 'Proposal Submitted' OPENED_SEALED = 'Opened Sealed Submission' + REVIEW_OPINION = 'Review Opinion' @classmethod def choices(cls): diff --git a/opentech/apply/review/forms.py b/opentech/apply/review/forms.py index 9987745338e627a82da14fb1981f4110b1d0c625..0d29c09ea311ac510dadc7422f527ce0ad0dfc38 100644 --- a/opentech/apply/review/forms.py +++ b/opentech/apply/review/forms.py @@ -1,10 +1,12 @@ from django import forms from django.core.exceptions import NON_FIELD_ERRORS +from django.utils.html import escape from opentech.apply.review.options import NA from opentech.apply.stream_forms.forms import StreamBaseForm -from .models import Review +from .models import Review, ReviewOpinion +from .options import OPINION_CHOICES class MixedMetaClass(type(StreamBaseForm), type(forms.ModelForm)): @@ -84,3 +86,54 @@ class ReviewModelForm(StreamBaseForm, forms.ModelForm, metaclass=MixedMetaClass) return sum(scores) / len(scores) except ZeroDivisionError: return NA + + +class SubmitButtonWidget(forms.Widget): + def render(self, name, value, attrs=None): + disabled = 'disabled' if attrs.get('disabled') else '' + return '<input type="submit" name="{name}" value="{value}" class="button button--primary button--bottom-space" {disabled}>'.format( + disabled=disabled, + name=escape(name), + value=escape(name.title()), + ) + + +class OpinionField(forms.IntegerField): + def __init__(self, *args, opinion, **kwargs): + kwargs["widget"] = SubmitButtonWidget + self.opinion = opinion + kwargs['label'] = '' + super().__init__(*args, **kwargs) + + def clean(self, value): + if value: + return self.opinion + + +class ReviewOpinionForm(forms.ModelForm): + opinion = forms.IntegerField(required=False, widget=forms.HiddenInput()) + + class Meta: + model = ReviewOpinion + fields = ('opinion',) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for value, opinion in OPINION_CHOICES: + self.fields[opinion.lower()] = OpinionField( + label=opinion.title(), + opinion=value, + disabled=self.instance.opinion == value, + ) + + def clean(self): + cleaned_data = super().clean() + opinions = [cleaned_data.get(opinion.lower()) for _, opinion in OPINION_CHOICES] + valid_opinions = [opinion for opinion in opinions if opinion is not None] + if len(valid_opinions) > 1: + self.add_error(None, 'Cant submit both an agreement and disagreement') + cleaned_data = {'opinion': valid_opinions[0]} + return cleaned_data + + def save(self, *args, **kwargs): + return super().save(*args, **kwargs) diff --git a/opentech/apply/review/migrations/0015_review_opinion.py b/opentech/apply/review/migrations/0015_review_opinion.py new file mode 100644 index 0000000000000000000000000000000000000000..d96d7c561190ae58bcc49d06a69afbd1de38f4f2 --- /dev/null +++ b/opentech/apply/review/migrations/0015_review_opinion.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.10 on 2019-02-16 12:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('review', '0014_add_markdown'), + ] + + operations = [ + migrations.CreateModel( + name='ReviewOpinion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('opinion', models.IntegerField(choices=[(1, 'Agree'), (0, 'Disagree')])), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opinions', to='review.Review')), + ], + ), + migrations.AlterUniqueTogether( + name='reviewopinion', + unique_together={('author', 'review')}, + ), + ] diff --git a/opentech/apply/review/models.py b/opentech/apply/review/models.py index f7f746918e1f4aaf5f3f458b082955be31f9112d..294b622e86c225e0ef4d4bb4e72d8e6d1f234ee4 100644 --- a/opentech/apply/review/models.py +++ b/opentech/apply/review/models.py @@ -11,7 +11,7 @@ from wagtail.core.fields import StreamField from opentech.apply.funds.models import AssignedReviewers from opentech.apply.funds.models.mixins import AccessFormData -from opentech.apply.review.options import YES, NO, MAYBE, RECOMMENDATION_CHOICES +from opentech.apply.review.options import YES, NO, MAYBE, RECOMMENDATION_CHOICES, OPINION_CHOICES from opentech.apply.stream_forms.models import BaseStreamForm from opentech.apply.users.models import User @@ -168,3 +168,19 @@ def update_submission_reviewers_list(sender, **kwargs): submission=review.submission, reviewer=review.author, ) + + +class ReviewOpinion(models.Model): + review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name='opinions') + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + ) + opinion = models.IntegerField(choices=OPINION_CHOICES) + + class Meta: + unique_together = ('author', 'review') + + @property + def opinion_display(self): + return self.get_opinion_display() diff --git a/opentech/apply/review/options.py b/opentech/apply/review/options.py index 2d58d84393ab0bbc580dc1defb4f7adc08de8ff9..84cec661736272f372f528805055a4c2fd0868fb 100644 --- a/opentech/apply/review/options.py +++ b/opentech/apply/review/options.py @@ -21,3 +21,11 @@ RECOMMENDATION_CHOICES = ( (MAYBE, 'Maybe'), (YES, 'Yes'), ) + +DISAGREE = 0 +AGREE = 1 + +OPINION_CHOICES = ( + (AGREE, 'Agree'), + (DISAGREE, 'Disagree'), +) diff --git a/opentech/apply/review/templates/review/review_detail.html b/opentech/apply/review/templates/review/review_detail.html index 4a82a07322bf04ed897c272dc3a90bba5ee25c7a..0e0c909c50804e9b8fe05d60a4a7e5092d08b68e 100644 --- a/opentech/apply/review/templates/review/review_detail.html +++ b/opentech/apply/review/templates/review/review_detail.html @@ -33,4 +33,12 @@ {{ object.output_answers|submission_links }} </div> + +{% if form %} + <form method="post"> + {% csrf_token %} + {{ form }} + </form> +{% endif %} + {% endblock %} diff --git a/opentech/apply/review/tests/test_views.py b/opentech/apply/review/tests/test_views.py index d9d3b9453e746c27b9dbec7511f5306bcd3b603b..d58724ff3f442a40caa974033a3cbd9c7bc92570 100644 --- a/opentech/apply/review/tests/test_views.py +++ b/opentech/apply/review/tests/test_views.py @@ -1,12 +1,13 @@ from django.urls import reverse +from opentech.apply.activity.models import Activity from opentech.apply.funds.tests.factories.models import ApplicationSubmissionFactory from opentech.apply.users.tests.factories import StaffFactory, UserFactory from opentech.apply.utils.testing.tests import BaseViewTestCase from .factories import ReviewFactory, ReviewFormFieldsFactory, ReviewFormFactory -from ..models import Review -from ..options import NA +from ..models import Review, ReviewOpinion +from ..options import NA, AGREE class StaffReviewsTestCase(BaseViewTestCase): @@ -206,3 +207,36 @@ class ReviewDetailTestCase(BaseViewTestCase): response = self.get_page(review) self.assertContains(response, submission.title) self.assertContains(response, "<p>Yes</p>") + + +class StaffReviewOpinionCase(BaseViewTestCase): + user_factory = StaffFactory + url_name = 'funds:submissions:reviews:{}' + base_view_name = 'review' + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.submission = ApplicationSubmissionFactory(status='draft_proposal', workflow_stages=2) + + def get_kwargs(self, instance): + return {'pk': instance.id, 'submission_pk': instance.submission.id} + + def test_can_see_opinion_buttons_on_others_review(self): + staff = StaffFactory() + review = ReviewFactory(submission=self.submission, author=staff, recommendation_yes=True) + response = self.get_page(review) + self.assertContains(response, 'name="agree"') + + def test_cant_see_opinion_buttons_on_self_review(self): + review = ReviewFactory(submission=self.submission, author=self.user, recommendation_yes=True) + response = self.get_page(review) + self.assertNotContains(response, 'name="agree"') + + def test_can_add_opinion_to_others_review(self): + staff = StaffFactory() + review = ReviewFactory(submission=self.submission, author=staff, recommendation_yes=True) + self.post_page(review, {'agree': AGREE}) + self.assertTrue(review.opinions.first().opinion_display in Activity.objects.first().message) + self.assertEqual(ReviewOpinion.objects.all().count(), 1) + self.assertEqual(ReviewOpinion.objects.first().opinion, AGREE) diff --git a/opentech/apply/review/views.py b/opentech/apply/review/views.py index 05c38204f8df6a57115df6e20f0871a95c581d99..c8605b2eb93cd743e650bd6efec6689df71117cb 100644 --- a/opentech/apply/review/views.py +++ b/opentech/apply/review/views.py @@ -6,14 +6,14 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy from django.utils.decorators import method_decorator -from django.views.generic import ListView, DetailView +from django.views.generic import CreateView, ListView, DetailView from wagtail.core.blocks import RichTextBlock from opentech.apply.activity.messaging import messenger, MESSAGES from opentech.apply.funds.models import ApplicationSubmission from opentech.apply.review.blocks import RecommendationBlock, RecommendationCommentsBlock -from opentech.apply.review.forms import ReviewModelForm +from opentech.apply.review.forms import ReviewModelForm, ReviewOpinionForm from opentech.apply.stream_forms.models import BaseStreamForm from opentech.apply.users.decorators import staff_required from opentech.apply.utils.views import CreateOrUpdateView @@ -24,7 +24,7 @@ from .models import Review class ReviewContextMixin: def get_context_data(self, **kwargs): assigned = self.object.assigned.order_by('role__order').select_related('reviewer') - reviews = self.object.reviews.all().select_related('author') + reviews = self.object.reviews.submitted().select_related('author') reviews_dict = {} for review in reviews: @@ -138,10 +138,22 @@ class ReviewCreateOrUpdateView(BaseStreamForm, CreateOrUpdateView): return self.submission.get_absolute_url() -@method_decorator(login_required, name='dispatch') -class ReviewDetailView(DetailView): +class ReviewDisplay(DetailView): model = Review + def get_context_data(self, **kwargs): + review = self.get_object() + if review.author != self.request.user: + consensus_form = ReviewOpinionForm( + instance=review.opinions.filter(author=self.request.user).first(), + ) + else: + consensus_form = None + return super().get_context_data( + form=consensus_form, + **kwargs, + ) + def dispatch(self, request, *args, **kwargs): review = self.get_object() author = review.author @@ -150,11 +162,53 @@ class ReviewDetailView(DetailView): raise PermissionDenied if review.is_draft: - return HttpResponseRedirect(reverse_lazy('apply:reviews:form', args=(review.submission.id,))) + return HttpResponseRedirect(reverse_lazy('apply:submissions:reviews:form', args=(review.submission.id,))) return super().dispatch(request, *args, **kwargs) +class ReviewOpinionFormView(CreateView): + template_name = 'review/review_detail.html' + form_class = ReviewOpinionForm + model = Review + + def get_form_kwargs(self): + self.object = self.get_object() + kwargs = super().get_form_kwargs() + instance = kwargs['instance'] + kwargs['instance'] = instance.opinions.filter(author=self.request.user).first() + return kwargs + + def form_valid(self, form): + self.review = self.get_object() + form.instance.author = self.request.user + form.instance.review = self.review + response = super().form_valid(form) + + messenger( + MESSAGES.REVIEW_OPINION, + request=self.request, + user=self.request.user, + submission=self.review.submission, + related=form.instance, + ) + return response + + def get_success_url(self): + return self.review.get_absolute_url() + + +@method_decorator(login_required, name='dispatch') +class ReviewDetailView(DetailView): + def get(self, request, *args, **kwargs): + view = ReviewDisplay.as_view() + return view(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + view = ReviewOpinionFormView.as_view() + return view(request, *args, **kwargs) + + @method_decorator(staff_required, name='dispatch') class ReviewListView(ListView): model = Review