diff --git a/hypha/apply/dashboard/templates/dashboard/contracting_dashboard.html b/hypha/apply/dashboard/templates/dashboard/contracting_dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..8d0b6d7e570b61bab5d8ea41cec3cc8af5fe95e8 --- /dev/null +++ b/hypha/apply/dashboard/templates/dashboard/contracting_dashboard.html @@ -0,0 +1,34 @@ +{% extends "base-apply.html" %} +{% load render_table from django_tables2 %} +{% load i18n static %} + +{% block title %}{% trans "Dashboard" %}{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner admin-bar__inner--with-button"> + {% block page_header %} + <h1 class="gamma heading heading--no-margin heading--bold">{% trans "Dashboard" %}</h1> + {% endblock %} + <a href="{% url 'wagtailadmin_home' %}" class="button button--primary button--arrow-pixels-white"> + {% trans "Apply admin" %} + <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> + </a> + </div> +</div> +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + {% if waiting_for_approval.count %} + <div id="paf-awaiting-approval" class="wrapper wrapper--bottom-space"> + <h4 class="heading heading--normal">{% trans "PAF awaiting approval" %}</h4> + {% render_table waiting_for_approval.table %} + </div> + {% endif %} +</div> +{% endblock %} + +{% block extra_js %} + <script src="{% static 'js/apply/url-search-params.js' %}"></script> + <script src="{% static 'js/apply/submission-filters.js' %}"></script> + <script src="{% static 'js/apply/submission-tooltips.js' %}"></script> + <script src="{% static 'js/apply/tabs.js' %}"></script> +{% endblock %} diff --git a/hypha/apply/dashboard/templates/dashboard/finance_dashboard.html b/hypha/apply/dashboard/templates/dashboard/finance_dashboard.html index 98b17737fdc551f8a31a64ffb67ed48a587daeaa..d3985ae2ccb0563c7ff19cd5cda7bf03e4d5f71f 100644 --- a/hypha/apply/dashboard/templates/dashboard/finance_dashboard.html +++ b/hypha/apply/dashboard/templates/dashboard/finance_dashboard.html @@ -25,6 +25,13 @@ {% trans "No Active Invoices" %} {% endif %} </div> + + {% if waiting_for_approval.count %} + <div id="paf-awaiting-approval" class="wrapper wrapper--bottom-space"> + <h4 class="heading heading--normal">{% trans "PAF awaiting approval" %}</h4> + {% render_table waiting_for_approval.table %} + </div> + {% endif %} </div> {% endblock %} diff --git a/hypha/apply/dashboard/tests/test_views.py b/hypha/apply/dashboard/tests/test_views.py index 4f1fc1ae03f6c94f4652f3afbe3dc4e08062c7cc..a4e160acbce7aca7e0e68b5cb1b231cf9b4db15d 100644 --- a/hypha/apply/dashboard/tests/test_views.py +++ b/hypha/apply/dashboard/tests/test_views.py @@ -10,7 +10,7 @@ from hypha.apply.projects.models.payment import ( RESUBMITTED, SUBMITTED, ) -from hypha.apply.projects.models.project import COMMITTED +from hypha.apply.projects.models.project import WAITING_FOR_APPROVAL from hypha.apply.projects.tests.factories import InvoiceFactory, ProjectFactory from hypha.apply.review.tests.factories import ReviewFactory, ReviewOpinionFactory from hypha.apply.users.groups import APPROVER_GROUP_NAME @@ -135,13 +135,13 @@ class TestStaffDashboard(BaseViewTestCase): self.assertNotContains(response, "Active Invoices") def test_non_project_approver_cannot_see_projects_awaiting_review_stats_or_table(self): - ProjectFactory(is_locked=True, status=COMMITTED) + ProjectFactory(is_locked=False, status=WAITING_FOR_APPROVAL) response = self.get_page() self.assertNotContains(response, "Projects awaiting approval") def test_project_approver_can_see_projects_awaiting_review_stats_or_table(self): - ProjectFactory(is_locked=True, status=COMMITTED) + ProjectFactory(is_locked=False, status=WAITING_FOR_APPROVAL) user = StaffFactory() user.groups.add(GroupFactory(name=APPROVER_GROUP_NAME)) diff --git a/hypha/apply/dashboard/views.py b/hypha/apply/dashboard/views.py index f08e327d03c75d6acf94f7d292fb642b9ba1a288..9ac43c32d82e9ec7b365309490083d2fc595bf93 100644 --- a/hypha/apply/dashboard/views.py +++ b/hypha/apply/dashboard/views.py @@ -122,7 +122,7 @@ class AdminDashboardView(MyFlaggedMixin, TemplateView): 'table': None, } - to_approve = Project.objects.in_approval().for_table() + to_approve = Project.objects.waiting_for_approval().for_table() return { 'count': to_approve.count(), @@ -160,6 +160,7 @@ class FinanceDashboardView(MyFlaggedMixin, TemplateView): context.update({ 'active_invoices': self.active_invoices(), + 'waiting_for_approval': self.waiting_for_approval(), }) return context @@ -175,6 +176,19 @@ class FinanceDashboardView(MyFlaggedMixin, TemplateView): 'table': InvoiceDashboardTable(invoices), } + def waiting_for_approval(self): + if not self.request.user.is_finance: + return { + 'count': None, + 'table': None, + } + + to_paf_approve = Project.objects.waiting_for_approval().for_table() + return { + 'count': to_paf_approve.count(), + 'table': ProjectsDashboardTable(data=to_paf_approve), + } + class ReviewerDashboardView(MyFlaggedMixin, MySubmissionContextMixin, TemplateView): template_name = 'dashboard/reviewer_dashboard.html' @@ -261,6 +275,31 @@ class PartnerDashboardView(MySubmissionContextMixin, TemplateView): return partner_submissions, partner_submissions_table +class ContractingDashboardView(MyFlaggedMixin, TemplateView): + template_name = 'dashboard/contracting_dashboard.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({ + 'waiting_for_approval': self.waiting_for_approval() + }) + + return context + + def waiting_for_approval(self): + if not self.request.user.is_contracting: + return { + 'count': None, + 'table': None, + } + + to_paf_approve = Project.objects.waiting_for_approval().for_table() + return { + 'count': to_paf_approve.count(), + 'table': ProjectsDashboardTable(data=to_paf_approve), + } + + class CommunityDashboardView(MySubmissionContextMixin, TemplateView): template_name = 'dashboard/community_dashboard.html' @@ -341,3 +380,4 @@ class DashboardView(ViewDispatcher): community_view = CommunityDashboardView applicant_view = ApplicantDashboardView finance_view = FinanceDashboardView + contracting_view = ContractingDashboardView diff --git a/hypha/apply/funds/admin.py b/hypha/apply/funds/admin.py index ccb575ed2c2c9f31e9176c0cd90aac1472ee8fb6..d22c52cd70a48d58a0e65394ffa0b989222f191f 100644 --- a/hypha/apply/funds/admin.py +++ b/hypha/apply/funds/admin.py @@ -6,6 +6,7 @@ from wagtail.contrib.modeladmin.options import ModelAdmin, ModelAdminGroup from hypha.apply.categories.admin import CategoryAdmin, MetaTermAdmin from hypha.apply.determinations.admin import DeterminationFormAdmin from hypha.apply.funds.models import ReviewerRole, ScreeningStatus +from hypha.apply.projects.admin import ProjectApprovalFormAdmin from hypha.apply.review.admin import ReviewFormAdmin from hypha.apply.utils.admin import ListRelatedMixin @@ -210,6 +211,7 @@ class ApplyAdminGroup(ModelAdminGroup): ApplicationFormAdmin, ReviewFormAdmin, DeterminationFormAdmin, + ProjectApprovalFormAdmin, CategoryAdmin, ScreeningStatusAdmin, ReviewerRoleAdmin, diff --git a/hypha/apply/projects/admin.py b/hypha/apply/projects/admin.py index 2192a905029d65670e97d25449ee6d72694fc0f6..149345f397130576832e72f69e59df0b7fbd5b69 100644 --- a/hypha/apply/projects/admin.py +++ b/hypha/apply/projects/admin.py @@ -31,5 +31,4 @@ class ManageAdminGoup(ModelAdminGroup): menu_icon = 'folder-open-inverse' items = ( DocumentCategoryAdmin, - ProjectApprovalFormAdmin, ) diff --git a/hypha/apply/projects/forms/__init__.py b/hypha/apply/projects/forms/__init__.py index a5f789bb06ce442cca232c51a4d3d3559fa24b26..73beff1943fbe43ff217092ce8a8cd804527f927 100644 --- a/hypha/apply/projects/forms/__init__.py +++ b/hypha/apply/projects/forms/__init__.py @@ -6,10 +6,10 @@ from .payment import ( ) from .project import ( ApproveContractForm, - CreateApprovalForm, + ChangePAFStatusForm, CreateProjectForm, + FinalApprovalForm, ProjectApprovalForm, - RejectionForm, RemoveDocumentForm, SetPendingForm, StaffUploadContractForm, @@ -30,10 +30,10 @@ from .vendor import ( __all__ = [ 'SelectDocumentForm', 'ApproveContractForm', + 'ChangePAFStatusForm', 'CreateProjectForm', - 'CreateApprovalForm', + 'FinalApprovalForm', 'ProjectApprovalForm', - 'RejectionForm', 'RemoveDocumentForm', 'SetPendingForm', 'UploadContractForm', diff --git a/hypha/apply/projects/forms/project.py b/hypha/apply/projects/forms/project.py index c7e3e2c7329481f3d62d8bd5bcd14e564e5bc41d..b967c98022e550d38d3ee01b945b50791cbe7207 100644 --- a/hypha/apply/projects/forms/project.py +++ b/hypha/apply/projects/forms/project.py @@ -7,7 +7,14 @@ from hypha.apply.funds.models import ApplicationSubmission from hypha.apply.stream_forms.forms import StreamBaseForm from hypha.apply.users.groups import STAFF_GROUP_NAME -from ..models.project import COMMITTED, Approval, Contract, PacketFile, Project +from ..models.project import ( + COMMITTED, + PAF_STATUS_CHOICES, + Contract, + PacketFile, + PAFReviewersRole, + Project, +) User = get_user_model() @@ -56,25 +63,17 @@ class CreateProjectForm(forms.Form): return Project.create_from_submission(submission) -class CreateApprovalForm(forms.ModelForm): - by = forms.ModelChoiceField( - queryset=User.objects.approvers(), - widget=forms.HiddenInput(), - ) +class FinalApprovalForm(forms.ModelForm): + name_prefix = 'final_approval_form' + final_approval_status = forms.ChoiceField(choices=PAF_STATUS_CHOICES) + comment = forms.CharField(required=False, widget=forms.Textarea) class Meta: - model = Approval - fields = ('by',) - - def __init__(self, user=None, *args, **kwargs): - self.user = user - super().__init__(*args, **kwargs) + model = Project + fields = ['final_approval_status', 'comment'] - def clean_by(self): - by = self.cleaned_data['by'] - if by != self.user: - raise forms.ValidationError(_('Cannot approve for a different user')) - return by + def __init__(self, instance, user=None, *args, **kwargs): + super().__init__(instance=instance, *args, **kwargs) class MixedMetaClass(type(StreamBaseForm), type(forms.ModelForm)): @@ -113,11 +112,19 @@ class ProjectApprovalForm(StreamBaseForm, forms.ModelForm, metaclass=MixedMetaCl return super().save(*args, **kwargs) -class RejectionForm(forms.Form): - comment = forms.CharField(widget=forms.Textarea) +class ChangePAFStatusForm(forms.ModelForm): + name_prefix = 'change_paf_status_form' + paf_reviewers_roles = PAFReviewersRole.objects.all().only('role') + paf_status = forms.ChoiceField(choices=PAF_STATUS_CHOICES) + role = forms.ModelChoiceField(queryset=paf_reviewers_roles) + comment = forms.CharField(required=False, widget=forms.Textarea) - def __init__(self, instance=None, user=None, *args, **kwargs): - super().__init__(*args, **kwargs) + class Meta: + fields = ['paf_status', 'role', 'comment'] + model = Project + + def __init__(self, instance, user, *args, **kwargs): + super().__init__(instance=instance, *args, **kwargs) class RemoveDocumentForm(forms.ModelForm): @@ -144,15 +151,8 @@ class SetPendingForm(forms.ModelForm): 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 UploadContractForm(forms.ModelForm): class Meta: diff --git a/hypha/apply/projects/migrations/0055_alter_project_status_add_pafreviewersrole.py b/hypha/apply/projects/migrations/0055_alter_project_status_add_pafreviewersrole.py new file mode 100644 index 0000000000000000000000000000000000000000..83d1dc25ac02f2070a4ce2592e404a5ce145df19 --- /dev/null +++ b/hypha/apply/projects/migrations/0055_alter_project_status_add_pafreviewersrole.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.14 on 2022-08-09 04:59 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0054_alter_project_form_fields'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='paf_reviews_meta_data', + field=models.JSONField(default=dict, help_text='Reviewers role and their actions/comments'), + ), + migrations.AlterField( + model_name='project', + name='status', + field=models.TextField(choices=[('committed', 'Committed'), ('waiting_for_approval', 'Waiting for Approval'), ('contracting', 'Contracting'), ('in_progress', 'In Progress'), ('closing', 'Closing'), ('complete', 'Complete')], default='committed'), + ), + migrations.CreateModel( + name='PAFReviewersRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('role', models.CharField(max_length=200)), + ('page', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='paf_reviewers_roles', to='application_projects.projectsettings')), + ], + options={ + 'ordering': ['sort_order'], + 'abstract': False, + }, + ), + ] diff --git a/hypha/apply/projects/models/project.py b/hypha/apply/projects/models/project.py index c1441746d66bba4ce6f2ae8ceb0c6eed896df1fa..594392f62f8c9adf868b8cc8ee52ceb7eb1b4e92 100644 --- a/hypha/apply/projects/models/project.py +++ b/hypha/apply/projects/models/project.py @@ -16,8 +16,11 @@ from django.dispatch.dispatcher import receiver from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from wagtail.admin.panels import FieldPanel +from modelcluster.fields import ParentalKey +from modelcluster.models import ClusterableModel +from wagtail.admin.panels import FieldPanel, InlinePanel from wagtail.contrib.settings.models import BaseSetting, register_setting +from wagtail.core.models import Orderable from wagtail.fields import StreamField from addressfield.fields import ADDRESS_FIELDS_ORDER @@ -40,13 +43,22 @@ def document_path(instance, filename): return f'projects/{instance.project_id}/supporting_documents/{filename}' +APPROVE = 'approve' +REQUEST_CHANGE = 'request_change' +PAF_STATUS_CHOICES = ( + (APPROVE, 'Approve'), + (REQUEST_CHANGE, 'Request Change') +) + COMMITTED = 'committed' +WAITING_FOR_APPROVAL = 'waiting_for_approval' CONTRACTING = 'contracting' IN_PROGRESS = 'in_progress' CLOSING = 'closing' COMPLETE = 'complete' PROJECT_STATUS_CHOICES = [ (COMMITTED, _('Committed')), + (WAITING_FOR_APPROVAL, _('Waiting for Approval')), (CONTRACTING, _('Contracting')), (IN_PROGRESS, _('In Progress')), (CLOSING, _('Closing')), @@ -68,11 +80,9 @@ class ProjectQuerySet(models.QuerySet): def complete(self): return self.filter(status=COMPLETE) - def in_approval(self): + def waiting_for_approval(self): return self.filter( - is_locked=True, - status=COMMITTED, - approvals__isnull=True, + status=WAITING_FOR_APPROVAL, ) def by_end_date(self, desc=False): @@ -169,6 +179,11 @@ class Project(BaseStreamForm, AccessFormData, models.Model): ) sent_to_compliance_at = models.DateTimeField(null=True) + paf_reviews_meta_data = models.JSONField( + default=dict, + help_text='Reviewers role and their actions/comments' + ) + objects = ProjectQuerySet.as_manager() def __str__(self): @@ -237,10 +252,12 @@ class Project(BaseStreamForm, AccessFormData, models.Model): def end_date(self): # Aiming for the proposed end date as the last day of the project # If still ongoing assume today is the end - return max( - self.proposed_end.date(), - timezone.now().date(), - ) + if self.proposed_end: + return max( + self.proposed_end.date(), + timezone.now().date(), + ) + return timezone.now().date() def paid_value(self): return self.invoices.paid_value() @@ -273,22 +290,27 @@ class Project(BaseStreamForm, AccessFormData, models.Model): def editable_by(self, user): if self.editable: - return True + # Approver can edit it when they are approving + if self.can_make_approval: + if user.is_finance or user.is_approver or user.is_contracting: + return True - # Approver can edit it when they are approving - return user.is_approver and self.can_make_approval + # Lead can make changes to the project + if user == self.lead: + return True + + # Staff can edit project + if user.is_apply_staff: + return True + return False @property def editable(self): - if self.status not in (CONTRACTING, COMMITTED): - return True - - # Someone has approved the project - consider it locked while with contracting - if self.approvals.exists(): + if self.is_locked: return False - - # Someone must lead the project to make changes - return self.lead and not self.is_locked + elif self.status in (COMMITTED, WAITING_FOR_APPROVAL): # locked condition is enough,it is just for double check + return True + return False def get_absolute_url(self): if settings.PROJECTS_ENABLED: @@ -297,7 +319,22 @@ class Project(BaseStreamForm, AccessFormData, models.Model): @property def can_make_approval(self): - return self.is_locked and self.status == COMMITTED + return self.status == WAITING_FOR_APPROVAL + + @property + def can_make_final_approval(self): + if self.status == WAITING_FOR_APPROVAL: + paf_reviewers_count = PAFReviewersRole.objects.all().count() + if paf_reviewers_count == len(self.paf_reviews_meta_data): + for paf_review_data in self.paf_reviews_meta_data.values(): + if paf_review_data['status'] == REQUEST_CHANGE: + return False + return True + return False + + @property + def can_update_paf_status(self): + return self.status == WAITING_FOR_APPROVAL and not self.can_make_final_approval def can_request_funding(self): """ @@ -359,19 +396,6 @@ class Project(BaseStreamForm, AccessFormData, models.Model): return reference_number.split('-')[0] return '' - # def send_to_compliance(self, request): - # """Notify Compliance about this Project.""" - - # messenger( - # MESSAGES.SENT_TO_COMPLIANCE, - # request=request, - # user=request.user, - # source=self, - # ) - - # self.sent_to_compliance_at = timezone.now() - # self.save(update_fields=['sent_to_compliance_at']) - class ProjectApprovalForm(BaseStreamForm, models.Model): name = models.CharField(max_length=255) @@ -386,11 +410,25 @@ class ProjectApprovalForm(BaseStreamForm, models.Model): return self.name +class PAFReviewersRole(Orderable): + role = models.CharField(max_length=200) + page = ParentalKey('ProjectSettings', related_name='paf_reviewers_roles') + + def __str__(self): + return str(self.role) + + @register_setting -class ProjectSettings(BaseSetting): +class ProjectSettings(BaseSetting, ClusterableModel): compliance_email = models.TextField("Compliance Email") vendor_setup_required = models.BooleanField(default=True) + panels = [ + FieldPanel('compliance_email'), + FieldPanel('vendor_setup_required'), + InlinePanel('paf_reviewers_roles', label=_('PAF Reviewers Roles')), + ] + class Approval(models.Model): project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="approvals") diff --git a/hypha/apply/projects/templates/application_projects/project_admin_detail.html b/hypha/apply/projects/templates/application_projects/project_admin_detail.html index 7cc6eb5951f69e00fe74c60ede72fdd38fa49fbf..19d215cc15c2b88cc8a95b2182b7d8fd991169c5 100644 --- a/hypha/apply/projects/templates/application_projects/project_admin_detail.html +++ b/hypha/apply/projects/templates/application_projects/project_admin_detail.html @@ -31,17 +31,11 @@ </div> <div class="modal" id="approve"> - <h4 class="modal__header-bar">{% trans "Add Approval" %}</h4> + <h4 class="modal__header-bar">{% trans "Update Final Approval Status" %}</h4> <p>{% trans "This will move the project into contracting and notify the compliance team." %}</p> <p>{% trans "This cannot be undone." %}</p> - {% trans "Approve" as approve %} - {% include 'funds/includes/delegated_form_base.html' with form=add_approval_form value=approve %} -</div> - -<div class="modal" id="request-project-changes"> - <h4 class="modal__header-bar">{% trans "Request Changes" %}</h4> - {% trans "Request Changes" as request_changes %} - {% include 'funds/includes/delegated_form_base.html' with form=rejection_form value=request_changes %} + {% trans "Update status" as update %} + {% include 'funds/includes/delegated_form_base.html' with form=final_approval_form value=update %} </div> {% if contract_to_approve %} @@ -63,33 +57,35 @@ {% trans "A lead must be assigned" %} {% elif not object.user_has_updated_details %} {% trans "Project approval form must be completed" %} - {% elif object.is_locked %} + {% elif object.can_make_approval or object.can_make_final_approval %} {% trans "Currently awaiting approval" %} {% endif %}" {% endif %} data-fancybox data-src="#send-for-approval" - class="button button--bottom-space button--primary button--full-width {% if not object.can_send_for_approval %}button--tooltip-disabled{% endif %}" + class="button button--bottom-space button--primary button--full-width {% if not object.can_send_for_approval or not user.is_apply_staff %}button--tooltip-disabled{% endif %}" href="#"> {% trans "Submit for Approval" %} </a> {% endif %} {% if object.can_make_approval %} - {% user_can_approve_project object user as user_can_approve %} + {% user_can_final_approve_project object user as user_can_approve %} <a data-fancybox data-src="#approve" class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}" href="#"> - {% trans "Approve" %} + {% trans "Update Project Status" %} </a> - <a data-fancybox - data-src="#request-project-changes" - class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}" - href="#"> - {% trans "Request changes" %} - </a> + {% user_can_update_paf_status object user as user_can_update_paf_status %} + <a data-fancybox data-src="#change-status" class="button button--primary button--full-width {% if not user_can_update_paf_status %} is-disabled {% endif %}" href="#">{% trans "Update PAF Status" %}</a> + <div class="modal" id="change-status"> + <h4 class="modal__header-bar">{% trans "Update PAF Status" %}</h4> + <p>{% trans "Project's current status" %}: {{ object.get_status_display }}</p> + {% trans "Update Status" as update %} + {% include 'funds/includes/delegated_form_base.html' with form=change_paf_status value=update %} + </div> {% endif %} {% endblock %} diff --git a/hypha/apply/projects/templates/application_projects/project_approval_form.html b/hypha/apply/projects/templates/application_projects/project_approval_form.html index 7f41d66c57c5b4523daeecd67c879d425415940b..1fc5cb378269e2ba0571de0dee42d7b509e0df85 100644 --- a/hypha/apply/projects/templates/application_projects/project_approval_form.html +++ b/hypha/apply/projects/templates/application_projects/project_approval_form.html @@ -46,7 +46,6 @@ {% block extra_js %} <script src="{% static 'js/apply/tinymce-word-count.js' %}"></script> <script src="{% static 'js/apply/multi-input-fields.js' %}"></script> - <script src="{% static 'js/apply/submission-form-copy.js' %}"></script> <script src="{% static 'js/apply/application-form-links-new-window.js' %}"></script> {% if not show_all_group_fields %} <script src="{% static 'js/apply/form-group-toggle.js' %}"></script> diff --git a/hypha/apply/projects/templates/application_projects/project_detail.html b/hypha/apply/projects/templates/application_projects/project_detail.html index c33052d289edd43015c4d99c0d055dfae59da4e6..aae7ba9cacedfd21ebd95ba1e83e70c00fc10b02 100644 --- a/hypha/apply/projects/templates/application_projects/project_detail.html +++ b/hypha/apply/projects/templates/application_projects/project_detail.html @@ -120,7 +120,7 @@ <h5>{% trans "Actions to take" %}</h5> - {% if request.user.is_apply_staff %} + {% if request.user.is_apply_staff or request.user.is_contracting or request.user.is_finance %} {% block admin_actions %}{% endblock %} {% endif %} @@ -231,14 +231,14 @@ <div class="tabs__content" id="tab-2"> <div class="feed"> {% include "activity/include/comment_form.html" %} - {% include "activity/include/comment_list.html" with editable=True %} + {% include "activity/include/comment_list.html" with editable=False %} </div> </div> {# Tab 3 #} <div class="tabs__content" id="tab-3"> <div class="feed"> - {% include "activity/include/action_list.html" %} + {% include "activity/include/action_list.html" with editable=False %} </div> </div> </div> diff --git a/hypha/apply/projects/templates/application_projects/project_simplified_detail.html b/hypha/apply/projects/templates/application_projects/project_simplified_detail.html index 08a8d4be470964b26ca072205b2e89a4ca40a726..5bc3b1bb0539ac16a55a3685d67e87870b22cee1 100644 --- a/hypha/apply/projects/templates/application_projects/project_simplified_detail.html +++ b/hypha/apply/projects/templates/application_projects/project_simplified_detail.html @@ -1,8 +1,13 @@ {% extends "base-apply.html" %} -{% load i18n %} +{% load i18n static approval_tools %} {% block title %}{{ object.title }}{% endblock %} +{% block extra_css %} + <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> + {{ reviewer_form.media.css }} +{% endblock %} + {% block body_class %}light-grey-bg{% endblock %} {% block content %} @@ -26,109 +31,142 @@ </a> </div> </div> - - <div class="simplified__wrapper"> - <h3>{% trans "Project Information" %}</h3> - <div class="card card--solid"> - <div class="grid grid--proposal-info"> - <div> - <h5>{% trans "Proposed start date" %}</h5> - <p>{{ object.proposed_start|date:"DATE_FORMAT"|default:"-" }}</p> - </div> - - <div> - <h5>{% trans "Project Proposed end date" %}</h5> - <p>{{ object.proposed_end|date:"DATE_FORMAT"|default:"-" }}</p> - </div> - - <div> - <h5>{% trans "Legal name" %}</h5> - <p>{{ object.contact_legal_name|default:"-" }}</p> - </div> - - <div> - <h5>{% trans "E-mail" %}</h5> - <p>{{ object.contact_email|default:"-" }}</p> - </div> - - <div> - <h5>{% trans "Address" %}</h5> - <p>{{ object.get_address_display|default:"-"}}</p> + <div class="wrapper wrapper--large wrapper--tabs"> + <div class="wrapper wrapper--sidebar"> + <article class="wrapper--sidebar--inner simplified__wrapper"> + <h3>{% trans "Project Information" %}</h3> + <div class="card card--solid"> + <div class="grid grid--proposal-info"> + <div> + <h5>{% trans "Proposed start date" %}</h5> + <p>{{ object.proposed_start|date:"DATE_FORMAT"|default:"-" }}</p> + </div> + + <div> + <h5>{% trans "Project Proposed end date" %}</h5> + <p>{{ object.proposed_end|date:"DATE_FORMAT"|default:"-" }}</p> + </div> + + <div> + <h5>{% trans "Legal name" %}</h5> + <p>{{ object.contact_legal_name|default:"-" }}</p> + </div> + + <div> + <h5>{% trans "E-mail" %}</h5> + <p>{{ object.contact_email|default:"-" }}</p> + </div> + + <div> + <h5>{% trans "Address" %}</h5> + <p>{{ object.get_address_display|default:"-"}}</p> + </div> + + <div> + <h5>{% trans "Phone" %}</h5> + <p>{{ object.phone|default:"-" }}</p> + </div> + + <div> + <h5>{% trans "Value" %}</h5> + <p>{{ CURRENCY_SYMBOL }}{{ object.value|default:"-" }}</p> + </div> + + {% if object.sent_to_compliance_at %} + <div> + <h5>{% trans "Sent to Compliance" %}</h5> + <p>{{ object.sent_to_compliance_at|date:"DATE_FORMAT" }}</p> + </div> + {% endif %} + + </div> + + {% if object.output_answers %} + <div class="simplified__rich-text"> + {{ object.output_answers }} + </div> + {% endif %} </div> - <div> - <h5>{% trans "Phone" %}</h5> - <p>{{ object.phone|default:"-" }}</p> + <h3>{% trans "Approvals" %}</h3> + <div class="card card--solid"> + <h4>{% trans "Approver" %}</h4> + {% with approval=project.approvals.first %} + <p>{{ approval.by }} - {{ approval.created_at|date:"DATE_FORMAT" }}</p> + {% endwith %} </div> - <div> - <h5>{% trans "Value" %}</h5> - <p>{{ CURRENCY_SYMBOL }}{{ object.value|default:"-" }}</p> + <h3>{% trans "Review" %}</h3> + <div class="card card--solid"> + <h4>{% trans "Submission lead" %}</h4> + <p>{{ project.submission.lead }}</p> + + <h4>{% trans "Reviews" %}</h4> + <h5>{% trans "Staff Reviewers" %}</h5> + {% for review in project.submission.reviews.by_staff %} + <div class="card__reviewer-outcome"> + <span class="card__reviewer"> + {{ review.author }} + {% if review.author.role %} + as {{ review.author.role }} + {% endif %} + - {{ review.created_at|date:"DATE_FORMAT" }} + </span> + </div> + {% empty %} + {% trans "No reviews" %} + {% endfor %} + <h5>{% trans "External Reviewers" %}</h5> + {% for review in project.submission.reviews.by_reviewers %} + <div class="card__reviewer-outcome"> + <span class="card__reviewer"> + {{ review.author }} - {{ review.created_at|date:"DATE_FORMAT" }} + </span> + </div> + {% empty %} + {% trans "No reviews" %} + {% endfor %} </div> - {% if object.sent_to_compliance_at %} - <div> - <h5>{% trans "Sent to Compliance" %}</h5> - <p>{{ object.sent_to_compliance_at|date:"DATE_FORMAT" }}</p> + <h3>{% trans "Supporting Documents" %}</h3> + <div class="card card--solid"> + <p><a href="{% url 'apply:submissions:simplified' pk=object.submission_id %}">{% trans "Submission" %}</a></p> + {% for packet_file in object.packet_files.all %} + <p><a href="{% url 'apply:projects:document' pk=object.pk file_pk=packet_file.pk %}">{{ packet_file.title }}</a></p> + {% endfor %} </div> + </article> + {% user_can_update_paf_status object user as user_can_take_actions %} + {% if user_can_take_actions %} + <aside class="sidebar"> + {% if mobile %} + <a class="js-actions-toggle button button--white button--full-width button--actions">{% trans "Actions to take" %}</a> {% endif %} - </div> - - {% if object.output_answers %} - <div class="simplified__rich-text"> - {{ object.output_answers }} - </div> - {% endif %} - </div> - - <h3>{% trans "Approvals" %}</h3> - <div class="card card--solid"> - <h4>{% trans "Approver" %}</h4> - {% with approval=project.approvals.first %} - <p>{{ approval.by }} - {{ approval.created_at|date:"DATE_FORMAT" }}</p> - {% endwith %} - </div> - - <h3>{% trans "Review" %}</h3> - <div class="card card--solid"> - <h4>{% trans "Submission lead" %}</h4> - <p>{{ project.submission.lead }}</p> - - <h4>{% trans "Reviews" %}</h4> - <h5>{% trans "Staff Reviewers" %}</h5> - {% for review in project.submission.reviews.by_staff %} - <div class="card__reviewer-outcome"> - <span class="card__reviewer"> - {{ review.author }} - {% if review.author.role %} - as {{ review.author.role }} + <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}"> + <h5>{% trans "Actions to take" %}</h5> + {% if request.user.is_contracting or request.user.is_finance %} + {% user_can_final_approve_project object user as user_can_approve %} + <a class="button button--bottom-space button--primary button--full-width {% if user_can_approve %} is-disabled {% endif %}" href="{% url 'apply:projects:edit' pk=object.pk %}">{% trans "Edit PAF" %}</a> + <a data-fancybox data-src="#change-status" class="button button--primary button--full-width {% if user_can_approve %} is-disabled {% endif %}" href="#">{% trans "Update PAF Status" %}</a> + <div class="modal" id="change-status"> + <h4 class="modal__header-bar">{% trans "Update PAF Status" %}</h4> + <p>{% trans "Project's current status" %}: {{ object.get_status_display }}</p> + {% trans "Update Status" as update %} + {% include 'funds/includes/delegated_form_base.html' with form=change_paf_status value=update %} + </div> {% endif %} - - {{ review.created_at|date:"DATE_FORMAT" }} - </span> - </div> - {% empty %} - {% trans "No reviews" %} - {% endfor %} - <h5>{% trans "External Reviewers" %}</h5> - {% for review in project.submission.reviews.by_reviewers %} - <div class="card__reviewer-outcome"> - <span class="card__reviewer"> - {{ review.author }} - {{ review.created_at|date:"DATE_FORMAT" }} - </span> </div> - {% empty %} - {% trans "No reviews" %} - {% endfor %} - </div> - <h3>{% trans "Supporting Documents" %}</h3> - <div class="card card--solid"> - <p><a href="{% url 'apply:submissions:simplified' pk=object.submission_id %}">{% trans "Submission" %}</a></p> - {% for packet_file in object.packet_files.all %} - <p><a href="{% url 'apply:projects:document' pk=object.pk file_pk=packet_file.pk %}">{{ packet_file.title }}</a></p> - {% endfor %} + </aside> + {% endif %} </div> </div> </div> {% endblock content %} + +{% block extra_js %} + {{ block.super }} + <script src="{% static 'js/apply/jquery.fancybox.min.js' %}"></script> + <script src="{% static 'js/apply/fancybox-global.js' %}"></script> +{% endblock %} diff --git a/hypha/apply/projects/templatetags/approval_tools.py b/hypha/apply/projects/templatetags/approval_tools.py index 84dc2891c73fffd61463b7374cb5fe1f0e2faaa5..988d70a77d2fa70bb9bedb62fab790b2acca576d 100644 --- a/hypha/apply/projects/templatetags/approval_tools.py +++ b/hypha/apply/projects/templatetags/approval_tools.py @@ -15,7 +15,26 @@ def can_send_for_approval(project, user): @register.simple_tag def user_can_approve_project(project, user): - return user.is_approver and not user_has_approved(project, user) + if not user_has_approved(project, user): + if user.is_finance or user.is_contracting or user.is_approver: + return True + return False + + +@register.simple_tag +def user_can_update_paf_status(project, user): + if not project.user == user: + if project.can_update_paf_status: + if user.is_finance or user.is_contracting or user.is_approver: + return True + return False + + +@register.simple_tag +def user_can_final_approve_project(project, user): + if user.is_approver and user.is_contracting and project.can_make_final_approval: + return True + return False @register.simple_tag diff --git a/hypha/apply/projects/templatetags/contract_tools.py b/hypha/apply/projects/templatetags/contract_tools.py index efb2701a3808cf03d98c609bbafb07907fcafab5..de991f0d9e18764185b31a0c234db84b14fff819 100644 --- a/hypha/apply/projects/templatetags/contract_tools.py +++ b/hypha/apply/projects/templatetags/contract_tools.py @@ -1,6 +1,6 @@ from django import template -from ..models.project import COMMITTED +from ..models.project import COMMITTED, WAITING_FOR_APPROVAL register = template.Library() @@ -8,7 +8,7 @@ register = template.Library() @register.simple_tag def user_can_upload_contract(project, user): if user.is_apply_staff: - return project.status != COMMITTED + return project.status not in [COMMITTED, WAITING_FOR_APPROVAL] # Does the Project have any unapproved contracts? latest_contract = project.contracts.order_by('-created_at').first() diff --git a/hypha/apply/projects/tests/factories.py b/hypha/apply/projects/tests/factories.py index 50c7bee415d657efd57f2c4f30941352a4be8d17..d781c7cdb50451af001343918e77bffec735048f 100644 --- a/hypha/apply/projects/tests/factories.py +++ b/hypha/apply/projects/tests/factories.py @@ -19,6 +19,7 @@ from ..models.project import ( Deliverable, DocumentCategory, PacketFile, + PAFReviewersRole, Project, ProjectApprovalForm, ) @@ -103,6 +104,13 @@ class ProjectFactory(factory.django.DjangoModelFactory): ) +class PAFReviewerRoleFactory(factory.django.DjangoModelFactory): + role = factory.Faker('name') + + class Meta: + model = PAFReviewersRole + + class ContractFactory(factory.django.DjangoModelFactory): approver = factory.SubFactory(StaffFactory) project = factory.SubFactory(ProjectFactory) diff --git a/hypha/apply/projects/tests/test_forms.py b/hypha/apply/projects/tests/test_forms.py index 769a3d6619da6349ee70d3531e1fe057f9f6c151..20d2c53d1f83fcbb841fd025cd9f83c2eacb4b05 100644 --- a/hypha/apply/projects/tests/test_forms.py +++ b/hypha/apply/projects/tests/test_forms.py @@ -5,6 +5,7 @@ from unittest import mock from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings +from hypha.apply.home.factories import ApplySiteFactory from hypha.apply.users.tests.factories import ( Finance2Factory, FinanceFactory, @@ -21,6 +22,8 @@ from ..forms.payment import ( filter_request_choices, ) from ..forms.project import ( + ChangePAFStatusForm, + FinalApprovalForm, ProjectApprovalForm, StaffUploadContractForm, UploadContractForm, @@ -37,9 +40,11 @@ from ..models.payment import ( SUBMITTED, invoice_status_user_choices, ) +from ..models.project import APPROVE, ProjectSettings from .factories import ( DocumentCategoryFactory, InvoiceFactory, + PAFReviewerRoleFactory, ProjectFactory, SupportingDocumentFactory, address_to_form_data, @@ -262,6 +267,56 @@ class TestChangeInvoiceStatusFormForm(TestCase): self.assertTrue(form.is_valid(), form.errors.as_text()) +class TestChangePAFStatusForm(TestCase): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + apply_site = ApplySiteFactory() + cls.project_setting, _ = ProjectSettings.objects.get_or_create(site_id=apply_site.id) + cls.project_setting.use_settings = True + cls.project_setting.save() + cls.role = PAFReviewerRoleFactory(page=cls.project_setting) + + def test_paf_status_is_required(self): + project = ProjectFactory(in_approval=True) + user = StaffFactory() + form = ChangePAFStatusForm(data={'role': self.role}, instance=project, user=user) + self.assertFalse(form.is_valid()) + self.assertIn('paf_status', form.errors.keys()) + + def test_role_is_required(self): + project = ProjectFactory(in_approval=True) + user = StaffFactory() + form = ChangePAFStatusForm(data={'paf_status': APPROVE}, instance=project, user=user) + self.assertFalse(form.is_valid()) + self.assertIn('role', form.errors.keys()) + + def test_comment_is_not_required(self): + project = ProjectFactory(in_approval=True) + user = StaffFactory() + form = ChangePAFStatusForm(data={'role': self.role, 'paf_status': APPROVE}, instance=project, user=user) + self.assertTrue(form.is_valid()) + self.assertEqual(form.errors, {}) + + +class TestFinalApprovalForm(TestCase): + def test_final_approval_status_is_required(self): + project = ProjectFactory(in_approval=True) + user = StaffFactory() + form = FinalApprovalForm(data={'comment': ''}, instance=project, user=user) + self.assertFalse(form.is_valid()) + self.assertNotEqual(form.errors, {}) + self.assertIn('final_approval_status', form.errors.keys()) + + def test_comment_is_not_required(self): + project = ProjectFactory(in_approval=True) + user = StaffFactory() + form = FinalApprovalForm(data={'final_approval_status': APPROVE}, instance=project, user=user) + self.assertTrue(form.is_valid()) + self.assertEqual(form.errors, {}) + + class TestProjectApprovalForm(TestCase): def test_updating_fields_sets_changed_flag(self): project = ProjectFactory() diff --git a/hypha/apply/projects/tests/test_views.py b/hypha/apply/projects/tests/test_views.py index d8c1c6e1d934b9b134b4fd693b9b3b1fed9ca9e5..163fd6d4a7a891451289d6bce985c2b497ea197d 100644 --- a/hypha/apply/projects/tests/test_views.py +++ b/hypha/apply/projects/tests/test_views.py @@ -9,9 +9,12 @@ from django.urls import reverse from django.utils import timezone from hypha.apply.funds.tests.factories import LabSubmissionFactory +from hypha.apply.home.factories import ApplySiteFactory from hypha.apply.users.tests.factories import ( ApplicantFactory, ApproverFactory, + ContractingApproverFactory, + ContractingFactory, FinanceFactory, ReviewerFactory, StaffFactory, @@ -23,13 +26,22 @@ from hypha.apply.utils.testing.tests import BaseViewTestCase from ..files import get_files from ..forms import SetPendingForm from ..models.payment import CHANGES_REQUESTED_BY_STAFF, SUBMITTED -from ..models.project import COMMITTED, CONTRACTING, IN_PROGRESS +from ..models.project import ( + APPROVE, + COMMITTED, + CONTRACTING, + IN_PROGRESS, + REQUEST_CHANGE, + WAITING_FOR_APPROVAL, + ProjectSettings, +) from ..views.project import ContractsMixin, ProjectDetailSimplifiedView from .factories import ( ContractFactory, DocumentCategoryFactory, InvoiceFactory, PacketFileFactory, + PAFReviewerRoleFactory, ProjectFactory, ReportFactory, ReportVersionFactory, @@ -66,40 +78,181 @@ class TestUpdateLeadView(BaseViewTestCase): self.assertEqual(project.lead, new_lead) -class TestCreateApprovalView(BaseViewTestCase): +class TestSendForApprovalView(BaseViewTestCase): base_view_name = 'detail' url_name = 'funds:projects:{}' - user_factory = ApproverFactory + user_factory = StaffFactory def get_kwargs(self, instance): return {'pk': instance.id} - def test_creating_an_approval_happy_path(self): - project = ProjectFactory(in_approval=True) - self.assertEqual(project.approvals.count(), 0) + def test_send_for_approval_fails_when_project_is_locked(self): + project = ProjectFactory(is_locked=True) - response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': self.user.id}) + # 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-request_approval_form': ''}) self.assertEqual(response.status_code, 200) project.refresh_from_db() - self.assertEqual(project.approvals.count(), 1) + self.assertFalse(project.is_locked) - self.assertEqual(project.status, 'contracting') + self.assertEqual(project.status, WAITING_FOR_APPROVAL) + + +class TestChangePAFStatusView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + apply_site = ApplySiteFactory() + cls.project_setting, _ = ProjectSettings.objects.get_or_create(site_id=apply_site.id) + cls.project_setting.use_settings = True + cls.project_setting.save() + cls.role = PAFReviewerRoleFactory(page=cls.project_setting) + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_applicant_cant_update_paf_status(self): + user = ApplicantFactory() + self.client.force_login(user=user) + project = ProjectFactory(in_approval=True) + + response = self.post_page(project, {'form-submitted-change_paf_status': '', 'paf_status': APPROVE, + 'role': self.role.id}) + self.assertEqual(response.status_code, 403) - approval = project.approvals.first() - self.assertEqual(approval.project_id, project.pk) + def test_staff_can_update_paf_status(self): + user = StaffFactory() + self.client.force_login(user=user) + project = ProjectFactory(in_approval=True) + + response = self.post_page(project, {'form-submitted-change_paf_status': '', 'paf_status': APPROVE, + 'role': self.role.id}) + self.assertEqual(response.status_code, 200) - def test_creating_an_approval_other_approver(self): + def test_finance_can_update_paf_status(self): + user = FinanceFactory() + self.client.force_login(user=user) project = ProjectFactory(in_approval=True) - self.assertEqual(project.approvals.count(), 0) - other = self.user_factory() - response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': other.id}) + response = self.post_page(project, {'form-submitted-change_paf_status': '', 'paf_status': APPROVE, + 'role': self.role.id}) + self.assertEqual(response.status_code, 200) + + def test_contracting_can_update_paf_status(self): + user = ContractingFactory() + self.client.force_login(user=user) + project = ProjectFactory(in_approval=True) + + response = self.post_page(project, {'form-submitted-change_paf_status': '', 'paf_status': APPROVE, + 'role': self.role.id}) + self.assertEqual(response.status_code, 200) + + def test_reviewer_approve_paf(self): + # reviewer can be staff, finance or contracting + project = ProjectFactory(in_approval=True) + + response = self.post_page(project, {'form-submitted-change_paf_status': '', 'role': self.role.id, + 'paf_status': APPROVE}) + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertIn(self.role.role, project.paf_reviews_meta_data.keys()) + self.assertIn('approve', project.paf_reviews_meta_data[self.role.role]['status']) + + def test_reviewer_rejects_paf(self): + # reviewer can be staff, finance or contracting + project = ProjectFactory(in_approval=True) + + response = self.post_page(project, {'form-submitted-change_paf_status': '', 'paf_status': REQUEST_CHANGE, + 'role': self.role.id}) + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.status, COMMITTED) + self.assertIn(self.role.role, project.paf_reviews_meta_data.keys()) + self.assertEqual('request_change', project.paf_reviews_meta_data[self.role.role]['status']) + + +class TestFinalApprovalView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = ContractingApproverFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_final_approver_cant_be_staff(self): + user = StaffFactory() + self.client.force_login(user) + project = ProjectFactory(in_approval=True) + response = self.post_page(project, {'form-submitted-final_approval_form': '', 'final_approval_status': APPROVE}) + self.assertEqual(response.status_code, 403) + + def test_final_approver_cant_be_finance(self): + user = FinanceFactory() + self.client.force_login(user) + project = ProjectFactory(in_approval=True) + response = self.post_page(project, {'form-submitted-final_approval_form': '', 'final_approval_status': APPROVE}) + self.assertEqual(response.status_code, 403) + + def test_final_approver_cant_be_contracting(self): + user = ContractingFactory() + self.client.force_login(user) + project = ProjectFactory(in_approval=True) + response = self.post_page(project, {'form-submitted-final_approval_form': '', 'final_approval_status': APPROVE}) + self.assertEqual(response.status_code, 403) + + def test_final_approver_cant_be_approver(self): + user = ApproverFactory() + self.client.force_login(user) + project = ProjectFactory(in_approval=True) + response = self.post_page(project, {'form-submitted-final_approval_form': '', 'final_approval_status': APPROVE}) + self.assertEqual(response.status_code, 403) + + def test_final_approver_cant_be_applicant(self): + user = ApplicantFactory() + self.client.force_login(user) + project = ProjectFactory(in_approval=True) + response = self.post_page(project, {'form-submitted-final_approval_form': '', 'final_approval_status': APPROVE}) + self.assertEqual(response.status_code, 403) + + def test_final_approval(self): + project = ProjectFactory(in_approval=True) + + response = self.post_page(project, {'form-submitted-final_approval_form': '', 'final_approval_status': APPROVE}) self.assertEqual(response.status_code, 200) project.refresh_from_db() - self.assertEqual(project.approvals.count(), 0) self.assertTrue(project.is_locked) + self.assertEqual(project.status, CONTRACTING) + + def test_final_rejection(self): + project = ProjectFactory(in_approval=True) + + response = self.post_page(project, {'form-submitted-final_approval_form': '', 'final_approval_status': REQUEST_CHANGE}) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + self.assertFalse(project.is_locked) + self.assertEqual(project.status, COMMITTED) class BaseProjectDetailTestCase(BaseViewTestCase): @@ -170,62 +323,6 @@ class TestReviewerUserProjectDetailView(BaseProjectDetailTestCase): self.assertEqual(response.status_code, 403) -class TestStaffProjectRejectView(BaseProjectDetailTestCase): - user_factory = StaffFactory - - def test_cant_reject(self): - project = ProjectFactory(in_approval=True) - response = self.post_page(project, { - 'form-submitted-rejection_form': '', - 'comment': 'needs to change', - }) - self.assertEqual(response.status_code, 403) - project = self.refresh(project) - self.assertEqual(project.status, COMMITTED) - self.assertTrue(project.is_locked) - - -class TestApproverProjectRejectView(BaseProjectDetailTestCase): - user_factory = ApproverFactory - - def test_can_reject(self): - project = ProjectFactory(in_approval=True) - response = self.post_page(project, { - 'form-submitted-rejection_form': '', - 'comment': 'needs to change', - }) - self.assertEqual(response.status_code, 200) - project = self.refresh(project) - self.assertEqual(project.status, COMMITTED) - self.assertFalse(project.is_locked) - - def test_cant_reject_no_comment(self): - project = ProjectFactory(in_approval=True) - response = self.post_page(project, { - 'form-submitted-rejection_form': '', - 'comment': '', - }) - self.assertEqual(response.status_code, 200) - project = self.refresh(project) - self.assertEqual(project.status, COMMITTED) - self.assertTrue(project.is_locked) - - -class TestUserProjectRejectView(BaseProjectDetailTestCase): - user_factory = ApplicantFactory - - def test_cant_reject(self): - project = ProjectFactory(in_approval=True, user=self.user) - response = self.post_page(project, { - 'form-submitted-rejection_form': '', - 'comment': 'needs to change', - }) - self.assertEqual(response.status_code, 200) - project = self.refresh(project) - self.assertEqual(project.status, COMMITTED) - self.assertTrue(project.is_locked) - - class TestRemoveDocumentView(BaseViewTestCase): base_view_name = 'detail' url_name = 'funds:projects:{}' @@ -255,42 +352,6 @@ class TestRemoveDocumentView(BaseViewTestCase): 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-request_approval_form': ''}) - self.assertEqual(response.status_code, 200) - - project.refresh_from_db() - - self.assertTrue(project.is_locked) - self.assertEqual(project.status, 'committed') - - class TestApplicantUploadContractView(BaseViewTestCase): base_view_name = 'detail' url_name = 'funds:projects:{}' diff --git a/hypha/apply/projects/views/__init__.py b/hypha/apply/projects/views/__init__.py index ca2ae12b2fbe72364883168db7bc5b8c591b61b1..787d33c4611c2e6bec479d4436a5a04170cc144a 100644 --- a/hypha/apply/projects/views/__init__.py +++ b/hypha/apply/projects/views/__init__.py @@ -13,7 +13,7 @@ from .project import ( ApproveContractView, BaseProjectDetailView, ContractPrivateMediaView, - CreateApprovalView, + FinalApprovalView, ProjectApprovalEditView, ProjectDetailPDFView, ProjectDetailSimplifiedView, @@ -21,7 +21,6 @@ from .project import ( ProjectListView, ProjectOverviewView, ProjectPrivateMediaView, - RejectionView, RemoveDocumentView, SelectDocumentView, SendForApprovalView, @@ -42,8 +41,7 @@ from .vendor import CreateVendorView, VendorDetailView, VendorPrivateMediaView __all__ = [ 'ChangeInvoiceStatusView', 'SendForApprovalView', - 'CreateApprovalView', - 'RejectionView', + 'FinalApprovalView', 'UploadDocumentView', 'RemoveDocumentView', 'SelectDocumentView', diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py index b436eb319248176f131024e64ea9a093b193ed32..0f4f800fe1d5887772d6cc59a7946ee6acc3440a 100644 --- a/hypha/apply/projects/views/project.py +++ b/hypha/apply/projects/views/project.py @@ -27,10 +27,12 @@ from django_filters.views import FilterView from django_tables2 import SingleTableMixin from hypha.apply.activity.messaging import MESSAGES, messenger +from hypha.apply.activity.models import ACTION, ALL, COMMENT, Activity from hypha.apply.activity.views import ActivityContextMixin, CommentFormView from hypha.apply.stream_forms.models import BaseStreamForm from hypha.apply.users.decorators import ( - approver_required, + contracting_approver_required, + staff_or_finance_or_contracting_required, staff_or_finance_required, staff_required, ) @@ -47,9 +49,9 @@ from ..files import get_files from ..filters import InvoiceListFilter, ProjectListFilter, ReportListFilter from ..forms import ( ApproveContractForm, - CreateApprovalForm, + ChangePAFStatusForm, + FinalApprovalForm, ProjectApprovalForm, - RejectionForm, RemoveDocumentForm, SelectDocumentForm, SetPendingForm, @@ -60,10 +62,12 @@ from ..forms import ( ) from ..models.payment import Invoice from ..models.project import ( + COMMITTED, CONTRACTING, IN_PROGRESS, PROJECT_STATUS_CHOICES, - Approval, + REQUEST_CHANGE, + WAITING_FOR_APPROVAL, Contract, PacketFile, Project, @@ -79,53 +83,35 @@ class SendForApprovalView(DelegatedViewMixin, UpdateView): form_class = SetPendingForm model = Project - def form_valid(self, form): - # lock project - response = super().form_valid(form) - + def send_to_compliance(self): + """Notify Compliance about this Project.""" messenger( - MESSAGES.SEND_FOR_APPROVAL, + MESSAGES.SENT_TO_COMPLIANCE, request=self.request, user=self.request.user, source=self.object, ) - return response - + self.object.sent_to_compliance_at = timezone.now() + self.object.save(update_fields=['sent_to_compliance_at']) -@method_decorator(staff_required, name='dispatch') -class CreateApprovalView(DelegatedViewMixin, CreateView): - context_name = 'add_approval_form' - form_class = CreateApprovalForm - model = Approval - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs.pop('instance') - kwargs.get('initial', {}).update({'by': kwargs.get('user')}) - return kwargs - - @transaction.atomic() def form_valid(self, form): project = self.kwargs['object'] old_stage = project.get_status_display() - form.instance.project = project - response = super().form_valid(form) messenger( - MESSAGES.APPROVE_PROJECT, + MESSAGES.SEND_FOR_APPROVAL, request=self.request, user=self.request.user, - source=project, + source=self.object, ) - # project.send_to_compliance(self.request) + project.status = WAITING_FOR_APPROVAL + project.save(update_fields=['status']) - project.is_locked = False - project.status = CONTRACTING - project.save(update_fields=['is_locked', 'status']) + self.send_to_compliance() messenger( MESSAGES.PROJECT_TRANSITION, @@ -138,25 +124,86 @@ class CreateApprovalView(DelegatedViewMixin, CreateView): return response -@method_decorator(approver_required, name='dispatch') -class RejectionView(DelegatedViewMixin, UpdateView): - context_name = 'rejection_form' - form_class = RejectionForm +@method_decorator(contracting_approver_required, name='dispatch') +class FinalApprovalView(DelegatedViewMixin, UpdateView): + form_class = FinalApprovalForm + context_name = 'final_approval_form' model = Project def form_valid(self, form): + project = self.object + old_stage = project.get_status_display() + + response = super().form_valid(form) + + comment = form.cleaned_data.get('comment', '') + status = form.cleaned_data['final_approval_status'] + + if status == REQUEST_CHANGE: + project.status = COMMITTED + project.is_locked = False + project.paf_reviews_meta_data = {} + project.save(update_fields=['status', 'is_locked', 'paf_reviews_meta_data']) + + project_status_message = _( + '<p>{user} request changes the Project and update status to {project_status}.</p>').format( + user=self.request.user, + project_status=project.status + ) + + Activity.objects.create( + user=self.request.user, + type=ACTION, + source=project, + timestamp=timezone.now(), + message=project_status_message, + visibility=ALL, + ) + + messenger( + MESSAGES.REQUEST_PROJECT_CHANGE, + request=self.request, + user=self.request.user, + source=self.object, + comment=comment, + ) + return response + messenger( - MESSAGES.REQUEST_PROJECT_CHANGE, + MESSAGES.APPROVE_PROJECT, request=self.request, user=self.request.user, - source=self.object, - comment=form.cleaned_data['comment'], + source=project, ) - self.object.is_locked = False - self.object.save(update_fields=['is_locked']) + project.is_locked = True + project.status = CONTRACTING + project.save(update_fields=['is_locked', 'status']) - return redirect(self.object) + project_status_message = _( + '<p>{user} approved the Project and update status to {project_status}.</p>').format( + user=self.request.user, + project_status=project.status + ) + + Activity.objects.create( + user=self.request.user, + type=ACTION, + source=project, + timestamp=timezone.now(), + message=project_status_message, + visibility=ALL, + ) + + messenger( + MESSAGES.PROJECT_TRANSITION, + request=self.request, + user=self.request.user, + source=project, + related=old_stage, + ) + + return response # PROJECT DOCUMENTS @@ -412,6 +459,63 @@ class UploadContractView(DelegatedViewMixin, CreateView): # PROJECT VIEW + +@method_decorator(staff_or_finance_or_contracting_required, name='dispatch') +class ChangePAFStatusView(DelegatedViewMixin, UpdateView): + form_class = ChangePAFStatusForm + context_name = 'change_paf_status' + model = Project + + def form_valid(self, form): + response = super().form_valid(form) + role = form.cleaned_data.get('role') + paf_status = form.cleaned_data.get('paf_status') + comment = form.cleaned_data.get('comment', '') + + self.object.paf_reviews_meta_data.update({str(role.role): {'status': paf_status, 'comment': comment}}) + self.object.save(update_fields=['paf_reviews_meta_data']) + + paf_status_update_message = _('<p>{role} has updated PAF status to {paf_status}.</p>').format( + role=role, paf_status=paf_status) + Activity.objects.create( + user=self.request.user, + type=ACTION, + source=self.object, + timestamp=timezone.now(), + message=paf_status_update_message, + visibility=ALL, + ) + + if paf_status == REQUEST_CHANGE: + self.object.status = COMMITTED + self.object.save(update_fields=['status']) + + messenger( + MESSAGES.REQUEST_PROJECT_CHANGE, + request=self.request, + user=self.request.user, + source=self.object, + comment=comment, + ) + + if form.cleaned_data['comment']: + + comment = f"<p>{form.cleaned_data['comment']}.</p>" + + message = paf_status_update_message + comment + + Activity.objects.create( + user=self.request.user, + type=COMMENT, + source=self.object, + timestamp=timezone.now(), + message=message, + visibility=ALL, + ) + + return response + + class BaseProjectDetailView(ReportingMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -429,8 +533,7 @@ class AdminProjectDetailView( form_views = [ ApproveContractView, CommentFormView, - CreateApprovalView, - RejectionView, + FinalApprovalView, RemoveDocumentView, SelectDocumentView, SendForApprovalView, @@ -438,6 +541,7 @@ class AdminProjectDetailView( UpdateLeadView, UploadContractView, UploadDocumentView, + ChangePAFStatusView, ] model = Project template_name_suffix = '_admin_detail' @@ -481,6 +585,7 @@ class ApplicantProjectDetailView( class ProjectDetailView(ViewDispatcher): admin_view = AdminProjectDetailView finance_view = AdminProjectDetailView + contracting_view = AdminProjectDetailView applicant_view = ApplicantProjectDetailView @@ -536,13 +641,16 @@ class ContractPrivateMediaView(UserPassesTestMixin, PrivateMediaView): # PROJECT EDIT -@method_decorator(staff_or_finance_required, name='dispatch') -class ProjectDetailSimplifiedView(DetailView): +@method_decorator(staff_or_finance_or_contracting_required, name='dispatch') +class ProjectDetailSimplifiedView(DelegateableView, DetailView): + form_views = [ + ChangePAFStatusView + ] model = Project template_name_suffix = '_simplified_detail' -@method_decorator(staff_required, name='dispatch') +@method_decorator(staff_or_finance_or_contracting_required, name='dispatch') class ProjectDetailPDFView(SingleObjectMixin, View): model = Project @@ -590,7 +698,7 @@ class ProjectDetailPDFView(SingleObjectMixin, View): ) -@method_decorator(staff_required, name='dispatch') +@method_decorator(staff_or_finance_or_contracting_required, name='dispatch') class ProjectApprovalEditView(BaseStreamForm, UpdateView): submission_form_class = ProjectApprovalForm model = Project @@ -598,7 +706,6 @@ class ProjectApprovalEditView(BaseStreamForm, UpdateView): def buttons(self): yield ('submit', 'primary', _('Submit')) - # yield ('save', 'white', _('Save draft')) def dispatch(self, request, *args, **kwargs): project = self.get_object() diff --git a/hypha/apply/users/decorators.py b/hypha/apply/users/decorators.py index 1017901b085e34a73fc337926fd04eaa8538633d..3bb0fe4ee3e6787e8f8a03b314ef01bca574c878 100644 --- a/hypha/apply/users/decorators.py +++ b/hypha/apply/users/decorators.py @@ -31,12 +31,24 @@ def is_apply_staff_or_finance(user): return True +def is_apply_staff_or_finance_or_contracting(user): + if not (user.is_apply_staff or user.is_finance or user.is_contracting): + raise PermissionDenied + return True + + def is_approver(user): if not user.is_approver: raise PermissionDenied return True +def is_contracting_approver(user): + if not user.is_approver or not user.is_contracting: + raise PermissionDenied + return True + + staff_required = [login_required, user_passes_test(is_apply_staff)] finance_required = [login_required, user_passes_test(is_finance)] @@ -45,6 +57,10 @@ staff_or_finance_required = [login_required, user_passes_test(is_apply_staff_or_ approver_required = [login_required, user_passes_test(is_approver)] +staff_or_finance_or_contracting_required = [login_required, user_passes_test(is_apply_staff_or_finance_or_contracting)] + +contracting_approver_required = [login_required, user_passes_test(is_contracting_approver)] + def superuser_decorator(fn): check = user_passes_test(lambda user: user.is_superuser) diff --git a/hypha/apply/users/tests/factories.py b/hypha/apply/users/tests/factories.py index 08f1907ab15036626c4bdd9c4138384cbe2e5e25..284d91896e74b7b8cbe24a79ad924829eed0b170 100644 --- a/hypha/apply/users/tests/factories.py +++ b/hypha/apply/users/tests/factories.py @@ -9,6 +9,7 @@ from ..groups import ( APPLICANT_GROUP_NAME, APPROVER_GROUP_NAME, COMMUNITY_REVIEWER_GROUP_NAME, + CONTRACTING_GROUP_NAME, FINANCE_GROUP_NAME, PARTNER_GROUP_NAME, REVIEWER_GROUP_NAME, @@ -130,6 +131,25 @@ class Finance2Factory(FinanceFactory): ) +class ContractingFactory(UserFactory): + @factory.post_generation + def groups(self, create, extracted, **kwargs): + if create: + self.groups.add( + GroupFactory(name=CONTRACTING_GROUP_NAME), + ) + + +class ContractingApproverFactory(UserFactory): + @factory.post_generation + def groups(self, create, extracted, **kwargs): + if create: + self.groups.add( + GroupFactory(name=CONTRACTING_GROUP_NAME), + GroupFactory(name=APPROVER_GROUP_NAME) + ) + + class SuperUserFactory(StaffFactory): is_superuser = True diff --git a/hypha/apply/utils/views.py b/hypha/apply/utils/views.py index 816d8dc982d8161e7bbaf785786c1cdebd5e29aa..351d1b560fd274f19e42771d9ea12e054f2fd3d3 100644 --- a/hypha/apply/utils/views.py +++ b/hypha/apply/utils/views.py @@ -30,6 +30,7 @@ class ViewDispatcher(View): community_view: View = None applicant_view: View = None finance_view: View = None + contracting_view: View = None def admin_check(self, request): return request.user.is_apply_staff @@ -46,6 +47,9 @@ class ViewDispatcher(View): def finance_check(self, request): return request.user.is_finance + def contracting_check(self, request): + return request.user.is_contracting + def dispatch(self, request, *args, **kwargs): view = self.applicant_view @@ -59,6 +63,8 @@ class ViewDispatcher(View): view = self.community_view elif self.finance_check(request): view = self.finance_view + elif self.contracting_check(request): + view = self.contracting_view if view: return view.as_view()(request, *args, **kwargs)