diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index 653530a32e306c1c556cc7e680eb2cab2f837b56..3dd42e5c89dcaa26d0c5c931b08d98b314830696 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -218,6 +218,7 @@ class ActivityAdapter(AdapterBase): MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source}', MESSAGES.CREATED_PROJECT: '{user} has created Project', MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from from {old_lead} to {source.lead} by {user}', + MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval on Project', } def recipients(self, message_type, **kwargs): @@ -379,6 +380,7 @@ class SlackAdapter(AdapterBase): MESSAGES.CREATED_PROJECT: '{user} has created a Project: <{link}|{source.title}>.', MESSAGES.UPDATE_PROJECT_LEAD: 'The lead of project <{link}|{source.title}> has been updated from {old_lead} to {source.lead} by {user}', MESSAGES.EDIT_REVIEW: '{user} has edited {review.author} review for <{link}|{source.title}>.', + MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval on project <{link}|{source.title}>.', } def __init__(self): @@ -407,6 +409,13 @@ class SlackAdapter(AdapterBase): } def recipients(self, message_type, source, related, **kwargs): + if message_type == MESSAGES.SEND_FOR_APPROVAL: + return [ + self.slack_id(user) + for slack_id in User.objects.approvers() + if self.slack_id(user) + ] + recipients = [self.slack_id(source.lead)] # Notify second reviewer when first reviewer is done. diff --git a/opentech/apply/activity/migrations/0034_add_send_for_approval.py b/opentech/apply/activity/migrations/0034_add_send_for_approval.py new file mode 100644 index 0000000000000000000000000000000000000000..4dcb14b42ac373f6837dca31c7a84ec74b1ef01d --- /dev/null +++ b/opentech/apply/activity/migrations/0034_add_send_for_approval.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-05 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0033_remove_old_submission_fk_event'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('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'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index 969eee9c5670ad0670215a67369d8e959b3dc9bd..8e253c8b6dc21c254f74e9679a1ed86302e1594d 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -29,6 +29,7 @@ class MESSAGES(Enum): CREATED_PROJECT = 'Created Project' UPDATE_PROJECT_LEAD = 'Update Project Lead' EDIT_REVIEW = 'Edit Review' + SEND_FOR_APPROVAL = 'Send for Approval' @classmethod def choices(cls): diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py index c49e67095f2407113ed3f157c611d7efd08183dc..cb18266afe9609f5ce454342ebe8c890ed3df5b7 100644 --- a/opentech/apply/projects/forms.py +++ b/opentech/apply/projects/forms.py @@ -5,7 +5,7 @@ from addressfield.fields import AddressField from opentech.apply.funds.models import ApplicationSubmission from opentech.apply.users.groups import STAFF_GROUP_NAME -from .models import Project +from .models import Project, COMMITTED class CreateProjectForm(forms.Form): @@ -54,6 +54,29 @@ class ProjectEditForm(forms.ModelForm): return super().save(*args, **kwargs) +class SetPendingForm(forms.ModelForm): + class Meta: + fields = ['id'] + model = Project + widgets = {'id': forms.HiddenInput()} + + def __init__(self, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + def clean(self): + if self.instance.status != COMMITTED: + raise forms.ValidationError('A Project can only be sent for Approval when Committed.') + + if self.instance.is_locked: + raise forms.ValidationError('A Project can only be sent for Approval once') + + super().clean() + + def save(self, *args, **kwargs): + self.instance.is_locked = True + return super().save(*args, **kwargs) + + class UpdateProjectLeadForm(forms.ModelForm): class Meta: fields = ['lead'] diff --git a/opentech/apply/projects/migrations/0009_add_approval.py b/opentech/apply/projects/migrations/0009_add_approval.py new file mode 100644 index 0000000000000000000000000000000000000000..92b0c41572e2d1549f53767d3da3dcff0b4424ae --- /dev/null +++ b/opentech/apply/projects/migrations/0009_add_approval.py @@ -0,0 +1,50 @@ +# Generated by Django 2.0.13 on 2019-08-06 09:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('application_projects', '0009_documentcategory'), + ] + + operations = [ + migrations.CreateModel( + name='Approval', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='project', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='project', + name='is_locked', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='project', + name='status', + field=models.TextField(choices=[('committed', 'Committed'), ('contracting', 'Contracting'), ('in_progress', 'In Progress'), ('closing', 'Closing'), ('complete', 'Complete')], default='committed'), + ), + migrations.AddField( + model_name='approval', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='application_projects.Project'), + ), + migrations.AlterUniqueTogether( + name='approval', + unique_together={('project', 'by')}, + ), + ] diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index 7d8364d0776b5a4df465124a350589cf113326b4..6c771c1e44fb43108d44c530fdd94c2abcc1d2d0 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -9,6 +9,29 @@ from django.urls import reverse from django.utils.translation import ugettext as _ +class Approval(models.Model): + project = models.ForeignKey("Project", on_delete=models.CASCADE) + by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ['project', 'by'] + + def __str__(self): + return f'Approval of "{self.project.title}" by {self.by}' + + +COMMITTED = 'committed' +PROJECT_STATUS_CHOICES = [ + (COMMITTED, 'Committed'), + ('contracting', 'Contracting'), + ('in_progress', 'In Progress'), + ('closing', 'Closing'), + ('complete', 'Complete'), +] + + class Project(models.Model): lead = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='lead_projects') submission = models.OneToOneField("funds.ApplicationSubmission", on_delete=models.CASCADE) @@ -29,6 +52,11 @@ class Project(models.Model): proposed_start = models.DateTimeField(_('Proposed Start Date'), null=True) proposed_end = models.DateTimeField(_('Proposed End Date'), null=True) + status = models.TextField(choices=PROJECT_STATUS_CHOICES, default=COMMITTED) + + # tracks read/write state of the Project + is_locked = models.BooleanField(default=False) + # tracks updates to the Projects fields via the Project Application Form. user_has_updated_details = models.BooleanField(default=False) @@ -38,6 +66,7 @@ class Project(models.Model): object_id_field='source_object_id', related_query_name='project', ) + created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.title @@ -79,6 +108,17 @@ class Project(models.Model): def get_absolute_url(self): return reverse('apply:projects:detail', args=[self.id]) + @property + def is_pending_approval(self): + """ + Wrapper to expose the pending approval state + + We don't want to expose a "Sent for Approval" state to the end User so + we infer it from the current status being "Comitted" and the Project + being locked. + """ + return self.status == COMMITTED and self.is_locked + class DocumentCategory(models.Model): name = models.CharField(max_length=254) diff --git a/opentech/apply/projects/templates/application_projects/project_admin_detail.html b/opentech/apply/projects/templates/application_projects/project_admin_detail.html index b8247d6c88077a1adadf792f8f63d551211876e0..105842c32f20d10a7be7814b592c2ab5890f2f4a 100644 --- a/opentech/apply/projects/templates/application_projects/project_admin_detail.html +++ b/opentech/apply/projects/templates/application_projects/project_admin_detail.html @@ -3,6 +3,11 @@ {% load static %} {% block admin_sidebar %} +<div class="modal" id="send-for-approval"> + <h4 class="modal__header-bar">Request Approval</h4> + {% include 'funds/includes/delegated_form_base.html' with form=approval_form value='Request'%} +</div> + <div class="modal" id="assign-lead"> <h4 class="modal__header-bar">Assign Lead</h4> {% include 'funds/includes/delegated_form_base.html' with form=lead_form value='Update'%} @@ -16,12 +21,14 @@ <h5>Actions to take</h5> + {% if not object.is_pending_approval %} <a data-fancybox data-src="#send-for-approval" class="button button--bottom-space button--primary button--full-width" href="#"> Send for approval </a> + {% endif %} <a data-fancybox data-src="#ready-for-contracting" diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py index 2bb01a348ff80d3622941bce885647e4f235eab3..b6eda3f39af5a0e6255dd4e7d7c9dfc1e8ca2ad3 100644 --- a/opentech/apply/projects/tests/test_views.py +++ b/opentech/apply/projects/tests/test_views.py @@ -4,7 +4,9 @@ from opentech.apply.users.tests.factories import (ReviewerFactory, StaffFactory, SuperUserFactory, UserFactory) +from opentech.apply.utils.testing.tests import BaseViewTestCase +from ..forms import SetPendingForm from ..views import ProjectDetailView from .factories import ProjectFactory @@ -41,3 +43,39 @@ class TestProjectDetailView(TestCase): response = ProjectDetailView.as_view()(request, pk=self.project.pk) self.assertEqual(response.status_code, 200) + + +class TestSendForApprovalView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_send_for_approval_fails_when_project_is_locked(self): + project = ProjectFactory(is_locked=True) + + # The view doesn't have any custom changes when form validation fails + # so check that directly. + form = SetPendingForm(instance=project) + self.assertFalse(form.is_valid()) + + def test_send_for_approval_fails_when_project_is_not_in_committed_state(self): + project = ProjectFactory(status='in_progress') + + # The view doesn't have any custom changes when form validation fails + # so check that directly. + form = SetPendingForm(instance=project) + self.assertFalse(form.is_valid()) + + def test_send_for_approval_happy_path(self): + project = ProjectFactory(is_locked=False, status='committed') + + response = self.post_page(project, {'form-submitted-approval_form': ''}) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertTrue(project.is_locked) + self.assertEqual(project.status, 'committed') diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py index 63e88bc5d182d3a1773cab7b9f94e68337f25e11..a0b6b86671d8486dc945dcc06fdeb79195dcbbee 100644 --- a/opentech/apply/projects/views.py +++ b/opentech/apply/projects/views.py @@ -9,8 +9,29 @@ from opentech.apply.users.decorators import staff_required from opentech.apply.utils.views import (DelegateableView, DelegatedViewMixin, ViewDispatcher) -from .forms import ProjectEditForm, UpdateProjectLeadForm -from .models import Project, DocumentCategory +from .forms import ProjectEditForm, SetPendingForm, UpdateProjectLeadForm +from .models import DocumentCategory, Project + + +@method_decorator(staff_required, name='dispatch') +class SendForApprovalView(DelegatedViewMixin, UpdateView): + context_name = 'approval_form' + form_class = SetPendingForm + model = Project + + def form_valid(self, form): + # lock project + response = super().form_valid(form) + + messenger( + MESSAGES.SEND_FOR_APPROVAL, + request=self.request, + user=self.request.user, + source=form.instance.submission, + project=form.instance, + ) + + return response @method_decorator(staff_required, name='dispatch') @@ -38,6 +59,7 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView): class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView): form_views = [ + SendForApprovalView, UpdateLeadView, CommentFormView, ] diff --git a/opentech/apply/users/groups.py b/opentech/apply/users/groups.py index cc9cad3806bf3e4bf2dd4a435da1170dcbb10c0a..0fb0a9f0bff52c703b3e7e42506034fca497784d 100644 --- a/opentech/apply/users/groups.py +++ b/opentech/apply/users/groups.py @@ -4,6 +4,7 @@ REVIEWER_GROUP_NAME = 'Reviewer' TEAMADMIN_GROUP_NAME = 'Team Admin' PARTNER_GROUP_NAME = 'Partner' COMMUNITY_REVIEWER_GROUP_NAME = 'Community reviewer' +APPROVER_GROUP_NAME = 'Approver' GROUPS = [ { @@ -30,4 +31,8 @@ GROUPS = [ 'name': COMMUNITY_REVIEWER_GROUP_NAME, 'permissions': [], }, + { + 'name': APPROVER_GROUP_NAME, + 'permissions': [], + } ] diff --git a/opentech/apply/users/migrations/0013_add_approver_group.py b/opentech/apply/users/migrations/0013_add_approver_group.py new file mode 100644 index 0000000000000000000000000000000000000000..54f9f968f7acf0efcbb62232970719a4ac83517f --- /dev/null +++ b/opentech/apply/users/migrations/0013_add_approver_group.py @@ -0,0 +1,43 @@ +# Generated by Django 2.0.13 on 2019-08-05 13:12 + +from django.core.exceptions import ObjectDoesNotExist +from django.core.management.sql import emit_post_migrate_signal +from django.db import migrations + +from opentech.apply.users.groups import APPROVER_GROUP_NAME, GROUPS + + +def add_groups(apps, schema_editor): + # Workaround for https://code.djangoproject.com/ticket/23422 + db_alias = schema_editor.connection.alias + emit_post_migrate_signal(2, False, db_alias) + + Group = apps.get_model('auth.Group') + Permission = apps.get_model('auth.Permission') + + for group_data in GROUPS: + group, created = Group.objects.get_or_create(name=group_data['name']) + for codename in group_data['permissions']: + try: + permission = Permission.objects.get(codename=codename) + except ObjectDoesNotExist: + print(f"Could not find the '{permission}' permission") + continue + + group.permissions.add(permission) + + +def remove_groups(apps, schema_editor): + Group = apps.get_model('auth.Group') + Group.objects.filter(name=APPROVER_GROUP_NAME).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0012_set_applicant_group'), + ] + + operations = [ + migrations.RunPython(add_groups, remove_groups) + ] diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py index 9609eb97e1ebfdf0d7b2b5b866f828088fba333b..768b583c958865dcd2851c39fa5ffeb686d3845b 100644 --- a/opentech/apply/users/models.py +++ b/opentech/apply/users/models.py @@ -1,11 +1,14 @@ -from django.db import models -from django.db.models import Q from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser, BaseUserManager, Group +from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from .groups import APPLICANT_GROUP_NAME, REVIEWER_GROUP_NAME, STAFF_GROUP_NAME, PARTNER_GROUP_NAME, COMMUNITY_REVIEWER_GROUP_NAME + +from .groups import (APPLICANT_GROUP_NAME, APPROVER_GROUP_NAME, + COMMUNITY_REVIEWER_GROUP_NAME, PARTNER_GROUP_NAME, + REVIEWER_GROUP_NAME, STAFF_GROUP_NAME) from .utils import send_activation_email @@ -27,6 +30,9 @@ class UserQuerySet(models.QuerySet): def applicants(self): return self.filter(groups__name=APPLICANT_GROUP_NAME) + def approvers(self): + return self.filter(groups__name=APPROVER_GROUP_NAME) + class UserManager(BaseUserManager.from_queryset(UserQuerySet)): use_in_migrations = True @@ -132,6 +138,10 @@ class User(AbstractUser): def is_applicant(self): return self.groups.filter(name=APPLICANT_GROUP_NAME).exists() + @cached_property + def is_approver(self): + return self.groups.filter(name=APPROVER_GROUP_NAME).exists() + class Meta: ordering = ('full_name', 'email')