diff --git a/hypha/apply/activity/adapters/emails.py b/hypha/apply/activity/adapters/emails.py index 2d2bee3f03..61cb35d952 100644 --- a/hypha/apply/activity/adapters/emails.py +++ b/hypha/apply/activity/adapters/emails.py @@ -10,7 +10,10 @@ from hypha.apply.activity import tasks from hypha.apply.activity.models import ALL, APPLICANT_PARTNERS, PARTNER -from hypha.apply.funds.models.co_applicants import COMMENT, EDIT +from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, +) from hypha.apply.projects.models.payment import ( APPROVED_BY_FINANCE, CHANGES_REQUESTED_BY_FINANCE, @@ -418,7 +421,13 @@ def recipients(self, message_type, source, user, **kwargs): APPROVED_BY_FINANCE, PAYMENT_FAILED, }: - return [source.user.email] + co_applicants = source.submission.co_applicants.filter( + project_permission__contains=[ + CoApplicantProjectPermission.INVOICES + ], + role__in=[CoApplicantRole.EDIT], + ).values_list("user__email", flat=True) + return [source.user.email, *co_applicants] elif status in {CHANGES_REQUESTED_BY_FINANCE, RESUBMITTED}: return [source.lead.email] return [] @@ -436,7 +445,13 @@ def recipients(self, message_type, source, user, **kwargs): ) return get_compliance_email(target_user_gps=[CONTRACTING_GROUP_NAME]) if source.status == INVOICING_AND_REPORTING: - return [source.user.email] + co_applicants = source.submission.co_applicants.filter( + project_permission__contains=[ + CoApplicantProjectPermission.INVOICES + ], + role__in=[CoApplicantRole.EDIT], + ).values_list("user__email", flat=True) + return [source.user.email, *co_applicants] if message_type == MESSAGES.APPROVE_INVOICE: if user.is_apply_staff: @@ -447,7 +462,13 @@ def recipients(self, message_type, source, user, **kwargs): if user == source.user: return [source.lead.email] else: - return [source.user.email] + co_applicants = source.submission.co_applicants.filter( + project_permission__contains=[ + CoApplicantProjectPermission.INVOICES + ], + role__in=[CoApplicantRole.EDIT], + ).values_list("user__email", flat=True) + return [source.user.email, *co_applicants] if isinstance(source, get_user_model()): return user.email @@ -456,35 +477,50 @@ def recipients(self, message_type, source, user, **kwargs): Project = apps.get_model("application_projects", "Project") if message_type == MESSAGES.COMMENT: # Comment handling for Submissions + comment = kwargs["related"] if isinstance(source, ApplicationSubmission): # add co-applicants with Comment or edit access co_applicants = source.co_applicants.filter( - role__in=[COMMENT, EDIT] + role__in=[CoApplicantRole.COMMENT, CoApplicantRole.EDIT] ).values_list("user__email", flat=True) recipients: List[str] = [source.user.email, *co_applicants] - comment = kwargs["related"] if partners := list(source.partners.values_list("email", flat=True)): if comment.visibility == PARTNER: recipients = partners elif comment.visibility in [APPLICANT_PARTNERS, ALL]: recipients += partners - try: - recipients.remove(comment.user.email) - except ValueError: - pass - - return recipients - # Comment handling for Projects - if isinstance(source, Project) and user == source.user: - return [] + elif isinstance(source, Project): + # co_applciants with Comment permission + co_applicants = ( + source.submission.co_applicants.filter( + role__in=[CoApplicantRole.COMMENT, CoApplicantRole.EDIT] + ) + .exclude(project_permission=[]) + .values_list("user__email", flat=True) + ) + recipients = [source.user.email, *co_applicants] + try: + recipients.remove(comment.user.email) + except ValueError: + pass + + return recipients if isinstance(source, ApplicationSubmission): # co-applicants edit/full-access access - co_applicants = source.co_applicants.filter(role__in=[EDIT]).values_list( - "user__email", flat=True + co_applicants = source.co_applicants.filter( + role__in=[CoApplicantRole.EDIT] + ).values_list("user__email", flat=True) + return [source.user.email, *co_applicants] + elif isinstance(source, Project): + # co-applicants edit access + co_applicants = ( + source.submission.co_applicants.exclude(project_permission=[]) + .filter(role__in=[CoApplicantRole.EDIT]) + .values_list("user__email", flat=True) ) return [source.user.email, *co_applicants] return [source.user.email] diff --git a/hypha/apply/activity/services.py b/hypha/apply/activity/services.py index 6931b1540d..7806ab4483 100644 --- a/hypha/apply/activity/services.py +++ b/hypha/apply/activity/services.py @@ -49,7 +49,13 @@ def get_related_activities_for_user(obj, user): [`Activity`][hypha.apply.activity.models.Activity] queryset """ if hasattr(obj, "project") and obj.project: - source_filter = Q(submission=obj) | Q(project=obj.project) + if ( + obj.co_applicants.filter(user=user).exists() + and not obj.co_applicants.filter(user=user).first().project_permission + ): + source_filter = Q(submission=obj) + else: + source_filter = Q(submission=obj) | Q(project=obj.project) elif hasattr(obj, "submission") and obj.submission: source_filter = Q(submission=obj.submission) | Q(project=obj) else: diff --git a/hypha/apply/funds/forms.py b/hypha/apply/funds/forms.py index dcda14e57f..10efee27ef 100644 --- a/hypha/apply/funds/forms.py +++ b/hypha/apply/funds/forms.py @@ -21,7 +21,7 @@ Reminder, ReviewerRole, ) -from .models.co_applicants import COAPPLICANT_ROLE_CHOICES +from .models.co_applicants import CoApplicantProjectPermission, CoApplicantRole from .permissions import can_change_external_reviewers from .utils import model_form_initial, render_icon from .widgets import MetaTermWidget, MultiCheckboxesWidget @@ -464,7 +464,14 @@ class Meta: class InviteCoApplicantForm(forms.ModelForm): invited_user_email = forms.EmailField(required=True, label="Email") role = forms.ChoiceField( - choices=COAPPLICANT_ROLE_CHOICES, label="Role", required=False + choices=CoApplicantRole.choices, label="Role", required=False + ) + project_permission = forms.MultipleChoiceField( + choices=CoApplicantProjectPermission.choices, + required=False, + widget=forms.CheckboxSelectMultiple, + label="Enable permissions for Project", + help_text="It will provide access of selected role for selected project sections. Ex: selected role - View, selected project section - Contracting, then co-applicant can only read/view the contracting section but can't edit/upload.", ) submission = forms.ModelChoiceField( @@ -478,6 +485,8 @@ def __init__(self, *args, submission, user=None, **kwargs): if submission: self.fields["submission"].initial = submission.id + if not hasattr(submission, "project"): + self.fields.pop("project_permission", None) class Meta: model = CoApplicantInvite @@ -486,12 +495,22 @@ class Meta: class EditCoApplicantForm(forms.ModelForm): role = forms.ChoiceField( - choices=COAPPLICANT_ROLE_CHOICES, label="Role", required=False + choices=CoApplicantRole.choices, label="Role", required=False + ) + project_permission = forms.MultipleChoiceField( + choices=CoApplicantProjectPermission.choices, + required=False, + widget=forms.CheckboxSelectMultiple, + label="Enable permissions for Project", + help_text="It will provide access of selected role for selected project sections. Ex: selected role - View, selected project section - Contracting, then co-applicant can only read/view the contracting section but can't edit/upload.", ) - def __int__(self, *args, **kwargs): + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + instance = kwargs.get("instance", None) + if not hasattr(instance.submission, "project"): + self.fields.pop("project_permission", None) class Meta: model = CoApplicant - fields = ("role",) + fields = ("role", "project_permission") diff --git a/hypha/apply/funds/migrations/0127_coapplicant_project_permission.py b/hypha/apply/funds/migrations/0127_coapplicant_project_permission.py new file mode 100644 index 0000000000..139a6d000f --- /dev/null +++ b/hypha/apply/funds/migrations/0127_coapplicant_project_permission.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.20 on 2025-05-24 06:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("funds", "0126_add_max_length"), + ] + + operations = [ + migrations.AddField( + model_name="coapplicant", + name="project_permission", + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/hypha/apply/funds/migrations/0128_alter_coapplicant_role_alter_coapplicantinvite_role.py b/hypha/apply/funds/migrations/0128_alter_coapplicant_role_alter_coapplicantinvite_role.py new file mode 100644 index 0000000000..887c56814a --- /dev/null +++ b/hypha/apply/funds/migrations/0128_alter_coapplicant_role_alter_coapplicantinvite_role.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.21 on 2025-05-29 11:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("funds", "0127_coapplicant_project_permission"), + ] + + operations = [ + migrations.AlterField( + model_name="coapplicant", + name="role", + field=models.CharField( + choices=[("view", "View"), ("comment", "Comment"), ("edit", "Edit")], + default="view", + ), + ), + migrations.AlterField( + model_name="coapplicantinvite", + name="role", + field=models.CharField( + choices=[("view", "View"), ("comment", "Comment"), ("edit", "Edit")], + default="view", + ), + ), + ] diff --git a/hypha/apply/funds/migrations/0129_coapplicantinvite_project_permission.py b/hypha/apply/funds/migrations/0129_coapplicantinvite_project_permission.py new file mode 100644 index 0000000000..efc6518d7c --- /dev/null +++ b/hypha/apply/funds/migrations/0129_coapplicantinvite_project_permission.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.21 on 2025-06-05 07:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("funds", "0128_alter_coapplicant_role_alter_coapplicantinvite_role"), + ] + + operations = [ + migrations.AddField( + model_name="coapplicantinvite", + name="project_permission", + field=models.JSONField(blank=True, default=list, null=True), + ), + ] diff --git a/hypha/apply/funds/models/co_applicants.py b/hypha/apply/funds/models/co_applicants.py index 235c5a6895..f2672b5443 100644 --- a/hypha/apply/funds/models/co_applicants.py +++ b/hypha/apply/funds/models/co_applicants.py @@ -3,22 +3,25 @@ from hypha.apply.users.models import User -READ_ONLY = "read_only" -COMMENT = "comment" -EDIT = "edit" -COAPPLICANT_ROLE_CHOICES = ( - (READ_ONLY, _("Read Only")), - (COMMENT, _("Comment")), - (EDIT, _("Edit")), -) +class CoApplicantRole(models.TextChoices): + VIEW = "view", _("View") + COMMENT = "comment", _("Comment") + EDIT = "edit", _("Edit") + + +class CoApplicantProjectPermission(models.TextChoices): + PROJECT_DOCUMENT = "project_document", _("Project Document") + CONTRACTING_DOCUMENT = "contracting_document", _("Contracting Document") + INVOICES = "invoices", _("Invoices") + REPORTS = "reports", _("Reports") class CoApplicantInviteStatus(models.TextChoices): - PENDING = "pending", "Pending" - ACCEPTED = "accepted", "Accepted" - REJECTED = "rejected", "Rejected" - EXPIRED = "expired", "Expired" + PENDING = "pending", _("Pending") + ACCEPTED = "accepted", _("Accepted") + REJECTED = "rejected", _("Rejected") + EXPIRED = "expired", _("Expired") class CoApplicantInvite(models.Model): @@ -40,7 +43,10 @@ class CoApplicantInvite(models.Model): choices=CoApplicantInviteStatus.choices, default=CoApplicantInviteStatus.PENDING, ) - role = models.CharField(choices=COAPPLICANT_ROLE_CHOICES, default=READ_ONLY) + role = models.CharField( + choices=CoApplicantRole.choices, default=CoApplicantRole.VIEW + ) + project_permission = models.JSONField(blank=True, null=True, default=list) responded_on = models.DateTimeField(blank=True, null=True) invited_at = models.DateTimeField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) @@ -64,7 +70,10 @@ class CoApplicant(models.Model): invite = models.OneToOneField( CoApplicantInvite, on_delete=models.CASCADE, related_name="co_applicant" ) - role = models.CharField(choices=COAPPLICANT_ROLE_CHOICES, default=READ_ONLY) + role = models.CharField( + choices=CoApplicantRole.choices, default=CoApplicantRole.VIEW + ) + project_permission = models.JSONField(blank=True, null=True, default=list) created_at = models.DateTimeField(auto_now_add=True, null=True) class Meta: diff --git a/hypha/apply/funds/permissions.py b/hypha/apply/funds/permissions.py index cb97f9e790..c19856ea80 100644 --- a/hypha/apply/funds/permissions.py +++ b/hypha/apply/funds/permissions.py @@ -2,7 +2,7 @@ from django.core.exceptions import PermissionDenied from rolepermissions.permissions import register_object_checker -from hypha.apply.funds.models.co_applicants import COMMENT, READ_ONLY, CoApplicant +from hypha.apply.funds.models.co_applicants import CoApplicant, CoApplicantRole from hypha.apply.funds.models.submissions import DRAFT_STATE from ..users.roles import STAFF_GROUP_NAME, SUPERADMIN, TEAMADMIN_GROUP_NAME, StaffAdmin @@ -28,10 +28,10 @@ def can_edit_submission(user, submission): if submission.phase.permissions.can_edit(user): co_applicant = submission.co_applicants.filter(user=user).first() if co_applicant: - if co_applicant.role not in [READ_ONLY, COMMENT]: + if co_applicant.role not in [CoApplicantRole.VIEW, CoApplicantRole.COMMENT]: return ( True, - "Co-applicant with read only or comment access can't edit submission", + "Co-applicant with read/view only or comment access can't edit submission", ) return False, "" return True, "User can edit in current phase" @@ -258,7 +258,7 @@ def can_update_co_applicant(user, invite): def user_can_view_post_comment_form(user, submission): co_applicant = CoApplicant.objects.filter(user=user, submission=submission).first() - if co_applicant and co_applicant.role == READ_ONLY: + if co_applicant and co_applicant.role == CoApplicantRole.VIEW: return False return True diff --git a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html index 9a477e023f..c41ac3328d 100644 --- a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html @@ -1,5 +1,5 @@ {% extends "base-apply.html" %} -{% load i18n static workflow_tags wagtailcore_tags statusbar_tags archive_tags submission_tags translate_tags primaryactions_tags %} +{% load i18n static workflow_tags wagtailcore_tags statusbar_tags archive_tags submission_tags translate_tags primaryactions_tags project_tags %} {% load heroicons %} {% load can from permission_tags %} @@ -24,14 +24,16 @@ > {% trans "Application" %} - {% if PROJECTS_ENABLED and object.project %} - - {% trans "Project" %} - + {% user_can_access_project object.project user as can_access_project %} + {% if can_access_project %} + + {% trans "Project" %} + + {% endif %} {% endif %} {% if object.project and PROJECTS_ENABLED %} - - {% trans "Project" %} - + {% user_can_access_project object.project user as can_access_project %} + {% if can_access_project %} + + {% trans "Project" %} + + {% endif %} {% endif %} - {% if form and not object.is_archive or object.project %} - {% trans "Add communication" %} - - - {% csrf_token %} - - {% for hidden in form.hidden_fields %} - {{ hidden }} - {% endfor %} - - - - {% include "forms/includes/field.html" with field=form.message label_classes="sr-only" %} - - - - {% trans "Add Comment" %} - + {% if form %} + {% if not object.is_archive or object.project %} + {% trans "Add communication" %} + + + {% csrf_token %} + + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} + + + + {% include "forms/includes/field.html" with field=form.message label_classes="sr-only" %} + + + + {% trans "Add Comment" %} + + - - - {% include "forms/includes/field.html" with field=form.visibility %} - {% include "forms/includes/field.html" with field=form.assign_to %} - {% include "forms/includes/field.html" with field=form.attachments %} + + {% include "forms/includes/field.html" with field=form.visibility %} + {% include "forms/includes/field.html" with field=form.assign_to %} + {% include "forms/includes/field.html" with field=form.attachments %} + - - - + + + {% endif %} {% endif %} diff --git a/hypha/apply/funds/templates/funds/includes/co-applicant-block.html b/hypha/apply/funds/templates/funds/includes/co-applicant-block.html index 91588a2f6d..6f27ab194d 100644 --- a/hypha/apply/funds/templates/funds/includes/co-applicant-block.html +++ b/hypha/apply/funds/templates/funds/includes/co-applicant-block.html @@ -19,6 +19,11 @@ {% trans "Invite" %} + {% if object.project %} + + {% trans "Project permissions are available now and can be updated for each co-applicant." %} + + {% endif %} {% if co_applicants %} {% for invite in co_applicants %} diff --git a/hypha/apply/funds/templates/funds/modals/edit_co_applicant_form.html b/hypha/apply/funds/templates/funds/modals/edit_co_applicant_form.html index 533fa31114..0d925b6c0a 100644 --- a/hypha/apply/funds/templates/funds/modals/edit_co_applicant_form.html +++ b/hypha/apply/funds/templates/funds/modals/edit_co_applicant_form.html @@ -16,7 +16,7 @@ {% trans "Update the role for co-applicant." %} - {% trans "1. Read Only: Co-applicant can only view the submission." %} + {% trans "1. View: Co-applicant can only view/read the submission." %} {% trans "2. Comment: Co-applicant can view and write comment." %} @@ -47,6 +47,7 @@ class="inline-flex justify-center items-center py-2 px-3 mt-3 w-full text-sm font-semibold text-gray-900 bg-white ring-1 ring-inset ring-gray-300 sm:mt-0 sm:w-auto hover:bg-gray-50 button button--warning rounded-xs shadow-xs" @click="show = false" hx-post="{% url 'apply:submissions:delete_co_applicant_invite' invite_pk=invite.pk %}" + hx-confirm="{% trans 'Are you sure you want to delete co-applicant?' %}" >{% trans "Delete" %} {% else %} @@ -60,6 +61,7 @@ class="inline-flex justify-center items-center py-2 px-3 mt-3 w-full text-sm font-semibold text-gray-900 bg-white ring-1 ring-inset ring-gray-300 sm:mt-0 sm:w-auto hover:bg-gray-50 button button--warning rounded-xs shadow-xs" @click="show = false" hx-post="{% url 'apply:submissions:delete_co_applicant_invite' invite_pk=invite.pk %}" + hx-confirm="{% trans 'Are you sure you want to delete co-applicant?' %}" >{% trans "Delete" %} {% trans "Enter a valid email address to invite a co-applicant. It'll send an email invite to provided email address that can be used one time only. Invite will have the expiry of" %} {{ expiry }} {% trans "days." %} {% trans "You may also assign the appropriate role:" %} - {% trans "1. Read Only: Co-applicant can only view the submission." %} + {% trans "1. View: Co-applicant can only view/read the submission." %} {% trans "2. Comment: Co-applicant can view and write comment." %} diff --git a/hypha/apply/funds/views/co_applicants.py b/hypha/apply/funds/views/co_applicants.py index ad5ea7ff24..ea6d6e61b6 100644 --- a/hypha/apply/funds/views/co_applicants.py +++ b/hypha/apply/funds/views/co_applicants.py @@ -71,6 +71,9 @@ def post(self, *args, **kwargs): form.instance.submission = self.submission form.instance.invited_user_email = form.cleaned_data["invited_user_email"] form.instance.role = form.cleaned_data["role"] + form.instance.project_permission = form.cleaned_data.get( + "project_permission", None + ) form.instance.invited_by = self.request.user form.instance.invited_at = timezone.now() co_applicant_invite = form.save() @@ -88,7 +91,7 @@ def post(self, *args, **kwargs): "HX-Trigger": json.dumps( { "coApplicantUpdated": None, - "showMessage": _("Co-applicant created"), + "showMessage": _("Co-applicant invited"), } ), }, @@ -170,6 +173,7 @@ def post(self, args, **kwargs): submission=self.invite.submission, user=user, role=self.invite.role, + project_permission=self.invite.project_permission, ) if not self.request.user.is_authenticated or self.request.user != user: diff --git a/hypha/apply/projects/models/payment.py b/hypha/apply/projects/models/payment.py index aca7a2f748..f0028c3100 100644 --- a/hypha/apply/projects/models/payment.py +++ b/hypha/apply/projects/models/payment.py @@ -158,20 +158,55 @@ def status_display(self): return self.get_status_display() def can_user_delete(self, user): - if user.is_applicant or user.is_apply_staff: - if self.status in (SUBMITTED): + from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, + ) + + if self.status in (SUBMITTED): + if user.is_apply_staff: return True + if user.is_applicant: + if user == self.project.user: + return True + co_applicant = self.project.submission.co_applicants.filter( + user=user + ).first() + if ( + co_applicant + and CoApplicantProjectPermission.INVOICES + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True return False def can_user_edit(self, user): + from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, + ) + """ Check when an user can edit an invoice. Only applicant and staff have permission to edit invoice based on its current status. """ if user.is_applicant: if self.status in {SUBMITTED, CHANGES_REQUESTED_BY_STAFF, RESUBMITTED}: - return True + if user == self.project.user: + return True + co_applicant = self.project.submission.co_applicants.filter( + user=user + ).first() + if ( + co_applicant + and CoApplicantProjectPermission.INVOICES + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True + return False if user.is_apply_staff: if self.status in {SUBMITTED, RESUBMITTED, CHANGES_REQUESTED_BY_FINANCE}: diff --git a/hypha/apply/projects/permissions.py b/hypha/apply/projects/permissions.py index ac948e1f95..702c4d9bb3 100644 --- a/hypha/apply/projects/permissions.py +++ b/hypha/apply/projects/permissions.py @@ -3,8 +3,12 @@ from rolepermissions.permissions import register_object_checker from hypha.apply.activity.adapters.utils import get_users_for_groups +from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, +) from hypha.apply.users.models import User -from hypha.apply.users.roles import Staff +from hypha.apply.users.roles import Applicant, Staff from .models.project import ( CLOSING, @@ -51,8 +55,21 @@ def can_upload_contract(user, project, **kwargs): if not user.is_authenticated: return False, "Login Required" - if user == project.user and project.contracts.exists(): - return True, "Project Owner can only re-upload contract with countersigned" + if user.is_applicant and project.contracts.exists(): + if user == project.user: + return True, "Project Owner can only re-upload contract with countersigned" + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return ( + True, + "Co-applicant with edit permission for project's contracting document can upload contract", + ) + return False, "Forbidden Error" if user.is_contracting: return True, "Contracting team can upload the contract" @@ -66,12 +83,29 @@ def can_upload_contract(user, project, **kwargs): def can_submit_contract_documents(user, project, **kwargs): if project.status != CONTRACTING: return False, "Project is not in Contracting State" - if user != project.user: - return False, "Only Vendor can submit contracting documents" + + if not user.is_applicant: + return False, "Only Applicants can submit contracting documents" if not kwargs.get("contract", None): return False, "Can not submit without contract" if not project.submitted_contract_documents: - return True, "Vendor can submit contracting documents" + if user == project.user: + return True, "Vendor can submit contracting documents" + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return ( + True, + "Co-applicant with edit permission for project's contracting document can submit contracting documents", + ) + return ( + False, + "Only applicant and co-applicant with appropriate permission can submit docs", + ) return False, "Forbidden Error" @@ -296,6 +330,15 @@ def can_access_project(user, project): if user.is_applicant and user == project.user: return True, "Vendor(project user) can view project in all statuses" + if ( + user.is_applicant + and project.submission.co_applicants.filter(user=user).exists() + ): + co_applicant = project.submission.co_applicants.filter(user=user).first() + if co_applicant.project_permission: + return True, "Co-applicant with project permission can access project" + return False, "Co-applicant without project permission can't access project" + if ( project.status in [DRAFT, INTERNAL_APPROVAL, CONTRACTING] and project.paf_approvals.exists() @@ -325,6 +368,14 @@ def can_view_contract_category_documents(user, project, **kwargs): return True, "Superuser can view all documents" if user == project.user: return True, "Vendor can view all documents" + if user.is_applicant: + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + ): + return True, "Co-applicant with permissions can view contracting documents" contract_category = kwargs.get("contract_category") if not contract_category: @@ -353,6 +404,42 @@ def upload_project_documents(role, user, project) -> bool: return False +@register_object_checker() +def update_contracting_documents(role, user, project) -> bool: + if role == Applicant: + if user == project.user: # owner + return True + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): # co-applicant with permission + return True + + return False + + +@register_object_checker() +def add_invoice(role, user, project) -> bool: + if project.status == INVOICING_AND_REPORTING: + if role == Staff: + return True + if role == Applicant: + if user == project.user: + return True + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.INVOICES + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True + return False + + permissions_map = { "contract_approve": can_approve_contract, "contract_upload": can_upload_contract, diff --git a/hypha/apply/projects/reports/permissions.py b/hypha/apply/projects/reports/permissions.py index c1e018af67..89f6c4e224 100644 --- a/hypha/apply/projects/reports/permissions.py +++ b/hypha/apply/projects/reports/permissions.py @@ -1,6 +1,10 @@ from rolepermissions.permissions import register_object_checker -from hypha.apply.users.roles import StaffAdmin +from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, +) +from hypha.apply.users.roles import Applicant, StaffAdmin from ..models.project import ( CLOSING, @@ -31,8 +35,18 @@ def update_project_reports(role, user, project) -> bool: return False if project.status != INVOICING_AND_REPORTING: return False - if role == StaffAdmin or user == project.user: + if role == StaffAdmin: return True + if role == Applicant: + if user == project.user: + return True + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.REPORTS in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True return False @@ -95,8 +109,17 @@ def view_report(role, user, report) -> bool: return False if report.skipped: return False - if user.is_apply_staff or user.is_finance or user == report.project.user: + if user.is_apply_staff or user.is_finance: return True + if role == Applicant: + if user == report.project.user: + return True + co_applicant = report.project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.REPORTS in co_applicant.project_permission + ): + return True return False @@ -129,7 +152,19 @@ def update_report(role, user, report) -> bool: if not report.can_submit: return False - if user.is_apply_staff or (user == report.project.user and not report.current): + if user.is_apply_staff: return True + if role == Applicant: + if user == report.project.user and not report.current: + return True + co_applicant = report.project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and not report.current + and CoApplicantProjectPermission.REPORTS in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True + return False diff --git a/hypha/apply/projects/templates/application_projects/includes/invoices.html b/hypha/apply/projects/templates/application_projects/includes/invoices.html index 6bac428f19..e86f7fe61b 100644 --- a/hypha/apply/projects/templates/application_projects/includes/invoices.html +++ b/hypha/apply/projects/templates/application_projects/includes/invoices.html @@ -1,9 +1,10 @@ {% load i18n invoice_tools humanize heroicons %} +{% load can from permission_tags %} {% trans "Invoices" %} - {% user_can_add_invoices object user as can_add_invoice %} + {% can "add_invoice" object as can_add_invoice %} {% if can_add_invoice %} {% trans "Project Information" %} + {% project_can_have_invoices object as can_have_invoices %} {% user_can_view_invoices object user as can_view_invoices %} - {% if can_view_invoices %} + {% if can_have_invoices and can_view_invoices %} {% include "application_projects/includes/invoices.html" %} {% endif %} {% project_show_reports_section object as show_reports_section %} - {% if show_reports_section %} + {% user_can_view_reports object user as can_view_reports %} + {% if show_reports_section and can_view_reports %} {% include "reports/includes/reports.html" %} {% endif %} {% project_can_have_contracting_section object as can_have_contracting_section %} - {% if can_have_contracting_section %} + {% user_can_view_contracting_documents object user as can_view_contracting_documents %} + {% if can_have_contracting_section and can_view_contracting_documents %} {% include "application_projects/includes/contracting_documents.html" %} {% endif %} - {% include "application_projects/includes/project_documents.html" %} + {% user_can_view_project_documents object user as can_view_project_documents %} + {% if can_view_project_documents %} + {% include "application_projects/includes/project_documents.html" %} + {% endif %} {% block sidebar %} @@ -170,6 +176,13 @@ {% trans "Project form approvals" %} {% block admin_assignments %}{% endblock %} {% endif %} + {% display_coapplicant_section user object as coapplicant_section %} + {% if coapplicant_section %} + {% block co_applicant %} + + {% endblock %} + {% endif %} + {% endblock sidebar %} diff --git a/hypha/apply/projects/templatetags/contract_tools.py b/hypha/apply/projects/templatetags/contract_tools.py index 6dc72f35e6..6dc7cc69bf 100644 --- a/hypha/apply/projects/templatetags/contract_tools.py +++ b/hypha/apply/projects/templatetags/contract_tools.py @@ -1,6 +1,10 @@ from django import template from django.conf import settings +from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, +) from hypha.apply.projects.models.project import CONTRACTING from ..permissions import has_permission @@ -80,6 +84,14 @@ def show_contract_upload_row(project, user): return False if user.is_contracting or user == project.user or user.is_apply_staff: return True + if user.is_applicant: + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + ): + return True return False @@ -89,6 +101,15 @@ def can_update_contracting_documents(project, user): return False if user == project.user and not user.is_apply_staff and not user.is_contracting: return True + if user.is_applicant: + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True return False diff --git a/hypha/apply/projects/templatetags/invoice_tools.py b/hypha/apply/projects/templatetags/invoice_tools.py index 625b136807..9895aba196 100644 --- a/hypha/apply/projects/templatetags/invoice_tools.py +++ b/hypha/apply/projects/templatetags/invoice_tools.py @@ -63,21 +63,12 @@ def percentage(value, total): @register.simple_tag -def user_can_view_invoices(project, user): +def project_can_have_invoices(project): if project.status in [INVOICING_AND_REPORTING, CLOSING, COMPLETE]: return True return False -@register.simple_tag -def user_can_add_invoices(project, user): - if project.status == INVOICING_AND_REPORTING and ( - user.is_apply_staff or user == project.user - ): - return True - return False - - @register.simple_tag def get_invoice_form(invoice, user): from hypha.apply.projects.views.payment import ChangeInvoiceStatusForm diff --git a/hypha/apply/projects/templatetags/project_tags.py b/hypha/apply/projects/templatetags/project_tags.py index b8ea6e9431..3bcaf4986f 100644 --- a/hypha/apply/projects/templatetags/project_tags.py +++ b/hypha/apply/projects/templatetags/project_tags.py @@ -4,6 +4,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from hypha.apply.funds.models.co_applicants import CoApplicantProjectPermission from hypha.apply.projects.models.project import ( CLOSING, COMPLETE, @@ -32,6 +33,66 @@ def user_can_skip_pafapproval_process(project, user): return False +@register.simple_tag +def user_can_access_project(project, user): + permission, _ = has_permission( + "project_access", user, object=project, raise_exception=False + ) + return permission + + +@register.simple_tag +def user_can_view_project_documents(project, user): + if project.submission.co_applicants.filter(user=user).exists(): + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.PROJECT_DOCUMENT + not in co_applicant.project_permission + ): + return False + return True + + +@register.simple_tag +def user_can_view_contracting_documents(project, user): + if project.submission.co_applicants.filter(user=user).exists(): + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + not in co_applicant.project_permission + ): + return False + return True + + +@register.simple_tag +def user_can_view_invoices(project, user): + if project.submission.co_applicants.filter(user=user).exists(): + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.INVOICES + not in co_applicant.project_permission + ): + return False + return True + + +@register.simple_tag +def user_can_view_reports(project, user): + if project.submission.co_applicants.filter(user=user).exists(): + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.REPORTS + not in co_applicant.project_permission + ): + return False + return True + + @register.simple_tag def user_next_step_on_project(project, user, request=None): from hypha.apply.projects.models.project import PAFReviewersRole, ProjectSettings diff --git a/hypha/apply/projects/tests/test_models.py b/hypha/apply/projects/tests/test_models.py index ee2ec84be7..cd89e4831e 100644 --- a/hypha/apply/projects/tests/test_models.py +++ b/hypha/apply/projects/tests/test_models.py @@ -76,8 +76,8 @@ def test_staff_cant_delete_from_declined(self): self.assertFalse(invoice.can_user_delete(staff)) def test_can_user_delete_from_submitted(self): - invoice = InvoiceFactory(status=SUBMITTED) user = ApplicantFactory() + invoice = InvoiceFactory(status=SUBMITTED, project__user=user) self.assertTrue(invoice.can_user_delete(user)) def test_user_cant_delete_from_resubmitted(self): @@ -136,7 +136,7 @@ def test_applicant_can_edit_invoice(self): statuses = [CHANGES_REQUESTED_BY_STAFF, RESUBMITTED, SUBMITTED] user = ApplicantFactory() for status in statuses: - invoice = InvoiceFactory(status=status) + invoice = InvoiceFactory(status=status, project__user=user) self.assertTrue(invoice.can_user_edit(user)) def test_applicant_cant_edit_invoice(self): diff --git a/hypha/apply/projects/tests/test_templatetags.py b/hypha/apply/projects/tests/test_templatetags.py index 9fd0d6e471..3ab0819c43 100644 --- a/hypha/apply/projects/tests/test_templatetags.py +++ b/hypha/apply/projects/tests/test_templatetags.py @@ -187,8 +187,8 @@ def test_staff_cant_delete_from_declined(self): self.assertFalse(can_delete(invoice, staff)) def test_user_can_delete_from_submitted(self): - invoice = InvoiceFactory(status=SUBMITTED) user = ApplicantFactory() + invoice = InvoiceFactory(status=SUBMITTED, project__user=user) self.assertTrue(can_delete(invoice, user)) @@ -217,16 +217,18 @@ def test_user_cant_delete_from_declined(self): self.assertFalse(can_delete(invoice, user)) def test_applicant_and_staff_can_edit_in_submitted(self): - invoice = InvoiceFactory(status=SUBMITTED) applicant = ApplicantFactory() + invoice = InvoiceFactory(status=SUBMITTED, project__user=applicant) staff = StaffFactory() self.assertTrue(can_edit(invoice, applicant)) self.assertTrue(can_edit(invoice, staff)) def test_applicant_can_edit_in_changes_requested(self): - invoice = InvoiceFactory(status=CHANGES_REQUESTED_BY_STAFF) applicant = ApplicantFactory() + invoice = InvoiceFactory( + status=CHANGES_REQUESTED_BY_STAFF, project__user=applicant + ) self.assertTrue(can_edit(invoice, applicant)) @@ -237,8 +239,8 @@ def test_staff_cant_edit_in_changes_requested(self): self.assertFalse(can_edit(invoice, staff)) def test_applicant_and_staff_can_edit_in_resubmitted(self): - invoice = InvoiceFactory(status=RESUBMITTED) applicant = ApplicantFactory() + invoice = InvoiceFactory(status=RESUBMITTED, project__user=applicant) staff = StaffFactory() self.assertTrue(can_edit(invoice, applicant)) diff --git a/hypha/apply/projects/views/payment.py b/hypha/apply/projects/views/payment.py index 4586943c64..484136f638 100644 --- a/hypha/apply/projects/views/payment.py +++ b/hypha/apply/projects/views/payment.py @@ -26,9 +26,11 @@ HttpResponseClientRefresh, ) from django_tables2 import SingleTableMixin +from rolepermissions.checkers import has_object_permission from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.activity.models import APPLICANT, COMMENT, Activity +from hypha.apply.funds.models.co_applicants import CoApplicantProjectPermission from hypha.apply.projects.templatetags.invoice_tools import ( display_invoice_status_for_user, ) @@ -93,6 +95,19 @@ def test_func(self): if self.request.user == self.get_object().project.user: return True + if self.request.user.is_applicant: + co_applicant = ( + self.get_object() + .project.submission.co_applicants.filter(user=self.request.user) + .first() + ) + if ( + co_applicant + and CoApplicantProjectPermission.INVOICES + in co_applicant.project_permission + ): + return True + return False @@ -259,7 +274,8 @@ class CreateInvoiceView(SuccessMessageMixin, CreateView): def dispatch(self, request, *args, **kwargs): self.project = get_object_or_404(Project, submission__id=kwargs["pk"]) - if not request.user.is_apply_staff and not self.project.user == request.user: + permission = has_object_permission("add_invoice", request.user, self.project) + if not permission: return redirect(self.project) return super().dispatch(request, *args, **kwargs) @@ -493,6 +509,17 @@ def test_func(self): if self.request.user == self.invoice.project.user: return True + if self.request.user.is_applicant: + co_applicant = self.invoice.project.submission.co_applicants.filter( + user=self.request.user + ).first() + if ( + co_applicant + and CoApplicantProjectPermission.INVOICES + in co_applicant.project_permission + ): + return True + return False diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py index f01231bead..38946b990b 100644 --- a/hypha/apply/projects/views/project.py +++ b/hypha/apply/projects/views/project.py @@ -40,6 +40,7 @@ from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.activity.models import ACTION, ALL, COMMENT, TEAM, Activity from hypha.apply.activity.views import ActivityContextMixin +from hypha.apply.funds.models.co_applicants import CoApplicantProjectPermission from hypha.apply.stream_forms.models import BaseStreamForm from hypha.apply.todo.options import ( PAF_REQUIRED_CHANGES, @@ -374,7 +375,11 @@ class RemoveContractDocumentView(ProjectBySubmissionIdMixin, View): def dispatch(self, request, *args, **kwargs): self.project = self.get_object() - if not request.user.is_applicant or request.user != self.project.user: + + permission = has_object_permission( + "update_contracting_documents", request.user, obj=self.project + ) + if not permission: raise PermissionDenied return super().dispatch(request, *args, **kwargs) @@ -710,7 +715,7 @@ def post(self, *args, **kwargs): form.instance.project = self.project - if self.request.user == self.project.user: + if self.request.user.is_applicant: form.instance.signed_by_applicant = True form.instance.uploaded_by_applicant_at = timezone.now() messages.success(self.request, _("Countersigned contract uploaded")) @@ -952,7 +957,10 @@ def dispatch(self, request, *args, **kwargs): self.category = get_object_or_404( ContractDocumentCategory, id=kwargs.get("category_pk") ) - if request.user != self.project.user or not request.user.is_applicant: + permission = has_object_permission( + "update_contracting_documents", request.user, self.project + ) + if not permission: raise PermissionDenied return super().dispatch(request, *args, **kwargs) @@ -1736,6 +1744,17 @@ def test_func(self): if self.request.user == self.project.user: return True + # co-applicant with project document permission can view documents + co_applicant = self.project.submission.co_applicants.filter( + user=self.request.user + ).first() + if ( + co_applicant + and CoApplicantProjectPermission.PROJECT_DOCUMENT + in co_applicant.project_permission + ): + return True + return False @@ -1789,6 +1808,17 @@ def test_func(self): if self.request.user == self.project.user: return True + # co-applicant with contract document permission can view documents + co_applicant = self.project.submission.co_applicants.filter( + user=self.request.user + ).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + ): + return True + return False
{% trans "Update the role for co-applicant." %} - {% trans "1. Read Only: Co-applicant can only view the submission." %} + {% trans "1. View: Co-applicant can only view/read the submission." %} {% trans "2. Comment: Co-applicant can view and write comment." %} @@ -47,6 +47,7 @@ class="inline-flex justify-center items-center py-2 px-3 mt-3 w-full text-sm font-semibold text-gray-900 bg-white ring-1 ring-inset ring-gray-300 sm:mt-0 sm:w-auto hover:bg-gray-50 button button--warning rounded-xs shadow-xs" @click="show = false" hx-post="{% url 'apply:submissions:delete_co_applicant_invite' invite_pk=invite.pk %}" + hx-confirm="{% trans 'Are you sure you want to delete co-applicant?' %}" >{% trans "Delete" %}
{% trans "You may also assign the appropriate role:" %} - {% trans "1. Read Only: Co-applicant can only view the submission." %} + {% trans "1. View: Co-applicant can only view/read the submission." %} {% trans "2. Comment: Co-applicant can view and write comment." %} diff --git a/hypha/apply/funds/views/co_applicants.py b/hypha/apply/funds/views/co_applicants.py index ad5ea7ff24..ea6d6e61b6 100644 --- a/hypha/apply/funds/views/co_applicants.py +++ b/hypha/apply/funds/views/co_applicants.py @@ -71,6 +71,9 @@ def post(self, *args, **kwargs): form.instance.submission = self.submission form.instance.invited_user_email = form.cleaned_data["invited_user_email"] form.instance.role = form.cleaned_data["role"] + form.instance.project_permission = form.cleaned_data.get( + "project_permission", None + ) form.instance.invited_by = self.request.user form.instance.invited_at = timezone.now() co_applicant_invite = form.save() @@ -88,7 +91,7 @@ def post(self, *args, **kwargs): "HX-Trigger": json.dumps( { "coApplicantUpdated": None, - "showMessage": _("Co-applicant created"), + "showMessage": _("Co-applicant invited"), } ), }, @@ -170,6 +173,7 @@ def post(self, args, **kwargs): submission=self.invite.submission, user=user, role=self.invite.role, + project_permission=self.invite.project_permission, ) if not self.request.user.is_authenticated or self.request.user != user: diff --git a/hypha/apply/projects/models/payment.py b/hypha/apply/projects/models/payment.py index aca7a2f748..f0028c3100 100644 --- a/hypha/apply/projects/models/payment.py +++ b/hypha/apply/projects/models/payment.py @@ -158,20 +158,55 @@ def status_display(self): return self.get_status_display() def can_user_delete(self, user): - if user.is_applicant or user.is_apply_staff: - if self.status in (SUBMITTED): + from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, + ) + + if self.status in (SUBMITTED): + if user.is_apply_staff: return True + if user.is_applicant: + if user == self.project.user: + return True + co_applicant = self.project.submission.co_applicants.filter( + user=user + ).first() + if ( + co_applicant + and CoApplicantProjectPermission.INVOICES + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True return False def can_user_edit(self, user): + from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, + ) + """ Check when an user can edit an invoice. Only applicant and staff have permission to edit invoice based on its current status. """ if user.is_applicant: if self.status in {SUBMITTED, CHANGES_REQUESTED_BY_STAFF, RESUBMITTED}: - return True + if user == self.project.user: + return True + co_applicant = self.project.submission.co_applicants.filter( + user=user + ).first() + if ( + co_applicant + and CoApplicantProjectPermission.INVOICES + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True + return False if user.is_apply_staff: if self.status in {SUBMITTED, RESUBMITTED, CHANGES_REQUESTED_BY_FINANCE}: diff --git a/hypha/apply/projects/permissions.py b/hypha/apply/projects/permissions.py index ac948e1f95..702c4d9bb3 100644 --- a/hypha/apply/projects/permissions.py +++ b/hypha/apply/projects/permissions.py @@ -3,8 +3,12 @@ from rolepermissions.permissions import register_object_checker from hypha.apply.activity.adapters.utils import get_users_for_groups +from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, +) from hypha.apply.users.models import User -from hypha.apply.users.roles import Staff +from hypha.apply.users.roles import Applicant, Staff from .models.project import ( CLOSING, @@ -51,8 +55,21 @@ def can_upload_contract(user, project, **kwargs): if not user.is_authenticated: return False, "Login Required" - if user == project.user and project.contracts.exists(): - return True, "Project Owner can only re-upload contract with countersigned" + if user.is_applicant and project.contracts.exists(): + if user == project.user: + return True, "Project Owner can only re-upload contract with countersigned" + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return ( + True, + "Co-applicant with edit permission for project's contracting document can upload contract", + ) + return False, "Forbidden Error" if user.is_contracting: return True, "Contracting team can upload the contract" @@ -66,12 +83,29 @@ def can_upload_contract(user, project, **kwargs): def can_submit_contract_documents(user, project, **kwargs): if project.status != CONTRACTING: return False, "Project is not in Contracting State" - if user != project.user: - return False, "Only Vendor can submit contracting documents" + + if not user.is_applicant: + return False, "Only Applicants can submit contracting documents" if not kwargs.get("contract", None): return False, "Can not submit without contract" if not project.submitted_contract_documents: - return True, "Vendor can submit contracting documents" + if user == project.user: + return True, "Vendor can submit contracting documents" + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return ( + True, + "Co-applicant with edit permission for project's contracting document can submit contracting documents", + ) + return ( + False, + "Only applicant and co-applicant with appropriate permission can submit docs", + ) return False, "Forbidden Error" @@ -296,6 +330,15 @@ def can_access_project(user, project): if user.is_applicant and user == project.user: return True, "Vendor(project user) can view project in all statuses" + if ( + user.is_applicant + and project.submission.co_applicants.filter(user=user).exists() + ): + co_applicant = project.submission.co_applicants.filter(user=user).first() + if co_applicant.project_permission: + return True, "Co-applicant with project permission can access project" + return False, "Co-applicant without project permission can't access project" + if ( project.status in [DRAFT, INTERNAL_APPROVAL, CONTRACTING] and project.paf_approvals.exists() @@ -325,6 +368,14 @@ def can_view_contract_category_documents(user, project, **kwargs): return True, "Superuser can view all documents" if user == project.user: return True, "Vendor can view all documents" + if user.is_applicant: + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + ): + return True, "Co-applicant with permissions can view contracting documents" contract_category = kwargs.get("contract_category") if not contract_category: @@ -353,6 +404,42 @@ def upload_project_documents(role, user, project) -> bool: return False +@register_object_checker() +def update_contracting_documents(role, user, project) -> bool: + if role == Applicant: + if user == project.user: # owner + return True + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.CONTRACTING_DOCUMENT + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): # co-applicant with permission + return True + + return False + + +@register_object_checker() +def add_invoice(role, user, project) -> bool: + if project.status == INVOICING_AND_REPORTING: + if role == Staff: + return True + if role == Applicant: + if user == project.user: + return True + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.INVOICES + in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True + return False + + permissions_map = { "contract_approve": can_approve_contract, "contract_upload": can_upload_contract, diff --git a/hypha/apply/projects/reports/permissions.py b/hypha/apply/projects/reports/permissions.py index c1e018af67..89f6c4e224 100644 --- a/hypha/apply/projects/reports/permissions.py +++ b/hypha/apply/projects/reports/permissions.py @@ -1,6 +1,10 @@ from rolepermissions.permissions import register_object_checker -from hypha.apply.users.roles import StaffAdmin +from hypha.apply.funds.models.co_applicants import ( + CoApplicantProjectPermission, + CoApplicantRole, +) +from hypha.apply.users.roles import Applicant, StaffAdmin from ..models.project import ( CLOSING, @@ -31,8 +35,18 @@ def update_project_reports(role, user, project) -> bool: return False if project.status != INVOICING_AND_REPORTING: return False - if role == StaffAdmin or user == project.user: + if role == StaffAdmin: return True + if role == Applicant: + if user == project.user: + return True + co_applicant = project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.REPORTS in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True return False @@ -95,8 +109,17 @@ def view_report(role, user, report) -> bool: return False if report.skipped: return False - if user.is_apply_staff or user.is_finance or user == report.project.user: + if user.is_apply_staff or user.is_finance: return True + if role == Applicant: + if user == report.project.user: + return True + co_applicant = report.project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and CoApplicantProjectPermission.REPORTS in co_applicant.project_permission + ): + return True return False @@ -129,7 +152,19 @@ def update_report(role, user, report) -> bool: if not report.can_submit: return False - if user.is_apply_staff or (user == report.project.user and not report.current): + if user.is_apply_staff: return True + if role == Applicant: + if user == report.project.user and not report.current: + return True + co_applicant = report.project.submission.co_applicants.filter(user=user).first() + if ( + co_applicant + and not report.current + and CoApplicantProjectPermission.REPORTS in co_applicant.project_permission + and co_applicant.role == CoApplicantRole.EDIT + ): + return True + return False diff --git a/hypha/apply/projects/templates/application_projects/includes/invoices.html b/hypha/apply/projects/templates/application_projects/includes/invoices.html index 6bac428f19..e86f7fe61b 100644 --- a/hypha/apply/projects/templates/application_projects/includes/invoices.html +++ b/hypha/apply/projects/templates/application_projects/includes/invoices.html @@ -1,9 +1,10 @@ {% load i18n invoice_tools humanize heroicons %} +{% load can from permission_tags %} {% trans "Invoices" %} - {% user_can_add_invoices object user as can_add_invoice %} + {% can "add_invoice" object as can_add_invoice %} {% if can_add_invoice %} {% trans "Project Information" %} + {% project_can_have_invoices object as can_have_invoices %} {% user_can_view_invoices object user as can_view_invoices %} - {% if can_view_invoices %} + {% if can_have_invoices and can_view_invoices %} {% include "application_projects/includes/invoices.html" %} {% endif %} {% project_show_reports_section object as show_reports_section %} - {% if show_reports_section %} + {% user_can_view_reports object user as can_view_reports %} + {% if show_reports_section and can_view_reports %} {% include "reports/includes/reports.html" %} {% endif %} {% project_can_have_contracting_section object as can_have_contracting_section %} - {% if can_have_contracting_section %} + {% user_can_view_contracting_documents object user as can_view_contracting_documents %} + {% if can_have_contracting_section and can_view_contracting_documents %} {% include "application_projects/includes/contracting_documents.html" %} {% endif %} - {% include "application_projects/includes/project_documents.html" %} + {% user_can_view_project_documents object user as can_view_project_documents %} + {% if can_view_project_documents %} + {% include "application_projects/includes/project_documents.html" %} + {% endif %} {% block sidebar %} @@ -170,6 +176,13 @@ {% trans "Project form approvals" %} {% block admin_assignments %}{% endblock %} {% endif %} + {% display_coapplicant_section user object as coapplicant_section %} + {% if coapplicant_section %} + {% block co_applicant %} + + {% endblock %} + {% endif %} + {% endblock sidebar %}