From 4ffef0ef57625126d5ab78991c525ace05f5542d Mon Sep 17 00:00:00 2001 From: Sandeep Chauhan <sandeepsajan0@gmail.com> Date: Tue, 30 May 2023 14:38:47 +0530 Subject: [PATCH] Updated PAF approval flow (#3400) Fixes #3387 --- .../activity/adapters/django_messages.py | 1 - hypha/apply/activity/adapters/emails.py | 31 +++- hypha/apply/activity/adapters/utils.py | 21 +++ .../migrations/0074_alter_event_type.py | 18 +++ hypha/apply/activity/options.py | 1 + .../messages/email/assign_paf_approvers.html | 14 ++ .../dashboard/contracting_dashboard.html | 7 + .../templates/dashboard/dashboard.html | 7 + .../dashboard/finance_dashboard.html | 7 + .../dashboard/reviewer_dashboard.html | 7 + hypha/apply/dashboard/views.py | 116 ++++++++++++- hypha/apply/projects/forms/__init__.py | 2 + hypha/apply/projects/forms/project.py | 76 +++++++-- .../0075_alter_pafapprovals_user.py | 21 +++ hypha/apply/projects/models/project.py | 2 +- hypha/apply/projects/permissions.py | 112 ++++++++++++- hypha/apply/projects/tables.py | 16 ++ .../includes/supporting_documents.html | 60 ++++++- .../application_projects/project_detail.html | 4 +- .../projects/templatetags/approval_tools.py | 18 +-- .../projects/templatetags/project_tags.py | 31 +++- hypha/apply/projects/tests/test_views.py | 8 + hypha/apply/projects/views/project.py | 152 +++++++++++++++--- tailwind.config.js | 1 + 24 files changed, 662 insertions(+), 71 deletions(-) create mode 100644 hypha/apply/activity/migrations/0074_alter_event_type.py create mode 100644 hypha/apply/activity/templates/messages/email/assign_paf_approvers.html create mode 100644 hypha/apply/projects/migrations/0075_alter_pafapprovals_user.py diff --git a/hypha/apply/activity/adapters/django_messages.py b/hypha/apply/activity/adapters/django_messages.py index c8f4a74d7..2499ed6d3 100644 --- a/hypha/apply/activity/adapters/django_messages.py +++ b/hypha/apply/activity/adapters/django_messages.py @@ -14,7 +14,6 @@ class DjangoMessagesAdapter(AdapterBase): MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated', MESSAGES.BATCH_TRANSITION: 'batch_transition', MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determinations', - MESSAGES.UPLOAD_DOCUMENT: _('Successfully uploaded document'), MESSAGES.REMOVE_DOCUMENT: _('Successfully removed document'), MESSAGES.SKIPPED_REPORT: 'handle_skipped_report', MESSAGES.REPORT_FREQUENCY_CHANGED: 'handle_report_frequency', diff --git a/hypha/apply/activity/adapters/emails.py b/hypha/apply/activity/adapters/emails.py index d297c06d0..0fd4aad46 100644 --- a/hypha/apply/activity/adapters/emails.py +++ b/hypha/apply/activity/adapters/emails.py @@ -24,6 +24,7 @@ from ..tasks import send_mail from .base import AdapterBase from .utils import ( get_compliance_email, + get_users_for_groups, is_ready_for_review, is_reviewer_update, is_transition, @@ -57,6 +58,7 @@ class EmailAdapter(AdapterBase): MESSAGES.SENT_TO_COMPLIANCE: 'messages/email/sent_to_compliance.html', MESSAGES.SEND_FOR_APPROVAL: 'messages/email/paf_for_approval.html', MESSAGES.REQUEST_PROJECT_CHANGE: 'messages/email/project_request_change.html', + MESSAGES.ASSIGN_PAF_APPROVER: 'messages/email/assign_paf_approvers.html', MESSAGES.APPROVE_PAF: 'messages/email/paf_for_approval.html', MESSAGES.UPDATE_INVOICE: 'handle_invoice_updated', MESSAGES.UPDATE_INVOICE_STATUS: 'handle_invoice_status_updated', @@ -84,7 +86,7 @@ class EmailAdapter(AdapterBase): subject = _( 'Reminder: Application ready to review: {source.title}' ).format(source=source) - elif message_type in [MESSAGES.SENT_TO_COMPLIANCE]: + elif message_type in [MESSAGES.SENT_TO_COMPLIANCE, MESSAGES.APPROVE_PAF, MESSAGES.SEND_FOR_APPROVAL]: subject = _('Project is waiting for approval: {source.title}').format(source=source) elif message_type == MESSAGES.UPLOAD_CONTRACT: subject = _('Contract uploaded for the project: {source.title}').format(source=source) @@ -100,6 +102,8 @@ class EmailAdapter(AdapterBase): subject = _('Project status has changed to {source.status}: {source.title}').format(source=source) elif message_type == MESSAGES.REQUEST_PROJECT_CHANGE: subject = _("Project has been rejected, please update and resubmit") + elif message_type == MESSAGES.ASSIGN_PAF_APPROVER: + subject = _("Project documents are ready to be assigned for approval: {source.title}".format(source=source)) else: try: subject = source.page.specific.subject or _( @@ -264,9 +268,30 @@ class EmailAdapter(AdapterBase): project_settings = ProjectSettings.for_request(request) if project_settings.paf_approval_sequential: next_paf_approval = source.paf_approvals.filter(approved=False).first() - if next_paf_approval: + if next_paf_approval and next_paf_approval.user: return [next_paf_approval.user.email] - return source.paf_approvals.filter(approved=False).values_list('user__email', flat=True) + return list(filter(lambda approver: approver is not None, source.paf_approvals.filter(approved=False).values_list('user__email', flat=True))) + + if message_type == MESSAGES.ASSIGN_PAF_APPROVER: + from hypha.apply.projects.models.project import ProjectSettings + # notify PAFReviewerRole's groups' users to assign approvers + request = kwargs.get('request') + project_settings = ProjectSettings.for_request(request) + if project_settings.paf_approval_sequential: + next_paf_approval = source.paf_approvals.filter(approved=False).first() + if next_paf_approval and not next_paf_approval.user: + assigners = get_users_for_groups(list(next_paf_approval.paf_reviewer_role.user_roles.all()), exact_match=True) + return [assigner.email for assigner in assigners] + + assigners_emails = [] + if user == source.lead: + for approval in source.paf_approvals.filter(approved=False, user__isnull=True): + assigners_emails.extend([assigner.email for assigner in get_users_for_groups(list(approval.paf_reviewer_role.user_roles.all()), exact_match=True)]) + else: + assigners_emails.extend([assigner.email for assigner in + get_users_for_groups(list(user.groups.all()), + exact_match=True)]) + return set(assigners_emails) if message_type == MESSAGES.REQUEST_PROJECT_CHANGE: return [source.lead.email] diff --git a/hypha/apply/activity/adapters/utils.py b/hypha/apply/activity/adapters/utils.py index 70c544024..786bd538a 100644 --- a/hypha/apply/activity/adapters/utils.py +++ b/hypha/apply/activity/adapters/utils.py @@ -1,5 +1,6 @@ from collections import defaultdict +from django.db.models import Count from django.utils.translation import gettext as _ from hypha.apply.activity.options import MESSAGES @@ -81,3 +82,23 @@ def get_compliance_email(target_user_gps=None): staff_users_email.append(user.email) target_user_emails.extend(staff_users_email) return target_user_emails + + +def get_users_for_groups(groups, user_queryset=None, exact_match=False): + """ + It will return the user queryset with the mentioned groups, + + **NOTE: exact_match and user_queryset are not working together(for now). Set either one of them. + """ + if groups: + if not user_queryset: + if exact_match: + user_queryset = User.objects.annotate(group_count=Count('groups')).filter(group_count=len(groups), groups__name=groups.pop().name) + else: + user_queryset = User.objects.filter(groups__name=groups.pop().name) + else: + user_queryset = user_queryset.filter(groups__name=groups.pop().name) + return get_users_for_groups(groups, user_queryset=user_queryset) + else: + return user_queryset + diff --git a/hypha/apply/activity/migrations/0074_alter_event_type.py b/hypha/apply/activity/migrations/0074_alter_event_type.py new file mode 100644 index 000000000..ecca83337 --- /dev/null +++ b/hypha/apply/activity/migrations/0074_alter_event_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-05-10 12:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0073_add_approve_invoice'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'updated lead'), ('BATCH_UPDATE_LEAD', 'batch updated lead'), ('EDIT_SUBMISSION', 'edited submission'), ('APPLICANT_EDIT', 'edited applicant'), ('NEW_SUBMISSION', 'submitted new submission'), ('DRAFT_SUBMISSION', 'submitted new draft submission'), ('SCREENING', 'screened'), ('TRANSITION', 'transitioned'), ('BATCH_TRANSITION', 'batch transitioned'), ('DETERMINATION_OUTCOME', 'sent determination outcome'), ('BATCH_DETERMINATION_OUTCOME', 'sent batch determination outcome'), ('INVITED_TO_PROPOSAL', 'invited to proposal'), ('REVIEWERS_UPDATED', 'updated reviewers'), ('BATCH_REVIEWERS_UPDATED', 'batch updated reviewers'), ('PARTNERS_UPDATED', 'updated partners'), ('PARTNERS_UPDATED_PARTNER', 'partners updated partner'), ('READY_FOR_REVIEW', 'marked ready for review'), ('BATCH_READY_FOR_REVIEW', 'marked batch ready for review'), ('NEW_REVIEW', 'added new review'), ('COMMENT', 'added comment'), ('PROPOSAL_SUBMITTED', 'submitted proposal'), ('OPENED_SEALED', 'opened sealed submission'), ('REVIEW_OPINION', 'reviewed opinion'), ('DELETE_SUBMISSION', 'deleted submission'), ('DELETE_REVIEW', 'deleted review'), ('CREATED_PROJECT', 'created project'), ('UPDATED_VENDOR', 'updated contracting information'), ('UPDATE_PROJECT_LEAD', 'updated project lead'), ('EDIT_REVIEW', 'edited review'), ('SEND_FOR_APPROVAL', 'sent for approval'), ('APPROVE_PROJECT', 'approved project'), ('ASSIGN_PAF_APPROVER', 'assign paf approver'), ('APPROVE_PAF', 'approved paf'), ('PROJECT_TRANSITION', 'transitioned project'), ('REQUEST_PROJECT_CHANGE', 'requested project change'), ('SUBMIT_CONTRACT_DOCUMENTS', 'submitted contract documents'), ('UPLOAD_DOCUMENT', 'uploaded document to project'), ('REMOVE_DOCUMENT', 'removed document from project'), ('UPLOAD_CONTRACT', 'uploaded contract to project'), ('APPROVE_CONTRACT', 'approved contract'), ('CREATE_INVOICE', 'created invoice for project'), ('UPDATE_INVOICE_STATUS', 'updated invoice status'), ('APPROVE_INVOICE', 'approve invoice'), ('DELETE_INVOICE', 'deleted invoice'), ('SENT_TO_COMPLIANCE', 'sent project to compliance'), ('UPDATE_INVOICE', 'updated invoice'), ('SUBMIT_REPORT', 'submitted report'), ('SKIPPED_REPORT', 'skipped report'), ('REPORT_FREQUENCY_CHANGED', 'changed report frequency'), ('DISABLED_REPORTING', 'disabled reporting'), ('REPORT_NOTIFY', 'notified report'), ('CREATE_REMINDER', 'created reminder'), ('DELETE_REMINDER', 'deleted reminder'), ('REVIEW_REMINDER', 'reminder to review'), ('BATCH_DELETE_SUBMISSION', 'batch deleted submissions'), ('BATCH_ARCHIVE_SUBMISSION', 'batch archive submissions'), ('STAFF_ACCOUNT_CREATED', 'created new account'), ('STAFF_ACCOUNT_EDITED', 'edited account'), ('ARCHIVE_SUBMISSION', 'archived submission'), ('UNARCHIVE_SUBMISSION', 'unarchived submission')], max_length=50, verbose_name='verb'), + ), + ] diff --git a/hypha/apply/activity/options.py b/hypha/apply/activity/options.py index a43ae5f85..7ba8bba05 100644 --- a/hypha/apply/activity/options.py +++ b/hypha/apply/activity/options.py @@ -35,6 +35,7 @@ class MESSAGES(TextChoices): EDIT_REVIEW = 'EDIT_REVIEW', _('edited review') SEND_FOR_APPROVAL = 'SEND_FOR_APPROVAL', _('sent for approval') APPROVE_PROJECT = 'APPROVE_PROJECT', _('approved project') + ASSIGN_PAF_APPROVER = 'ASSIGN_PAF_APPROVER', _('assign paf approver') APPROVE_PAF = 'APPROVE_PAF', _('approved paf') PROJECT_TRANSITION = 'PROJECT_TRANSITION', _('transitioned project') REQUEST_PROJECT_CHANGE = 'REQUEST_PROJECT_CHANGE', _('requested project change') diff --git a/hypha/apply/activity/templates/messages/email/assign_paf_approvers.html b/hypha/apply/activity/templates/messages/email/assign_paf_approvers.html new file mode 100644 index 000000000..e87757f02 --- /dev/null +++ b/hypha/apply/activity/templates/messages/email/assign_paf_approvers.html @@ -0,0 +1,14 @@ +{% extends "messages/email/base.html" %} + +{% load i18n %} +{% block salutation %}{% endblock %} + +{% block content %} +{% trans "Project documents are ready to be assigned for approval." %} + +{% trans "Title" %}: {{ source.title }} +{% trans "Link" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:approval' pk=source.pk %} +{% trans "Original Submission" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:submissions:simplified' pk=source.submission.pk %} + +{% blocktrans with lead=source.lead email=source.lead.email %}Please contact {{ lead }} - {{ email }} if you have any questions.{% endblocktrans %} +{% endblock %} diff --git a/hypha/apply/dashboard/templates/dashboard/contracting_dashboard.html b/hypha/apply/dashboard/templates/dashboard/contracting_dashboard.html index 6c693b18e..d78251451 100644 --- a/hypha/apply/dashboard/templates/dashboard/contracting_dashboard.html +++ b/hypha/apply/dashboard/templates/dashboard/contracting_dashboard.html @@ -22,6 +22,13 @@ {% include "dashboard/includes/paf_waiting_for_approval.html" with paf_waiting_for_approval=paf_waiting_for_approval %} {% endif %} + {% if paf_waiting_for_assignment.count %} + <div id="paf_waiting_for_assignment" class="wrapper wrapper--bottom-space"> + <h4 class="heading heading--normal">{% trans "PAF waiting for assignee" %}</h4> + {% render_table paf_waiting_for_assignment.table %} + </div> + {% endif %} + {% if projects_in_contracting.count %} {% include "dashboard/includes/projects_in_contracting.html" with projects_in_contracting=projects_in_contracting %} {% endif %} diff --git a/hypha/apply/dashboard/templates/dashboard/dashboard.html b/hypha/apply/dashboard/templates/dashboard/dashboard.html index 1cda540e0..beb08b907 100644 --- a/hypha/apply/dashboard/templates/dashboard/dashboard.html +++ b/hypha/apply/dashboard/templates/dashboard/dashboard.html @@ -76,6 +76,13 @@ {% include "dashboard/includes/paf_waiting_for_approval.html" with paf_waiting_for_approval=paf_waiting_for_approval %} {% endif %} + {% if paf_waiting_for_assignment.count %} + <div id="paf_waiting_for_assignment" class="wrapper wrapper--bottom-space"> + <h4 class="heading heading--normal">{% trans "PAF waiting for assignee" %}</h4> + {% render_table paf_waiting_for_assignment.table %} + </div> + {% endif %} + {% if projects.table.data %} <div id="active-projects" class="wrapper wrapper--bottom-space"> {% trans "Your projects" as project_heading %} diff --git a/hypha/apply/dashboard/templates/dashboard/finance_dashboard.html b/hypha/apply/dashboard/templates/dashboard/finance_dashboard.html index e417cd9e6..a95b0f681 100644 --- a/hypha/apply/dashboard/templates/dashboard/finance_dashboard.html +++ b/hypha/apply/dashboard/templates/dashboard/finance_dashboard.html @@ -83,6 +83,13 @@ {% if paf_waiting_for_approval.count %} {% include "dashboard/includes/paf_waiting_for_approval.html" with paf_waiting_for_approval=paf_waiting_for_approval %} {% endif %} + + {% if paf_waiting_for_assignment.count %} + <div id="paf_waiting_for_assignment" class="wrapper wrapper--bottom-space"> + <h4 class="heading heading--normal">{% trans "PAF waiting for assignee" %}</h4> + {% render_table paf_waiting_for_assignment.table %} + </div> + {% endif %} </div> {% endblock %} diff --git a/hypha/apply/dashboard/templates/dashboard/reviewer_dashboard.html b/hypha/apply/dashboard/templates/dashboard/reviewer_dashboard.html index f327f26f0..83011ccd6 100644 --- a/hypha/apply/dashboard/templates/dashboard/reviewer_dashboard.html +++ b/hypha/apply/dashboard/templates/dashboard/reviewer_dashboard.html @@ -70,6 +70,13 @@ </div> {% endif %} + {% if paf_waiting_for_assignment.count %} + <div id="paf_waiting_for_assignment" class="wrapper wrapper--bottom-space"> + <h4 class="heading heading--normal">{% trans "PAF waiting for assignee" %}</h4> + {% render_table paf_waiting_for_assignment.table %} + </div> + {% endif %} + {% if my_inactive_submissions.data %} <div class="wrapper wrapper--bottom-space"> <h4 class="heading heading--normal">{% trans "Submission history" %}</h4> diff --git a/hypha/apply/dashboard/views.py b/hypha/apply/dashboard/views.py index 4232657af..258bcb7d8 100644 --- a/hypha/apply/dashboard/views.py +++ b/hypha/apply/dashboard/views.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.db.models import Count from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import reverse, reverse_lazy @@ -22,7 +23,11 @@ from hypha.apply.projects.filters import ProjectListFilter from hypha.apply.projects.models import Invoice, PAFApprovals, Project, ProjectSettings from hypha.apply.projects.models.project import WAITING_FOR_APPROVAL from hypha.apply.projects.permissions import has_permission -from hypha.apply.projects.tables import InvoiceDashboardTable, ProjectsDashboardTable +from hypha.apply.projects.tables import ( + InvoiceDashboardTable, + ProjectsAssigneeDashboardTable, + ProjectsDashboardTable, +) from hypha.apply.utils.views import ViewDispatcher @@ -75,6 +80,7 @@ class AdminDashboardView(MyFlaggedMixin, TemplateView): 'paf_waiting_for_approval': self.paf_waiting_for_approval(), 'rounds': self.rounds(), 'my_flagged': self.my_flagged(submissions), + 'paf_waiting_for_assignment': self.paf_waiting_for_approver_assignment(), }) return context @@ -117,6 +123,34 @@ class AdminDashboardView(MyFlaggedMixin, TemplateView): 'url': reverse('apply:projects:all'), } + def paf_waiting_for_approver_assignment(self): + project_settings = ProjectSettings.for_request(self.request) + + paf_approvals = PAFApprovals.objects.annotate( + roles_count=Count('paf_reviewer_role__user_roles') + ).filter(roles_count=len(list(self.request.user.groups.all())), approved=False, user__isnull=True) + + for role in self.request.user.groups.all(): + paf_approvals = paf_approvals.filter(paf_reviewer_role__user_roles__id=role.id) + + paf_approvals_ids = paf_approvals.values_list('id', flat=True) + projects = Project.objects.filter(paf_approvals__id__in=paf_approvals_ids).for_table() + + if project_settings.paf_approval_sequential: + all_projects = list(projects) + for project in all_projects: + matched_paf_approval = paf_approvals.filter(project=project).order_by( + 'paf_reviewer_role__sort_order').first() + if project.paf_approvals.filter( + paf_reviewer_role__sort_order__lt=matched_paf_approval.paf_reviewer_role.sort_order, + approved=False).exists(): + projects = projects.exclude(id=project.id) + + return { + 'count': projects.count(), + 'table': ProjectsAssigneeDashboardTable(projects), + } + def paf_waiting_for_approval(self): if not self.request.user.is_apply_staff or not PAFApprovals.objects.filter( project__status=WAITING_FOR_APPROVAL, @@ -203,7 +237,8 @@ class FinanceDashboardView(MyFlaggedMixin, TemplateView): 'active_invoices': self.active_invoices(), 'invoices_for_approval': self.invoices_for_approval(), 'invoices_to_convert': self.invoices_to_convert(), - 'paf_waiting_for_approval': self.paf_waiting_for_approval() + 'paf_waiting_for_approval': self.paf_waiting_for_approval(), + 'paf_waiting_for_assignment': self.paf_waiting_for_approver_assignment(), }) return context @@ -219,6 +254,31 @@ class FinanceDashboardView(MyFlaggedMixin, TemplateView): 'table': InvoiceDashboardTable(invoices), } + def paf_waiting_for_approver_assignment(self): + project_settings = ProjectSettings.for_request(self.request) + + paf_approvals = PAFApprovals.objects.annotate( + roles_count=Count('paf_reviewer_role__user_roles') + ).filter(roles_count=len(list(self.request.user.groups.all())), approved=False, user__isnull=True) + + for role in self.request.user.groups.all(): + paf_approvals = paf_approvals.filter(paf_reviewer_role__user_roles__id=role.id) + + paf_approvals_ids = paf_approvals.values_list('id', flat=True) + projects = Project.objects.filter(paf_approvals__id__in=paf_approvals_ids).for_table() + + if project_settings.paf_approval_sequential: + all_projects = list(projects) + for project in all_projects: + matched_paf_approval = paf_approvals.filter(project=project).order_by('paf_reviewer_role__sort_order').first() + if project.paf_approvals.filter(paf_reviewer_role__sort_order__lt=matched_paf_approval.paf_reviewer_role.sort_order, approved=False).exists(): + projects = projects.exclude(id=project.id) + + return { + 'count': projects.count(), + 'table': ProjectsAssigneeDashboardTable(projects), + } + def invoices_for_approval(self): if self.request.user.is_finance_level_2: invoices = Invoice.objects.approved_by_finance_1() @@ -327,6 +387,7 @@ class ReviewerDashboardView(MyFlaggedMixin, MySubmissionContextMixin, TemplateVi 'awaiting_reviews': self.awaiting_reviews(submissions), 'my_reviewed': self.my_reviewed(submissions), 'my_flagged': self.my_flagged(submissions), + 'paf_waiting_for_assignment': self.paf_waiting_for_approver_assignment(), }) return context @@ -343,6 +404,31 @@ class ReviewerDashboardView(MyFlaggedMixin, MySubmissionContextMixin, TemplateVi 'table': ReviewerSubmissionsTable(submissions[:limit], prefix='my-review-'), } + def paf_waiting_for_approver_assignment(self): + project_settings = ProjectSettings.for_request(self.request) + + paf_approvals = PAFApprovals.objects.annotate( + roles_count=Count('paf_reviewer_role__user_roles') + ).filter(roles_count=len(list(self.request.user.groups.all())), approved=False, user__isnull=True) + + for role in self.request.user.groups.all(): + paf_approvals = paf_approvals.filter(paf_reviewer_role__user_roles__id=role.id) + + paf_approvals_ids = paf_approvals.values_list('id', flat=True) + projects = Project.objects.filter(paf_approvals__id__in=paf_approvals_ids).for_table() + + if project_settings.paf_approval_sequential: + all_projects = list(projects) + for project in all_projects: + matched_paf_approval = paf_approvals.filter(project=project).order_by('paf_reviewer_role__sort_order').first() + if project.paf_approvals.filter(paf_reviewer_role__sort_order__lt=matched_paf_approval.paf_reviewer_role.sort_order, approved=False).exists(): + projects = projects.exclude(id=project.id) + + return { + 'count': projects.count(), + 'table': ProjectsAssigneeDashboardTable(projects), + } + def my_reviewed(self, submissions): """Staff reviewer's reviewed submissions for 'Previous reviews' block""" submissions = submissions.reviewed_by(self.request.user).order_by('-submit_time') @@ -389,6 +475,7 @@ class ContractingDashboardView(MyFlaggedMixin, TemplateView): context.update({ 'paf_waiting_for_approval': self.paf_waiting_for_approval(), 'projects_in_contracting': self.projects_in_contracting(), + 'paf_waiting_for_assignment': self.paf_waiting_for_approver_assignment(), }) return context @@ -446,6 +533,31 @@ class ContractingDashboardView(MyFlaggedMixin, TemplateView): } } + def paf_waiting_for_approver_assignment(self): + project_settings = ProjectSettings.for_request(self.request) + + paf_approvals = PAFApprovals.objects.annotate( + roles_count=Count('paf_reviewer_role__user_roles') + ).filter(roles_count=len(list(self.request.user.groups.all())), approved=False, user__isnull=True) + + for role in self.request.user.groups.all(): + paf_approvals = paf_approvals.filter(paf_reviewer_role__user_roles__id=role.id) + + paf_approvals_ids = paf_approvals.values_list('id', flat=True) + projects = Project.objects.filter(paf_approvals__id__in=paf_approvals_ids).for_table() + + if project_settings.paf_approval_sequential: + all_projects = list(projects) + for project in all_projects: + matched_paf_approval = paf_approvals.filter(project=project).order_by('paf_reviewer_role__sort_order').first() + if project.paf_approvals.filter(paf_reviewer_role__sort_order__lt=matched_paf_approval.paf_reviewer_role.sort_order, approved=False).exists(): + projects = projects.exclude(id=project.id) + + return { + 'count': projects.count(), + 'table': ProjectsAssigneeDashboardTable(projects), + } + def projects_in_contracting(self): if not self.request.user.is_contracting: return { diff --git a/hypha/apply/projects/forms/__init__.py b/hypha/apply/projects/forms/__init__.py index 08f68acaa..aab0d7c6d 100644 --- a/hypha/apply/projects/forms/__init__.py +++ b/hypha/apply/projects/forms/__init__.py @@ -7,6 +7,7 @@ from .payment import ( from .project import ( ApproveContractForm, ApproversForm, + AssignApproversForm, ChangePAFStatusForm, ChangeProjectStatusForm, CreateProjectForm, @@ -37,6 +38,7 @@ __all__ = [ 'SubmitContractDocumentsForm', 'ApproveContractForm', 'ApproversForm', + 'AssignApproversForm', 'ChangePAFStatusForm', 'ChangeProjectStatusForm', 'CreateProjectForm', diff --git a/hypha/apply/projects/forms/project.py b/hypha/apply/projects/forms/project.py index 2a3db2099..2a564d523 100644 --- a/hypha/apply/projects/forms/project.py +++ b/hypha/apply/projects/forms/project.py @@ -1,6 +1,6 @@ from django import forms from django.contrib.auth import get_user_model -from django.db.models import Q +from django.db.models import Count, Q from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django_file_form.forms import FileFormMixin @@ -33,6 +33,17 @@ def filter_request_choices(choices): return [(k, v) for k, v in PROJECT_STATUS_CHOICES if k in choices] +def get_latest_project_paf_approval_via_roles(project, roles): + # exact match the roles with paf approval's reviewer roles + paf_approvals = project.paf_approvals.annotate( + roles_count=Count('paf_reviewer_role__user_roles') + ).filter(roles_count=len(list(roles)), approved=False) + + for role in roles: + paf_approvals = paf_approvals.filter(paf_reviewer_role__user_roles__id=role.id) + return paf_approvals.first() + + class ApproveContractForm(forms.Form): id = forms.IntegerField(widget=forms.HiddenInput()) @@ -224,47 +235,39 @@ class ApproversForm(forms.ModelForm): widgets = {'id': forms.HiddenInput()} def __init__(self, user=None, *args, **kwargs): + from hypha.apply.activity.adapters.utils import get_users_for_groups super().__init__(*args, **kwargs) for paf_reviewer_role in PAFReviewersRole.objects.all(): - users = User.objects.all() - for group in paf_reviewer_role.user_roles.all(): - users = users.filter(groups__name=group) + users = get_users_for_groups(list(paf_reviewer_role.user_roles.all()), exact_match=True) approval = PAFApprovals.objects.filter(project=self.instance, paf_reviewer_role=paf_reviewer_role) if approval: initial_user = approval.first().user self.fields[slugify(paf_reviewer_role.label)] = forms.ModelChoiceField( queryset=users, + required=False, + blank=True, label=paf_reviewer_role.label, initial=initial_user if approval else None, disabled=approval.first().approved if approval.first() else False, # using approval.first() as condition for existing projects ) - def clean(self): - cleaned_data = super().clean() - - paf_reviewer_roles = PAFReviewersRole.objects.all() - if paf_reviewer_roles: - for paf_reviewer_role in paf_reviewer_roles: - if not cleaned_data[slugify(paf_reviewer_role.label)]: - self.add_error(slugify(paf_reviewer_role.label)) - return cleaned_data - def save(self, commit=True): # add users as PAFApprovals for paf_reviewer_role in PAFReviewersRole.objects.all(): + assigned_user = self.cleaned_data[slugify(paf_reviewer_role.label)] paf_approvals = PAFApprovals.objects.filter(project=self.instance, paf_reviewer_role=paf_reviewer_role) if not paf_approvals.exists(): PAFApprovals.objects.create( project=self.instance, paf_reviewer_role=paf_reviewer_role, - user=self.cleaned_data[slugify(paf_reviewer_role.label)], + user=assigned_user if assigned_user else None, approved=False, ) elif not paf_approvals.first().approved: paf_approval = paf_approvals.first() - paf_approval.user = self.cleaned_data[slugify(paf_reviewer_role.label)] + paf_approval.user = assigned_user if assigned_user else None paf_approval.save() return super().save(commit=True) @@ -274,10 +277,51 @@ class SetPendingForm(ApproversForm): if self.instance.status != DRAFT: raise forms.ValidationError(_('A Project can only be sent for Approval when Drafted.')) + # :todo: we should have a check form contains enough data to create PAF Approvals cleaned_data = super().clean() return cleaned_data +class AssignApproversForm(forms.ModelForm): + class Meta: + fields = ['id'] + model = Project + widgets = {'id': forms.HiddenInput()} + + def __init__(self, user=None, *args, **kwargs): + from hypha.apply.activity.adapters.utils import get_users_for_groups + super().__init__(*args, **kwargs) + self.user = user + + paf_approval = get_latest_project_paf_approval_via_roles(project=self.instance, roles=user.groups.all()) + + if paf_approval: + current_paf_reviewer_role = paf_approval.paf_reviewer_role + + users = get_users_for_groups(list(current_paf_reviewer_role.user_roles.all()), exact_match=True) + + self.fields[slugify(current_paf_reviewer_role.label)] = forms.ModelChoiceField( + queryset=users, + required=False, + blank=True, + label=current_paf_reviewer_role.label, + initial=paf_approval.user, + disabled=paf_approval.approved, + ) + + def save(self, commit=True): + paf_approval = get_latest_project_paf_approval_via_roles(project=self.instance, roles=self.user.groups.all()) + + current_paf_reviewer_role = paf_approval.paf_reviewer_role + assigned_user = self.cleaned_data[slugify(current_paf_reviewer_role.label)] + + if not paf_approval.approved: + paf_approval.user = assigned_user if assigned_user else None + paf_approval.save() + + return super().save(commit=True) + + class SubmitContractDocumentsForm(forms.ModelForm): class Meta: fields = ['id'] diff --git a/hypha/apply/projects/migrations/0075_alter_pafapprovals_user.py b/hypha/apply/projects/migrations/0075_alter_pafapprovals_user.py new file mode 100644 index 000000000..32369c66b --- /dev/null +++ b/hypha/apply/projects/migrations/0075_alter_pafapprovals_user.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.18 on 2023-05-04 12:21 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('application_projects', '0074_update_projects_status_committed_to_draft'), + ] + + operations = [ + migrations.AlterField( + model_name='pafapprovals', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='paf_approvals', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/hypha/apply/projects/models/project.py b/hypha/apply/projects/models/project.py index cbb7867a9..cf00f475b 100644 --- a/hypha/apply/projects/models/project.py +++ b/hypha/apply/projects/models/project.py @@ -499,7 +499,7 @@ class ProjectSettings(BaseSiteSetting, ClusterableModel): class PAFApprovals(models.Model): project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="paf_approvals") paf_reviewer_role = models.ForeignKey("PAFReviewersRole", on_delete=models.CASCADE, related_name="paf_approvals") - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="paf_approvals") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="paf_approvals") approved = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField() diff --git a/hypha/apply/projects/permissions.py b/hypha/apply/projects/permissions.py index d7548b885..5c460f016 100644 --- a/hypha/apply/projects/permissions.py +++ b/hypha/apply/projects/permissions.py @@ -1,5 +1,7 @@ from django.core.exceptions import PermissionDenied +from hypha.apply.activity.adapters.utils import get_users_for_groups + from .models.project import ( CLOSING, COMPLETE, @@ -66,6 +68,100 @@ def can_submit_contract_documents(user, project, **kwargs): return False, 'Forbidden Error' +def can_update_paf_approvers(user, project, **kwargs): + if not user.is_authenticated: + return False, 'Login Required' + + if project.status != WAITING_FOR_APPROVAL: + return False, 'PAF Approvers can be updated only in Waiting for approval state' + if user == project.lead: + return True, 'Lead can update approvers in approval state' + if not project.paf_approvals.exists(): + return False, 'No user can update approvers without paf approval, except lead(lead can add paf approvals)' + + request = kwargs.get('request') + project_settings = ProjectSettings.for_request(request) + if project_settings.paf_approval_sequential: + next_paf_approval = project.paf_approvals.filter(approved=False).first() + if next_paf_approval: + if next_paf_approval.user and user in get_users_for_groups(list(next_paf_approval.paf_reviewer_role.user_roles.all()), exact_match=True): + return True, 'PAF Reviewer-roles users can update next approval approvers if any approvers assigned' + return False, 'Forbidden Error' + else: + approvers_ids = [] + for approval in project.paf_approvals.filter(approved=False, user__isnull=False): + approvers_ids.extend(assigner.id for assigner in + get_users_for_groups(list(approval.paf_reviewer_role.user_roles.all()), + exact_match=True)) + if user.id in approvers_ids: + return True, 'PAF Reviewer-roles users can update approvers' + return False, 'Forbidden Error' + + +def can_update_assigned_paf_approvers(user, project, **kwargs): + """ + Only for Approvers teams members(with PAFReviewerRoles' user_roles' users) + UpdateAssignApproversView will be used by only approvers teams members. + """ + if not user.is_authenticated: + return False, 'Login Required' + if project.status != WAITING_FOR_APPROVAL: + return False, 'PAF approvers can be assigned only in Waiting for Approval state' + if not project.paf_approvals.exists(): + return False, 'No user can assign approvers with paf_approvals' + + request = kwargs.get('request') + project_settings = ProjectSettings.for_request(request) + if project_settings.paf_approval_sequential: + next_paf_approval = project.paf_approvals.filter(approved=False).first() + if next_paf_approval: + if user in get_users_for_groups(list(next_paf_approval.paf_reviewer_role.user_roles.all()), + exact_match=True): + return True, 'PAF Reviewer-roles users can assign approvers' + return False, 'Forbidden Error' + return False, 'Forbidden Error' + else: + assigners_ids = [] + for approval in project.paf_approvals.filter(approved=False): + assigners_ids.extend(assigner.id for assigner in + get_users_for_groups(list(approval.paf_reviewer_role.user_roles.all()), + exact_match=True)) + if user.id in assigners_ids: + return True, 'PAF Reviewer-roles users can assign approvers' + return False, 'Forbidden Error' + + +def can_assign_paf_approvers(user, project, **kwargs): + if not user.is_authenticated: + return False, 'Login Required' + + if project.status != WAITING_FOR_APPROVAL: + return False, 'PAF approvers can be assigned only in Waiting for Approval state' + if not project.paf_approvals.exists(): + return False, 'No user can assign approvers with paf_approvals' + + request = kwargs.get('request') + project_settings = ProjectSettings.for_request(request) + if project_settings.paf_approval_sequential: + next_paf_approval = project.paf_approvals.filter(approved=False).first() + if next_paf_approval: + if next_paf_approval.user: + return False, 'User already assigned' + else: + if user in get_users_for_groups(list(next_paf_approval.paf_reviewer_role.user_roles.all()), exact_match=True): + return True, 'PAF Reviewer-roles users can assign approvers' + return False, 'Forbidden Error' + return False, 'Forbidden Error' + else: + assigners_ids = [] + for approval in project.paf_approvals.filter(approved=False, user__isnull=True): + assigners_ids.extend(assigner.id for assigner in get_users_for_groups(list(approval.paf_reviewer_role.user_roles.all()), exact_match=True)) + + if user.id in assigners_ids: + return True, 'PAF Reviewer-roles users can assign approvers' + return False, 'Forbidden Error' + + def can_update_paf_status(user, project, **kwargs): if not user.is_authenticated: return False, 'Login Required' @@ -80,7 +176,8 @@ def can_update_paf_status(user, project, **kwargs): if request: project_settings = ProjectSettings.for_request(request) if project_settings.paf_approval_sequential: - if user.id == project.paf_approvals.filter(approved=False).first().user.id: + approver = project.paf_approvals.filter(approved=False).first().user + if approver and user.id == approver.id: return True, 'Next Approver can approve PAF(For Sequential Approvals)' return False, 'Only Next can approve PAF(For Sequential Approvals)' if user.id in project.paf_approvals.filter(approved=False).values_list('user', flat=True): @@ -170,9 +267,13 @@ def can_access_project(user, project): if user.is_applicant and user == project.user: return True, 'Applicant(project user) can view project in all statuses' - if project.status in [DRAFT, WAITING_FOR_APPROVAL] and project.paf_approvals.exists() and \ - user.id in project.paf_approvals.all().values_list('user', flat=True): - return True, 'PAF Approvers can access the project in Draft and Approval state' + if project.status in [DRAFT, WAITING_FOR_APPROVAL, CONTRACTING] and project.paf_approvals.exists(): + paf_reviewer_roles_users_ids = [] + for approval in project.paf_approvals.all(): + paf_reviewer_roles_users_ids.extend([role_user.id for role_user in get_users_for_groups( + list(approval.paf_reviewer_role.user_roles.all()), exact_match=True)]) + if user.id in paf_reviewer_roles_users_ids: + return True, 'PAF Approvers can access the project in Draft, Approval state and after approval state' return False, 'Forbidden Error' @@ -181,6 +282,9 @@ permissions_map = { 'contract_approve': can_approve_contract, 'contract_upload': can_upload_contract, 'paf_status_update': can_update_paf_status, + 'paf_approvers_update': can_update_paf_approvers, + 'paf_approvers_assign': can_assign_paf_approvers, + 'update_paf_assigned_approvers': can_update_assigned_paf_approvers, # Permission for UpdateAssignApproversView 'project_status_update': can_update_project_status, 'project_reports_update': can_update_project_reports, 'report_update': can_update_report, diff --git a/hypha/apply/projects/tables.py b/hypha/apply/projects/tables.py index 823e5ef2f..ac4f24b44 100644 --- a/hypha/apply/projects/tables.py +++ b/hypha/apply/projects/tables.py @@ -106,6 +106,22 @@ class ProjectsDashboardTable(BaseProjectsTable): attrs = {'class': 'projects-table'} +class ProjectsAssigneeDashboardTable(BaseProjectsTable): + class Meta: + fields = [ + 'title', + 'fund', + 'lead', + 'reporting', + 'last_payment_request', + 'end_date', + ] + model = Project + orderable = False + exclude = ['status'] + attrs = {'class': 'projects-table'} + + class ProjectsListTable(BaseProjectsTable): class Meta: fields = [ diff --git a/hypha/apply/projects/templates/application_projects/includes/supporting_documents.html b/hypha/apply/projects/templates/application_projects/includes/supporting_documents.html index e696b7bd9..8baa8996e 100644 --- a/hypha/apply/projects/templates/application_projects/includes/supporting_documents.html +++ b/hypha/apply/projects/templates/application_projects/includes/supporting_documents.html @@ -10,6 +10,7 @@ <svg class="icon icon--caret-down" x-show="collapsed" aria-hidden=true><use xlink:href="#caret-down"></use></svg> {% endif %} </div> + <div> {% user_can_send_for_approval object user as can_send_to_approve %} {% if can_send_to_approve %} <a data-fancybox @@ -23,24 +24,43 @@ {% endif %} </a> {% endif %} - {% user_can_update_paf_approvers object user as can_update_paf_approvers %} + {% user_can_update_paf_approvers object user request as can_update_paf_approvers %} + {% user_can_assign_approvers_to_project object user request as can_assign_paf_approvers %} {% if can_update_paf_approvers %} + {% if user == project.lead %} <a data-fancybox data-src="#update-paf-approvers" - class="button button--project-action button--project-action--white" + class="button button--project-action button--project-action--white ml-2" href="#"> {% trans "View/Update Approvers" %} </a> + {% else %} + <a data-fancybox + data-src="#change-assigned-paf-approvers" + class="button button--project-action button--project-action--white ml-2" + href="#"> + {% trans "Change approver" %} + </a> + {% endif %} + {% endif %} + {% if can_assign_paf_approvers %} + <a data-fancybox + data-src="#assign-paf-approvers" + class="button button--project-action ml-2" + href="#"> + {% trans "Assign approver" %} + </a> {% endif %} {% user_can_update_paf_status object user request=request as can_update_paf_status %} {% if object.can_make_approval and can_update_paf_status %} <a data-fancybox data-src="#update-paf-status" - class="button button--project-action" + class="button button--project-action ml-2" href="#"> {% trans "Update Status" %} </a> {% endif %} + </div> </div> <ul class="docs-block__inner" id="project-documents-elements" {% if collapsible_header %} x-show="!collapsed" role="region" aria-labelledby="project-documents-section" {% endif %}> @@ -227,7 +247,8 @@ <h4 class="modal__project-header-bar">{% trans "Submit for Approval" %}</h4> {% if remaining_document_categories %} - <h5>{% trans "Are you sure you're ready to submit?" %}</h5> + <h5>{% trans "Are you sure you're ready to submit the project documents to be approved in" %} + {% if project_settings.paf_approval_sequential %}{% trans "sequential order?" %}{% else %}{% trans "parallel order?" %}{% endif %}</h5> <p>{% trans "This project is missing the following documents" %}:</p> @@ -238,12 +259,19 @@ </ul> {% trans "Submit anyway" as submit %} {% else %} + <h5>{% trans "Are you ready to submit the project documents to be approved in" %} + {% if project_settings.paf_approval_sequential %}{% trans "sequential order?" %}{% else %}{% trans "parallel order?" %}{% endif %}</h5> {% trans "Submit" as submit %} {% endif %} {% if project_settings.paf_reviewers_roles.all %} - <p> <strong> {% trans "Please select approvers for PAF in " %} {% if project_settings.paf_approval_sequential %} {% trans "sequential order" %} {% else %}{% trans "parallel order" %}{% endif %}</strong></p> - <p>{% trans "(please note that in "%}{% if project_settings.paf_approval_sequential %}{%trans "sequential order, approvers will approve PAF one after the other)"%}{% else %}{% trans "parallel order, approvers can approve PAF anytime)" %}{% endif %}</p> - <br> + + <div class="flex items-center text-sm"> + <p class="flex-shrink text-slate-500 pr-2 mb-0">Optional</p> + <p class="flex-grow h-px bg-mid-grey mb-0"></p> + </div> + + <p>{% trans "Please note that in "%}{% if project_settings.paf_approval_sequential %}{%trans "sequential order, approvers will approve PAF one after the other"%}{% else %}{% trans "parallel order, approvers can approve PAF anytime" %}{% endif %}</p> + {% include 'funds/includes/delegated_form_base.html' with form=request_approval_form value=submit %} {% else %} <p>{% trans "No PAF Reviewer Roles created yet, please create these in " %} @@ -268,12 +296,28 @@ </p> {% endif %} </div> + +<div class="modal" id="change-assigned-paf-approvers"> + <h4 class="modal__project-header-bar">{% trans "Change Approver" %}</h4> + <p class="text-mid-grey">{% trans "Selected approver will be notified. On unselecting, every listed member here will be notified." %} </p> + {% trans "Submit" as submit %} + {% include 'funds/includes/delegated_form_base.html' with form=assign_approvers_form value=submit %} + </div> + +{% endif %} + +{% if can_assign_paf_approvers %} + <div class="modal" id="assign-paf-approvers"> + <h4 class="modal__project-header-bar">{% trans "Assign Approver" %}</h4> + <p class="text-mid-grey">{% trans "Selected approver will be notified. On unselecting, every listed member here will be notified." %} </p> + {% trans "Submit" as submit %} + {% include 'funds/includes/delegated_form_base.html' with form=assign_approvers_form value=submit %} + </div> {% endif %} {% if can_update_paf_status %} <div class="modal" id="update-paf-status"> <h4 class="modal__project-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> diff --git a/hypha/apply/projects/templates/application_projects/project_detail.html b/hypha/apply/projects/templates/application_projects/project_detail.html index cf1134f9b..8565b9d40 100644 --- a/hypha/apply/projects/templates/application_projects/project_detail.html +++ b/hypha/apply/projects/templates/application_projects/project_detail.html @@ -153,7 +153,7 @@ {% block sidebar %} <aside class="sidebar"> - {% user_next_step_on_project object user as next_step %} + {% user_next_step_on_project object user request=request as next_step %} {% if next_step %} {% if mobile %} <a class="js-actions-toggle button button--white button--full-width button--actions">{% trans "Next Step" %}</a> @@ -187,7 +187,7 @@ {% if paf_approval.approved %} <p class="sidebar__paf-approvals--approved m-0">{% trans "Approved by " %}{{ paf_approval.user }} {% if paf_approval.approved_at %}(<i>{{ paf_approval.approved_at|date }}</i>){% endif %}</p> {% else %} - <p class="sidebar__paf-approvals--pending m-0">{% trans "Pending approval from " %}{{ paf_approval.user }}</p> + <p class="sidebar__paf-approvals--pending m-0">{% trans "Pending approval from " %}{% if paf_approval.user %}{{ paf_approval.user }}{% else %} {{ paf_approval.paf_reviewer_role.label }}{% trans " (nobody assigned yet)" %} {% endif %}</p> {% endif %} <br> {% endfor %} diff --git a/hypha/apply/projects/templatetags/approval_tools.py b/hypha/apply/projects/templatetags/approval_tools.py index db10faca1..3374fd36d 100644 --- a/hypha/apply/projects/templatetags/approval_tools.py +++ b/hypha/apply/projects/templatetags/approval_tools.py @@ -1,6 +1,5 @@ from django import template -from ..models.project import WAITING_FOR_APPROVAL from ..permissions import has_permission register = template.Library() @@ -22,14 +21,15 @@ def user_can_send_for_approval(project, user): @register.simple_tag -def user_can_update_paf_approvers(project, user): - if not project.status == WAITING_FOR_APPROVAL: - return False - if user.paf_approvals.filter(project=project).exists(): - return False - if project.paf_approvals.exists() and user.is_apply_staff: - return True - return False +def user_can_update_paf_approvers(project, user, request): + permission, _ = has_permission('paf_approvers_update', user, object=project, raise_exception=False, request=request) + return permission + + +@register.simple_tag +def user_can_assign_approvers_to_project(project, user, request): + permission, _ = has_permission('paf_approvers_assign', user, object=project, raise_exception=False, request=request) + return permission @register.simple_tag diff --git a/hypha/apply/projects/templatetags/project_tags.py b/hypha/apply/projects/templatetags/project_tags.py index 7b20c9ac9..b145c3512 100644 --- a/hypha/apply/projects/templatetags/project_tags.py +++ b/hypha/apply/projects/templatetags/project_tags.py @@ -1,4 +1,5 @@ from django import template +from django.db.models import Count from django.urls import reverse from hypha.apply.projects.models.project import ( @@ -22,7 +23,8 @@ def project_can_have_report(project): @register.simple_tag -def user_next_step_on_project(project, user): +def user_next_step_on_project(project, user, request=None): + from hypha.apply.projects.models.project import PAFReviewersRole, ProjectSettings if project.status == DRAFT: if user.is_apply_staff: if not project.user_has_updated_details: @@ -34,10 +36,33 @@ def user_next_step_on_project(project, user): return "Changes requested. Awaiting documents to be resubmitted." return "Awaiting approval form to be created." elif project.status == WAITING_FOR_APPROVAL: - if user.id in project.paf_approvals.values_list('user', flat=True): - return "Awaiting project approval from assigned approvers. Please review and update status" if user.is_applicant: return "Awaiting approval form to be approved." + + if request: + project_settings = ProjectSettings.for_request(request=request) + if project_settings.paf_approval_sequential: + latest_unapproved_approval = project.paf_approvals.filter(approved=False).first() + if latest_unapproved_approval: + if latest_unapproved_approval.user: + return f"Awaiting approval. Assigned to {latest_unapproved_approval.user}" + return f"Awaiting {latest_unapproved_approval.paf_reviewer_role.label} to assign an approver" + else: + matched_roles = PAFReviewersRole.objects.annotate(roles_count=Count('user_roles')).filter( + roles_count=len(user.groups.all())) + for group in user.groups.all(): + matched_roles = matched_roles.filter(user_roles__id=group.id) + if not matched_roles: + return "Awaiting PAF approval form to be approved" + else: + matched_unapproved_approval = project.paf_approvals.filter(approved=False, paf_reviewer_role__in=matched_roles) + if not matched_unapproved_approval.exists(): + return "Awaiting approval from other approvers teams" + else: + if matched_unapproved_approval.first().user: + return f"Awaiting approval. Assigned to {matched_unapproved_approval.first().user}" + return f"Awaiting {matched_unapproved_approval.first().paf_reviewer_role.label} to assign an approver" + return "Awaiting project approval from assigned approvers" elif project.status == CONTRACTING: if not project.contracts.exists(): diff --git a/hypha/apply/projects/tests/test_views.py b/hypha/apply/projects/tests/test_views.py index d654542ff..3520e21a9 100644 --- a/hypha/apply/projects/tests/test_views.py +++ b/hypha/apply/projects/tests/test_views.py @@ -85,6 +85,14 @@ class TestSendForApprovalView(BaseViewTestCase): url_name = 'funds:projects:{}' user_factory = StaffFactory + def setUp(self): + super().setUp() + apply_site = ApplySiteFactory() + self.project_setting, _ = ProjectSettings.objects.get_or_create(site_id=apply_site.id) + self.project_setting.use_settings = True + self.project_setting.save() + self.role = PAFReviewerRoleFactory(page=self.project_setting) + def get_kwargs(self, instance): return {'pk': instance.id} diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py index 9399e1120..fba849a5f 100644 --- a/hypha/apply/projects/views/project.py +++ b/hypha/apply/projects/views/project.py @@ -52,6 +52,7 @@ from ..filters import InvoiceListFilter, ProjectListFilter, ReportListFilter from ..forms import ( ApproveContractForm, ApproversForm, + AssignApproversForm, ChangePAFStatusForm, ChangeProjectStatusForm, ProjectApprovalForm, @@ -104,12 +105,43 @@ class SendForApprovalView(DelegatedViewMixin, UpdateView): response = super().form_valid(form) - messenger( - MESSAGES.SEND_FOR_APPROVAL, - request=self.request, - user=self.request.user, - source=self.object, - ) + project_settings = ProjectSettings.for_request(self.request) + + paf_approvals = self.object.paf_approvals.filter(approved=False) + + if project_settings.paf_approval_sequential: + if paf_approvals: + user = paf_approvals.first().user + if user: + # notify only if first user/approver is updated + messenger( + MESSAGES.SEND_FOR_APPROVAL, + request=self.request, + user=self.request.user, + source=self.object, + ) + else: + messenger( + MESSAGES.ASSIGN_PAF_APPROVER, + request=self.request, + user=self.request.user, + source=self.object, + ) + else: + if paf_approvals.filter(user__isnull=False).exists(): + messenger( + MESSAGES.SEND_FOR_APPROVAL, + request=self.request, + user=self.request.user, + source=self.object, + ) + if paf_approvals.filter(user__isnull=True).exists(): + messenger( + MESSAGES.ASSIGN_PAF_APPROVER, + request=self.request, + user=self.request.user, + source=self.object, + ) project.status = WAITING_FOR_APPROVAL project.save(update_fields=['status']) @@ -509,12 +541,20 @@ class ChangePAFStatusView(DelegatedViewMixin, UpdateView): if project_settings.paf_approval_sequential: # notify next approver if self.object.paf_approvals.filter(approved=False).exists(): - messenger( - MESSAGES.APPROVE_PAF, - request=self.request, - user=self.request.user, - source=self.object, - ) + if self.object.paf_approvals.filter(approved=False).first().user: + messenger( + MESSAGES.APPROVE_PAF, + request=self.request, + user=self.request.user, + source=self.object, + ) + else: + messenger( + MESSAGES.ASSIGN_PAF_APPROVER, + request=self.request, + user=self.request.user, + source=self.object, + ) messages.success(self.request, _("PAF has been approved"), extra_tags=PROJECT_ACTION_MESSAGE_TAG) @@ -591,15 +631,59 @@ class ChangeProjectstatusView(DelegatedViewMixin, UpdateView): return response +@method_decorator(login_required, name='dispatch') +class UpdateAssignApproversView(DelegatedViewMixin, UpdateView): + context_name = 'assign_approvers_form' + form_class = AssignApproversForm + model = Project + + def dispatch(self, request, *args, **kwargs): + self.project = get_object_or_404(Project, pk=self.kwargs['pk']) + permission, _ = has_permission('update_paf_assigned_approvers', request.user, self.project, + raise_exception=True, request=request) + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + from ..forms.project import get_latest_project_paf_approval_via_roles + project = self.kwargs['object'] + + response = super().form_valid(form) + + paf_approval = get_latest_project_paf_approval_via_roles(project=project, roles=self.request.user.groups.all()) + + if paf_approval.user: + messenger( + MESSAGES.APPROVE_PAF, + request=self.request, + user=self.request.user, + source=self.object, + ) + else: + messenger( + MESSAGES.ASSIGN_PAF_APPROVER, + request=self.request, + user=self.request.user, + source=self.object, + ) + + return response + + class UpdatePAFApproversView(DelegatedViewMixin, UpdateView): context_name = 'update_approvers_form' form_class = ApproversForm model = Project + def dispatch(self, request, *args, **kwargs): + self.project = get_object_or_404(Project, pk=self.kwargs['pk']) + permission, _ = has_permission('paf_approvers_update', request.user, self.project, raise_exception=True, request=request) + return super().dispatch(request, *args, **kwargs) + def form_valid(self, form): project = self.kwargs['object'] project_settings = ProjectSettings.for_request(self.request) + old_approvers = None if self.object.paf_approvals.exists(): old_approvers = list(project.paf_approvals.filter(approved=False).values_list('user__id', flat=True)) @@ -611,28 +695,51 @@ class UpdatePAFApproversView(DelegatedViewMixin, UpdateView): # if approvers exists already if project_settings.paf_approval_sequential: user = paf_approvals.first().user - if user.id != old_approvers[0]: + if user and user.id != old_approvers[0]: # notify only if first user/approver is updated messenger( MESSAGES.APPROVE_PAF, request=self.request, - user=self.object.user, + user=self.request.user, + source=self.object, + ) + elif not user: + messenger( + MESSAGES.ASSIGN_PAF_APPROVER, + request=self.request, + user=self.request.user, source=self.object, ) else: + if paf_approvals.filter(user__isnull=False).exists(): + messenger( + MESSAGES.APPROVE_PAF, + request=self.request, + user=self.request.user, + source=self.object, + ) + if paf_approvals.filter(user__isnull=True).exists(): + messenger( + MESSAGES.ASSIGN_PAF_APPROVER, + request=self.request, + user=self.request.user, + source=self.object, + ) + elif paf_approvals: + if paf_approvals.filter(user__isnull=False).exists(): messenger( MESSAGES.APPROVE_PAF, request=self.request, - user=self.object.user, + user=self.request.user, + source=self.object, + ) + if paf_approvals.filter(user__isnull=True).exists(): + messenger( + MESSAGES.ASSIGN_PAF_APPROVER, + request=self.request, + user=self.request.user, source=self.object, ) - elif paf_approvals: - messenger( - MESSAGES.APPROVE_PAF, - request=self.request, - user=self.object.user, - source=self.object, - ) messages.success(self.request, _("PAF approvers have been updated"), extra_tags=PROJECT_ACTION_MESSAGE_TAG) return response @@ -665,6 +772,7 @@ class AdminProjectDetailView( UpdateLeadView, UploadContractView, UploadDocumentView, + UpdateAssignApproversView, ChangePAFStatusView, ChangeProjectstatusView, ChangeInvoiceStatusView, diff --git a/tailwind.config.js b/tailwind.config.js index c022d8117..fd02a9d18 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -6,6 +6,7 @@ module.exports = { colors: { 'light-blue' : '#0d7db0', 'tomato': '#f05e54', + 'mid-grey': '#cfcfcf', }, }, }, -- GitLab