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" %} - -
- + {% 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" %} + +
+ +
-
-
- {% 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" %}
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