Skip to content
70 changes: 53 additions & 17 deletions hypha/apply/activity/adapters/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 []
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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]
Expand Down
8 changes: 7 additions & 1 deletion hypha/apply/activity/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 24 additions & 5 deletions hypha/apply/funds/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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")
Original file line number Diff line number Diff line change
@@ -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),
),
]
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Original file line number Diff line number Diff line change
@@ -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),
),
]
37 changes: 23 additions & 14 deletions hypha/apply/funds/models/co_applicants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions hypha/apply/funds/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 %}

Expand All @@ -24,14 +24,16 @@
>
{% trans "Application" %}
</a>

{% if PROJECTS_ENABLED and object.project %}
<a
class="tab__item"
href="{% url 'funds:submissions:project' pk=object.id %}"
>
{% trans "Project" %}
</a>
{% user_can_access_project object.project user as can_access_project %}
{% if can_access_project %}
<a
class="tab__item"
href="{% url 'funds:submissions:project' pk=object.id %}"
>
{% trans "Project" %}
</a>
{% endif %}
{% endif %}

<a
Expand Down
Loading
Loading