diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index 29c0ad9d865174320b45a13d5f8f5c2f5f73aa4a..aed187f4a54f01ee3936d8ded5011ef42b8b957a 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -21,6 +21,7 @@ neat_related = { MESSAGES.APPLICANT_EDIT: 'revision', MESSAGES.EDIT: 'revision', MESSAGES.COMMENT: 'comment', + MESSAGES.SCREENING: 'old_status', } @@ -127,6 +128,7 @@ class ActivityAdapter(AdapterBase): MESSAGES.REVIEWERS_UPDATED: '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}' } def recipients(self, message_type, **kwargs): diff --git a/opentech/apply/activity/migrations/0013_add_new_event_type_screening.py b/opentech/apply/activity/migrations/0013_add_new_event_type_screening.py new file mode 100644 index 0000000000000000000000000000000000000000..065b7afa51b2253c7411086c11f82d114c81c1e4 --- /dev/null +++ b/opentech/apply/activity/migrations/0013_add_new_event_type_screening.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.10 on 2019-01-09 16:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0012_add_generic_relation_to_activity'), + ] + + 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'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('READY_FOR_REVIEW', 'Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index ca9a78cb0baafbf87323780ab7f3b20e1971317f..46d744e035fa0af24a76b6c63996edf6e3523e6e 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -6,6 +6,7 @@ class MESSAGES(Enum): EDIT = 'Edit' APPLICANT_EDIT = "Applicant Edit" NEW_SUBMISSION = 'New Submission' + SCREENING = 'Screening' TRANSITION = 'Transition' DETERMINATION_OUTCOME = 'Determination Outcome' INVITED_TO_PROPOSAL = 'Invited To Proposal' diff --git a/opentech/apply/funds/admin.py b/opentech/apply/funds/admin.py index 559a76bf512d52c8f94da44f33c9761a8374f645..689f73027216344b55a4b82eed731de5b7b55dfb 100644 --- a/opentech/apply/funds/admin.py +++ b/opentech/apply/funds/admin.py @@ -1,6 +1,7 @@ from wagtail.contrib.modeladmin.helpers import PermissionHelper from wagtail.contrib.modeladmin.options import ModelAdmin, ModelAdminGroup +from opentech.apply.funds.models import ScreeningStatus from opentech.apply.review.admin import ReviewFormAdmin from opentech.apply.utils.admin import ListRelatedMixin from .admin_helpers import ( @@ -27,6 +28,28 @@ class RoundAdmin(BaseRoundAdmin): menu_icon = 'repeat' +class ScreeningStatusPermissionHelper(PermissionHelper): + def user_can_edit_obj(self, user, obj): + """ + Return a boolean to indicate whether `user` is permitted to 'change' + a specific `self.model` instance. + """ + return user.is_superuser + + def user_can_delete_obj(self, user, obj): + """ + Return a boolean to indicate whether `user` is permitted to 'delete' + a specific `self.model` instance. + """ + return user.is_superuser + + +class ScreeningStatusAdmin(ModelAdmin): + model = ScreeningStatus + menu_icon = 'tag' + permission_helper_class = ScreeningStatusPermissionHelper + + class SealedRoundAdmin(BaseRoundAdmin): model = SealedRound menu_icon = 'locked' @@ -82,4 +105,5 @@ class ApplyAdminGroup(ModelAdminGroup): ApplicationFormAdmin, ReviewFormAdmin, CategoryAdmin, + ScreeningStatusAdmin, ) diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py index dd435d559c7c1ad8fe05463bbf774398e294af66..0b1df3d3b4a79c45b485b0cff5600b80a4a3961a 100644 --- a/opentech/apply/funds/forms.py +++ b/opentech/apply/funds/forms.py @@ -22,6 +22,20 @@ class ProgressSubmissionForm(forms.ModelForm): self.should_show = bool(choices) +class ScreeningSubmissionForm(forms.ModelForm): + + class Meta: + model = ApplicationSubmission + fields = ('screening_status',) + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user') + super().__init__(*args, **kwargs) + self.should_show = False + if (self.instance.active and self.user.is_apply_staff) or self.user.is_superuser: + self.should_show = True + + class UpdateSubmissionLeadForm(forms.ModelForm): class Meta: model = ApplicationSubmission diff --git a/opentech/apply/funds/migrations/0049_screening_status.py b/opentech/apply/funds/migrations/0049_screening_status.py new file mode 100644 index 0000000000000000000000000000000000000000..ad1fb85306d9ac71a70cf6a79071ba401f60a1a3 --- /dev/null +++ b/opentech/apply/funds/migrations/0049_screening_status.py @@ -0,0 +1,29 @@ +# Generated by Django 2.0.10 on 2019-01-10 15:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('funds', '0048_add_field_slack_channel'), + ] + + operations = [ + migrations.CreateModel( + name='ScreeningStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=128)), + ], + options={ + 'verbose_name_plural': 'screening statuses', + }, + ), + migrations.AddField( + model_name='applicationsubmission', + name='screening_status', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='funds.ScreeningStatus', verbose_name='screening status'), + ), + ] diff --git a/opentech/apply/funds/models/__init__.py b/opentech/apply/funds/models/__init__.py index 9cfc41a3b66c9995a9b7aca916bceec94f8b0928..99f56509adf031f7eaab669918752fe5fa1ecc83 100644 --- a/opentech/apply/funds/models/__init__.py +++ b/opentech/apply/funds/models/__init__.py @@ -2,10 +2,11 @@ from django.utils.translation import ugettext_lazy as _ from .applications import ApplicationBase, RoundBase, LabBase from .forms import ApplicationForm +from .screening import ScreeningStatus from .submissions import ApplicationSubmission, ApplicationRevision -__all__ = ['ApplicationSubmission', 'ApplicationRevision', 'ApplicationForm'] +__all__ = ['ApplicationSubmission', 'ApplicationRevision', 'ApplicationForm', 'ScreeningStatus'] class FundType(ApplicationBase): diff --git a/opentech/apply/funds/models/screening.py b/opentech/apply/funds/models/screening.py new file mode 100644 index 0000000000000000000000000000000000000000..c590704b145651314664e60798c8a61ff563dc46 --- /dev/null +++ b/opentech/apply/funds/models/screening.py @@ -0,0 +1,11 @@ +from django.db import models + + +class ScreeningStatus(models.Model): + title = models.CharField(max_length=128) + + class Meta: + verbose_name_plural = "screening statuses" + + def __str__(self): + return self.title diff --git a/opentech/apply/funds/models/submissions.py b/opentech/apply/funds/models/submissions.py index 2e584563854fc1bba4b44ea6db350dea04468fa5..2979848089accd8770db2716928d70f4a9c05d1e 100644 --- a/opentech/apply/funds/models/submissions.py +++ b/opentech/apply/funds/models/submissions.py @@ -310,6 +310,14 @@ class ApplicationSubmission( # Workflow inherited from WorkflowHelpers status = FSMField(default=INITIAL_STATE, protected=True) + screening_status = models.ForeignKey( + 'funds.ScreeningStatus', + related_name='+', + on_delete=models.SET_NULL, + verbose_name='screening status', + null=True, + ) + is_draft = False live_revision = models.OneToOneField( diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html index e0a41610a6575d80050c321abbd4416b41a59bb6..d90c454f4229a2805103bbf2afd9f8751318af7e 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html @@ -12,6 +12,7 @@ {% block sidebar_forms %} {% include "funds/includes/actions.html" %} + {% include "funds/includes/screening_form.html" %} {% include "funds/includes/progress_form.html" %} {% include "funds/includes/update_lead_form.html" %} {% include "funds/includes/update_reviewer_form.html" %} diff --git a/opentech/apply/funds/templates/funds/includes/actions.html b/opentech/apply/funds/templates/funds/includes/actions.html index b66583a84a0bb7c13435a6d61033d60c8ddc17ef..63e1ac00bf41195d1d0fcbee753b3310ed722e09 100644 --- a/opentech/apply/funds/templates/funds/includes/actions.html +++ b/opentech/apply/funds/templates/funds/includes/actions.html @@ -4,7 +4,12 @@ <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}"> <h5>Actions to take</h5> + + <a data-fancybox data-src="#screen-application" class="button button--bottom-space button--primary button--full-width {% if screening_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Screen application</a> + <a data-fancybox data-src="#update-status" class="button button--primary button--full-width {% if progress_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Update status</a> + + <p class="sidebar__separator">Assign</p> <div class="wrapper wrapper--sidebar-buttons"> diff --git a/opentech/apply/funds/templates/funds/includes/screening_form.html b/opentech/apply/funds/templates/funds/includes/screening_form.html new file mode 100644 index 0000000000000000000000000000000000000000..87b3f9cd4a6cfdb2341f270704c9d793e1c7bec5 --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/screening_form.html @@ -0,0 +1,7 @@ +{% if screening_form.should_show %} +<div class="modal" id="screen-application"> + <h4>Update status</h4> + <p>Current status: {{ object.screening_status }}</p> + {% include 'funds/includes/delegated_form_base.html' with form=screening_form value='Screen'%} +</div> +{% endif %} diff --git a/opentech/apply/funds/tests/factories/models.py b/opentech/apply/funds/tests/factories/models.py index fc9722cc7ccae76c1a29ed81903a0c8240b59f5e..035f04115f6f050f67d6cb8ea6c3036dca65e883 100644 --- a/opentech/apply/funds/tests/factories/models.py +++ b/opentech/apply/funds/tests/factories/models.py @@ -10,6 +10,7 @@ from opentech.apply.funds.models import ( LabType, RequestForPartners, Round, + ScreeningStatus, SealedRound, ) from opentech.apply.funds.models.forms import ( @@ -43,6 +44,7 @@ __all__ = [ 'LabBaseFormFactory', 'LabSubmissionFactory', 'RequestForPartnersFactory', + 'ScreeningStatusFactory', 'SealedRoundFactory', 'SealedSubmissionFactory', 'TodayRoundFactory', @@ -311,3 +313,10 @@ class LabBaseReviewFormFactory(AbstractReviewFormFactory): model = LabBaseReviewForm lab = factory.SubFactory(LabFactory) + + +class ScreeningStatusFactory(factory.DjangoModelFactory): + class Meta: + model = ScreeningStatus + + title = factory.Iterator(["Bad", "Good"]) diff --git a/opentech/apply/funds/tests/test_views.py b/opentech/apply/funds/tests/test_views.py index f0209aa6a70bfbed444fb0b919e88c4531a27d11..b8adc0e10c5aa0a927fab5f1e36d4fc1db208d5c 100644 --- a/opentech/apply/funds/tests/test_views.py +++ b/opentech/apply/funds/tests/test_views.py @@ -10,6 +10,7 @@ from opentech.apply.funds.tests.factories import ( LabFactory, LabSubmissionFactory, RoundFactory, + ScreeningStatusFactory, SealedRoundFactory, SealedSubmissionFactory, ) @@ -182,6 +183,21 @@ class TestStaffSubmissionView(BaseSubmissionViewTestCase): response = self.get_page(submission) self.assertNotContains(response, 'Value') + def test_can_screen_submission(self): + screening_outcome = ScreeningStatusFactory() + self.post_page(self.submission, {'form-submitted-screening_form': '', 'screening_status': screening_outcome.id}) + submission = self.refresh(self.submission) + self.assertEqual(submission.screening_status, screening_outcome) + + def test_cant_screen_submission(self): + """ + Now that the submission has been rejected, we cannot screen it as staff + """ + submission = ApplicationSubmissionFactory(rejected=True) + screening_outcome = ScreeningStatusFactory() + response = self.post_page(submission, {'form-submitted-screening_form': '', 'screening_status': screening_outcome.id}) + self.assertEqual(response.context_data['screening_form'].should_show, False) + class TestReviewersUpdateView(BaseSubmissionViewTestCase): user_factory = StaffFactory @@ -361,6 +377,17 @@ class TestApplicantSubmissionView(BaseSubmissionViewTestCase): response = self.get_page(submission, 'edit') self.assertEqual(response.status_code, 403) + def test_cant_screen_submission(self): + """ + Test that an applicant cannot set the screening status + and that they don't see the screening status form. + """ + screening_outcome = ScreeningStatusFactory() + response = self.post_page(self.submission, {'form-submitted-screening_form': '', 'screening_status': screening_outcome.id}) + self.assertNotIn('screening_form', response.context_data) + submission = self.refresh(self.submission) + self.assertNotEqual(submission.screening_status, screening_outcome) + class TestRevisionsView(BaseSubmissionViewTestCase): user_factory = UserFactory @@ -608,3 +635,33 @@ class TestApplicantSubmissionByRound(ByRoundTestCase): def test_cant_access_non_existing_page(self): response = self.get_page({'id': 555}) self.assertEqual(response.status_code, 403) + + +class TestSuperUserSubmissionView(BaseSubmissionViewTestCase): + user_factory = SuperUserFactory + + @classmethod + def setUpTestData(cls): + cls.submission = ApplicationSubmissionFactory() + super().setUpTestData() + + def __setUp__(self): + self.refresh(self.submission) + + def test_can_screen_submission(self): + screening_outcome = ScreeningStatusFactory() + self.post_page(self.submission, {'form-submitted-screening_form': '', 'screening_status': screening_outcome.id}) + submission = self.refresh(self.submission) + self.assertEqual(submission.screening_status, screening_outcome) + + def test_can_screen_applications_in_final_status(self): + """ + Now that the submission has been rejected (final determination), + we can still screen it because we are super user + """ + submission = ApplicationSubmissionFactory(rejected=True) + screening_outcome = ScreeningStatusFactory() + response = self.post_page(submission, {'form-submitted-screening_form': '', 'screening_status': screening_outcome.id}) + submission = self.refresh(submission) + self.assertEqual(response.context_data['screening_form'].should_show, True) + self.assertEqual(submission.screening_status, screening_outcome) diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index aed7a7f0088f7799e9a89569fd5a4592782b3379..207e2203c0a280fe00f35734fe57168be41b78ea 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -30,7 +30,7 @@ from opentech.apply.users.decorators import staff_required from opentech.apply.utils.views import DelegateableView, ViewDispatcher from .differ import compare -from .forms import ProgressSubmissionForm, UpdateReviewersForm, UpdateSubmissionLeadForm +from .forms import ProgressSubmissionForm, ScreeningSubmissionForm, UpdateReviewersForm, UpdateSubmissionLeadForm from .models import ApplicationSubmission, ApplicationRevision, RoundBase, LabBase from .tables import AdminSubmissionsTable, SubmissionFilterAndSearch from .workflow import STAGE_CHANGE_ACTIONS @@ -112,6 +112,26 @@ class ProgressSubmissionView(DelegatedViewMixin, UpdateView): return super().form_valid(form) +@method_decorator(staff_required, name='dispatch') +class ScreeningSubmissionView(DelegatedViewMixin, UpdateView): + model = ApplicationSubmission + form_class = ScreeningSubmissionForm + context_name = 'screening_form' + + def form_valid(self, form): + old = copy(self.get_object()) + response = super().form_valid(form) + # Record activity + messenger( + MESSAGES.SCREENING, + request=self.request, + user=self.request.user, + submission=self.object, + related=old.screening_status or '-', + ) + return response + + @method_decorator(staff_required, name='dispatch') class UpdateLeadView(DelegatedViewMixin, UpdateView): model = ApplicationSubmission @@ -162,6 +182,7 @@ class AdminSubmissionDetailView(ReviewContextMixin, ActivityContextMixin, Delega model = ApplicationSubmission form_views = [ ProgressSubmissionView, + ScreeningSubmissionView, CommentFormView, UpdateLeadView, UpdateReviewersView,