Skip to content

Commit c02f2fd

Browse files
committed
Merge remote-tracking branch 'origin/main' into themes
* origin/main: Expand co-applicant feature to projects (#4552) Set label in project header to "Project lead:" instead of only "Lead:" (#4570) Update Django to 4.2.2 and also update other python packages. (#4571)
2 parents 15d02d7 + 56b31c9 commit c02f2fd

28 files changed

Lines changed: 610 additions & 152 deletions

hypha/apply/activity/adapters/emails.py

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010

1111
from hypha.apply.activity import tasks
1212
from hypha.apply.activity.models import ALL, APPLICANT_PARTNERS, PARTNER
13-
from hypha.apply.funds.models.co_applicants import COMMENT, EDIT
13+
from hypha.apply.funds.models.co_applicants import (
14+
CoApplicantProjectPermission,
15+
CoApplicantRole,
16+
)
1417
from hypha.apply.projects.models.payment import (
1518
APPROVED_BY_FINANCE,
1619
CHANGES_REQUESTED_BY_FINANCE,
@@ -418,7 +421,13 @@ def recipients(self, message_type, source, user, **kwargs):
418421
APPROVED_BY_FINANCE,
419422
PAYMENT_FAILED,
420423
}:
421-
return [source.user.email]
424+
co_applicants = source.submission.co_applicants.filter(
425+
project_permission__contains=[
426+
CoApplicantProjectPermission.INVOICES
427+
],
428+
role__in=[CoApplicantRole.EDIT],
429+
).values_list("user__email", flat=True)
430+
return [source.user.email, *co_applicants]
422431
elif status in {CHANGES_REQUESTED_BY_FINANCE, RESUBMITTED}:
423432
return [source.lead.email]
424433
return []
@@ -436,7 +445,13 @@ def recipients(self, message_type, source, user, **kwargs):
436445
)
437446
return get_compliance_email(target_user_gps=[CONTRACTING_GROUP_NAME])
438447
if source.status == INVOICING_AND_REPORTING:
439-
return [source.user.email]
448+
co_applicants = source.submission.co_applicants.filter(
449+
project_permission__contains=[
450+
CoApplicantProjectPermission.INVOICES
451+
],
452+
role__in=[CoApplicantRole.EDIT],
453+
).values_list("user__email", flat=True)
454+
return [source.user.email, *co_applicants]
440455

441456
if message_type == MESSAGES.APPROVE_INVOICE:
442457
if user.is_apply_staff:
@@ -447,7 +462,13 @@ def recipients(self, message_type, source, user, **kwargs):
447462
if user == source.user:
448463
return [source.lead.email]
449464
else:
450-
return [source.user.email]
465+
co_applicants = source.submission.co_applicants.filter(
466+
project_permission__contains=[
467+
CoApplicantProjectPermission.INVOICES
468+
],
469+
role__in=[CoApplicantRole.EDIT],
470+
).values_list("user__email", flat=True)
471+
return [source.user.email, *co_applicants]
451472

452473
if isinstance(source, get_user_model()):
453474
return user.email
@@ -456,35 +477,50 @@ def recipients(self, message_type, source, user, **kwargs):
456477
Project = apps.get_model("application_projects", "Project")
457478
if message_type == MESSAGES.COMMENT:
458479
# Comment handling for Submissions
480+
comment = kwargs["related"]
459481
if isinstance(source, ApplicationSubmission):
460482
# add co-applicants with Comment or edit access
461483
co_applicants = source.co_applicants.filter(
462-
role__in=[COMMENT, EDIT]
484+
role__in=[CoApplicantRole.COMMENT, CoApplicantRole.EDIT]
463485
).values_list("user__email", flat=True)
464486
recipients: List[str] = [source.user.email, *co_applicants]
465487

466-
comment = kwargs["related"]
467488
if partners := list(source.partners.values_list("email", flat=True)):
468489
if comment.visibility == PARTNER:
469490
recipients = partners
470491
elif comment.visibility in [APPLICANT_PARTNERS, ALL]:
471492
recipients += partners
472493

473-
try:
474-
recipients.remove(comment.user.email)
475-
except ValueError:
476-
pass
477-
478-
return recipients
479-
480494
# Comment handling for Projects
481-
if isinstance(source, Project) and user == source.user:
482-
return []
495+
elif isinstance(source, Project):
496+
# co_applciants with Comment permission
497+
co_applicants = (
498+
source.submission.co_applicants.filter(
499+
role__in=[CoApplicantRole.COMMENT, CoApplicantRole.EDIT]
500+
)
501+
.exclude(project_permission=[])
502+
.values_list("user__email", flat=True)
503+
)
504+
recipients = [source.user.email, *co_applicants]
505+
try:
506+
recipients.remove(comment.user.email)
507+
except ValueError:
508+
pass
509+
510+
return recipients
483511

484512
if isinstance(source, ApplicationSubmission):
485513
# co-applicants edit/full-access access
486-
co_applicants = source.co_applicants.filter(role__in=[EDIT]).values_list(
487-
"user__email", flat=True
514+
co_applicants = source.co_applicants.filter(
515+
role__in=[CoApplicantRole.EDIT]
516+
).values_list("user__email", flat=True)
517+
return [source.user.email, *co_applicants]
518+
elif isinstance(source, Project):
519+
# co-applicants edit access
520+
co_applicants = (
521+
source.submission.co_applicants.exclude(project_permission=[])
522+
.filter(role__in=[CoApplicantRole.EDIT])
523+
.values_list("user__email", flat=True)
488524
)
489525
return [source.user.email, *co_applicants]
490526
return [source.user.email]

hypha/apply/activity/services.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ def get_related_activities_for_user(obj, user):
4949
[`Activity`][hypha.apply.activity.models.Activity] queryset
5050
"""
5151
if hasattr(obj, "project") and obj.project:
52-
source_filter = Q(submission=obj) | Q(project=obj.project)
52+
if (
53+
obj.co_applicants.filter(user=user).exists()
54+
and not obj.co_applicants.filter(user=user).first().project_permission
55+
):
56+
source_filter = Q(submission=obj)
57+
else:
58+
source_filter = Q(submission=obj) | Q(project=obj.project)
5359
elif hasattr(obj, "submission") and obj.submission:
5460
source_filter = Q(submission=obj.submission) | Q(project=obj)
5561
else:

hypha/apply/funds/forms.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
Reminder,
2222
ReviewerRole,
2323
)
24-
from .models.co_applicants import COAPPLICANT_ROLE_CHOICES
24+
from .models.co_applicants import CoApplicantProjectPermission, CoApplicantRole
2525
from .permissions import can_change_external_reviewers
2626
from .utils import model_form_initial, render_icon
2727
from .widgets import MetaTermWidget, MultiCheckboxesWidget
@@ -464,7 +464,14 @@ class Meta:
464464
class InviteCoApplicantForm(forms.ModelForm):
465465
invited_user_email = forms.EmailField(required=True, label="Email")
466466
role = forms.ChoiceField(
467-
choices=COAPPLICANT_ROLE_CHOICES, label="Role", required=False
467+
choices=CoApplicantRole.choices, label="Role", required=False
468+
)
469+
project_permission = forms.MultipleChoiceField(
470+
choices=CoApplicantProjectPermission.choices,
471+
required=False,
472+
widget=forms.CheckboxSelectMultiple,
473+
label="Enable permissions for Project",
474+
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.",
468475
)
469476

470477
submission = forms.ModelChoiceField(
@@ -478,6 +485,8 @@ def __init__(self, *args, submission, user=None, **kwargs):
478485

479486
if submission:
480487
self.fields["submission"].initial = submission.id
488+
if not hasattr(submission, "project"):
489+
self.fields.pop("project_permission", None)
481490

482491
class Meta:
483492
model = CoApplicantInvite
@@ -486,12 +495,22 @@ class Meta:
486495

487496
class EditCoApplicantForm(forms.ModelForm):
488497
role = forms.ChoiceField(
489-
choices=COAPPLICANT_ROLE_CHOICES, label="Role", required=False
498+
choices=CoApplicantRole.choices, label="Role", required=False
499+
)
500+
project_permission = forms.MultipleChoiceField(
501+
choices=CoApplicantProjectPermission.choices,
502+
required=False,
503+
widget=forms.CheckboxSelectMultiple,
504+
label="Enable permissions for Project",
505+
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.",
490506
)
491507

492-
def __int__(self, *args, **kwargs):
508+
def __init__(self, *args, **kwargs):
493509
super().__init__(*args, **kwargs)
510+
instance = kwargs.get("instance", None)
511+
if not hasattr(instance.submission, "project"):
512+
self.fields.pop("project_permission", None)
494513

495514
class Meta:
496515
model = CoApplicant
497-
fields = ("role",)
516+
fields = ("role", "project_permission")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 4.2.20 on 2025-05-24 06:48
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("funds", "0126_add_max_length"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="coapplicant",
14+
name="project_permission",
15+
field=models.JSONField(blank=True, default=list, null=True),
16+
),
17+
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 4.2.21 on 2025-05-29 11:09
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("funds", "0127_coapplicant_project_permission"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="coapplicant",
14+
name="role",
15+
field=models.CharField(
16+
choices=[("view", "View"), ("comment", "Comment"), ("edit", "Edit")],
17+
default="view",
18+
),
19+
),
20+
migrations.AlterField(
21+
model_name="coapplicantinvite",
22+
name="role",
23+
field=models.CharField(
24+
choices=[("view", "View"), ("comment", "Comment"), ("edit", "Edit")],
25+
default="view",
26+
),
27+
),
28+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 4.2.21 on 2025-06-05 07:58
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("funds", "0128_alter_coapplicant_role_alter_coapplicantinvite_role"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="coapplicantinvite",
14+
name="project_permission",
15+
field=models.JSONField(blank=True, default=list, null=True),
16+
),
17+
]

hypha/apply/funds/models/co_applicants.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,25 @@
33

44
from hypha.apply.users.models import User
55

6-
READ_ONLY = "read_only"
7-
COMMENT = "comment"
8-
EDIT = "edit"
96

10-
COAPPLICANT_ROLE_CHOICES = (
11-
(READ_ONLY, _("Read Only")),
12-
(COMMENT, _("Comment")),
13-
(EDIT, _("Edit")),
14-
)
7+
class CoApplicantRole(models.TextChoices):
8+
VIEW = "view", _("View")
9+
COMMENT = "comment", _("Comment")
10+
EDIT = "edit", _("Edit")
11+
12+
13+
class CoApplicantProjectPermission(models.TextChoices):
14+
PROJECT_DOCUMENT = "project_document", _("Project Document")
15+
CONTRACTING_DOCUMENT = "contracting_document", _("Contracting Document")
16+
INVOICES = "invoices", _("Invoices")
17+
REPORTS = "reports", _("Reports")
1518

1619

1720
class CoApplicantInviteStatus(models.TextChoices):
18-
PENDING = "pending", "Pending"
19-
ACCEPTED = "accepted", "Accepted"
20-
REJECTED = "rejected", "Rejected"
21-
EXPIRED = "expired", "Expired"
21+
PENDING = "pending", _("Pending")
22+
ACCEPTED = "accepted", _("Accepted")
23+
REJECTED = "rejected", _("Rejected")
24+
EXPIRED = "expired", _("Expired")
2225

2326

2427
class CoApplicantInvite(models.Model):
@@ -40,7 +43,10 @@ class CoApplicantInvite(models.Model):
4043
choices=CoApplicantInviteStatus.choices,
4144
default=CoApplicantInviteStatus.PENDING,
4245
)
43-
role = models.CharField(choices=COAPPLICANT_ROLE_CHOICES, default=READ_ONLY)
46+
role = models.CharField(
47+
choices=CoApplicantRole.choices, default=CoApplicantRole.VIEW
48+
)
49+
project_permission = models.JSONField(blank=True, null=True, default=list)
4450
responded_on = models.DateTimeField(blank=True, null=True)
4551
invited_at = models.DateTimeField(blank=True, null=True)
4652
created_at = models.DateTimeField(auto_now_add=True)
@@ -64,7 +70,10 @@ class CoApplicant(models.Model):
6470
invite = models.OneToOneField(
6571
CoApplicantInvite, on_delete=models.CASCADE, related_name="co_applicant"
6672
)
67-
role = models.CharField(choices=COAPPLICANT_ROLE_CHOICES, default=READ_ONLY)
73+
role = models.CharField(
74+
choices=CoApplicantRole.choices, default=CoApplicantRole.VIEW
75+
)
76+
project_permission = models.JSONField(blank=True, null=True, default=list)
6877
created_at = models.DateTimeField(auto_now_add=True, null=True)
6978

7079
class Meta:

hypha/apply/funds/permissions.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.core.exceptions import PermissionDenied
33
from rolepermissions.permissions import register_object_checker
44

5-
from hypha.apply.funds.models.co_applicants import COMMENT, READ_ONLY, CoApplicant
5+
from hypha.apply.funds.models.co_applicants import CoApplicant, CoApplicantRole
66
from hypha.apply.funds.models.submissions import DRAFT_STATE
77

88
from ..users.roles import STAFF_GROUP_NAME, SUPERADMIN, TEAMADMIN_GROUP_NAME, StaffAdmin
@@ -38,10 +38,10 @@ def can_edit_submission(user, submission):
3838
if submission.phase.permissions.can_edit(user):
3939
co_applicant = submission.co_applicants.filter(user=user).first()
4040
if co_applicant:
41-
if co_applicant.role not in [READ_ONLY, COMMENT]:
41+
if co_applicant.role not in [CoApplicantRole.VIEW, CoApplicantRole.COMMENT]:
4242
return (
4343
True,
44-
"Co-applicant with read only or comment access can't edit submission",
44+
"Co-applicant with read/view only or comment access can't edit submission",
4545
)
4646
return False, ""
4747
return True, "User can edit in current phase"
@@ -268,7 +268,7 @@ def can_update_co_applicant(user, invite):
268268

269269
def user_can_view_post_comment_form(user, submission):
270270
co_applicant = CoApplicant.objects.filter(user=user, submission=submission).first()
271-
if co_applicant and co_applicant.role == READ_ONLY:
271+
if co_applicant and co_applicant.role == CoApplicantRole.VIEW:
272272
return False
273273
return True
274274

hypha/apply/funds/templates/funds/applicationsubmission_detail.html

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{% extends "base-apply.html" %}
2-
{% load i18n static workflow_tags wagtailcore_tags statusbar_tags archive_tags submission_tags translate_tags primaryactions_tags %}
2+
{% load i18n static workflow_tags wagtailcore_tags statusbar_tags archive_tags submission_tags translate_tags primaryactions_tags project_tags %}
33
{% load heroicons %}
44
{% load can from permission_tags %}
55

@@ -26,12 +26,15 @@
2626
</a>
2727

2828
{% if PROJECTS_ENABLED and object.project %}
29-
<a
30-
class="tab [--color-base-content:var(--color-neutral-content)]"
31-
href="{% url 'funds:submissions:project' pk=object.id %}"
32-
>
33-
{% trans "Project" %}
34-
</a>
29+
{% user_can_access_project object.project user as can_access_project %}
30+
{% if can_access_project %}
31+
<a
32+
class="tab [--color-base-content:var(--color-neutral-content)]"
33+
href="{% url 'funds:submissions:project' pk=object.id %}"
34+
>
35+
{% trans "Project" %}
36+
</a>
37+
{% endif %}
3538
{% endif %}
3639

3740
<a

0 commit comments

Comments
 (0)