Skip to content

Commit ddd37e6

Browse files
Add Co-applicants feature to submissions (#4492)
Fixes #3919
1 parent 317e016 commit ddd37e6

31 files changed

Lines changed: 1166 additions & 46 deletions

hypha/apply/activity/adapters/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
MESSAGES.TRANSITION: "old_phase",
1212
MESSAGES.BATCH_TRANSITION: "transitions",
1313
MESSAGES.APPLICANT_EDIT: "revision",
14+
MESSAGES.INVITE_COAPPLICANT: "co_applicant_invite",
1415
MESSAGES.EDIT_SUBMISSION: "revision",
1516
MESSAGES.COMMENT: "comment",
1617
MESSAGES.SCREENING: "old_status",

hypha/apply/activity/adapters/emails.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
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
1314
from hypha.apply.projects.models.payment import (
1415
APPROVED_BY_FINANCE,
1516
CHANGES_REQUESTED_BY_FINANCE,
@@ -49,6 +50,7 @@ class EmailAdapter(AdapterBase):
4950
messages = {
5051
MESSAGES.NEW_SUBMISSION: "messages/email/submission_confirmation.html",
5152
MESSAGES.DRAFT_SUBMISSION: "messages/email/submission_confirmation.html",
53+
MESSAGES.INVITE_COAPPLICANT: "handle_co_applicant_invite",
5254
MESSAGES.COMMENT: "notify_comment",
5355
MESSAGES.EDIT_SUBMISSION: "messages/email/submission_edit.html",
5456
MESSAGES.TRANSITION: "handle_transition",
@@ -96,6 +98,8 @@ def get_subject(self, message_type, source):
9698
subject = _(
9799
"Reminder: Application ready to review: {source.title_text_display}"
98100
).format(source=source)
101+
elif message_type == MESSAGES.INVITE_COAPPLICANT:
102+
subject = _("You are invited as a co-applicant")
99103
elif message_type in [
100104
MESSAGES.SENT_TO_COMPLIANCE,
101105
MESSAGES.APPROVE_PAF,
@@ -173,6 +177,25 @@ def handle_transition(self, old_phase, source, **kwargs):
173177
**kwargs,
174178
)
175179

180+
def handle_co_applicant_invite(self, source, related, **kwargs):
181+
from hypha.apply.funds.utils import generate_invite_path
182+
183+
invited_user = User.objects.filter(email=related.invited_user_email).first()
184+
can_accept = True
185+
if invited_user and (invited_user.is_org_faculty):
186+
can_accept = False
187+
188+
accept_link = generate_invite_path(invite=related)
189+
return self.render_message(
190+
"messages/email/invite_co_applicant.html",
191+
source=source,
192+
can_accept=can_accept,
193+
accept_link=accept_link,
194+
related=related,
195+
invited_user=invited_user,
196+
**kwargs,
197+
)
198+
176199
def handle_batch_transition(self, transitions, sources, **kwargs):
177200
submissions = sources
178201
kwargs.pop("source")
@@ -295,6 +318,10 @@ def recipients(self, message_type, source, user, **kwargs):
295318
if not source.phase.permissions.can_view(source.user):
296319
return []
297320

321+
if message_type == MESSAGES.INVITE_COAPPLICANT:
322+
related = kwargs.get("related", None)
323+
return [related.invited_user_email]
324+
298325
if message_type == MESSAGES.PARTNERS_UPDATED_PARTNER:
299326
partners = kwargs["added"]
300327
return [partner.email for partner in partners]
@@ -430,7 +457,11 @@ def recipients(self, message_type, source, user, **kwargs):
430457
if message_type == MESSAGES.COMMENT:
431458
# Comment handling for Submissions
432459
if isinstance(source, ApplicationSubmission):
433-
recipients: List[str] = [source.user.email]
460+
# add co-applicants with Comment or edit access
461+
co_applicants = source.co_applicants.filter(
462+
role__in=[COMMENT, EDIT]
463+
).values_list("user__email", flat=True)
464+
recipients: List[str] = [source.user.email, *co_applicants]
434465

435466
comment = kwargs["related"]
436467
if partners := list(source.partners.values_list("email", flat=True)):
@@ -450,6 +481,12 @@ def recipients(self, message_type, source, user, **kwargs):
450481
if isinstance(source, Project) and user == source.user:
451482
return []
452483

484+
if isinstance(source, ApplicationSubmission):
485+
# co-applicants edit/full-access access
486+
co_applicants = source.co_applicants.filter(role__in=[EDIT]).values_list(
487+
"user__email", flat=True
488+
)
489+
return [source.user.email, *co_applicants]
453490
return [source.user.email]
454491

455492
def batch_recipients(self, message_type, sources, **kwargs):
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Generated by Django 4.2.20 on 2025-04-30 05:35
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("activity", "0086_remove_django_messages_adapter"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="event",
14+
name="type",
15+
field=models.CharField(
16+
choices=[
17+
("UPDATE_LEAD", "updated lead"),
18+
("BATCH_UPDATE_LEAD", "batch updated lead"),
19+
("EDIT_SUBMISSION", "edited submission"),
20+
("APPLICANT_EDIT", "edited applicant"),
21+
("NEW_SUBMISSION", "submitted new submission"),
22+
("DRAFT_SUBMISSION", "submitted new draft submission"),
23+
("SCREENING", "screened"),
24+
("TRANSITION", "transitioned"),
25+
("BATCH_TRANSITION", "batch transitioned"),
26+
("DETERMINATION_OUTCOME", "sent determination outcome"),
27+
("BATCH_DETERMINATION_OUTCOME", "sent batch determination outcome"),
28+
("INVITED_TO_PROPOSAL", "invited to proposal"),
29+
("REVIEWERS_UPDATED", "updated reviewers"),
30+
("BATCH_REVIEWERS_UPDATED", "batch updated reviewers"),
31+
("PARTNERS_UPDATED", "updated partners"),
32+
("PARTNERS_UPDATED_PARTNER", "partners updated partner"),
33+
("READY_FOR_REVIEW", "marked ready for review"),
34+
("BATCH_READY_FOR_REVIEW", "marked batch ready for review"),
35+
("NEW_REVIEW", "added new review"),
36+
("COMMENT", "added comment"),
37+
("PROPOSAL_SUBMITTED", "submitted proposal"),
38+
("OPENED_SEALED", "opened sealed submission"),
39+
("REVIEW_OPINION", "reviewed opinion"),
40+
("DELETE_SUBMISSION", "deleted submission"),
41+
("DELETE_REVIEW", "deleted review"),
42+
("DELETE_REVIEW_OPINION", "deleted review opinion"),
43+
("CREATED_PROJECT", "created project"),
44+
("UPDATE_PROJECT_LEAD", "updated project lead"),
45+
("UPDATE_PROJECT_TITLE", "updated project title"),
46+
("EDIT_REVIEW", "edited review"),
47+
("SEND_FOR_APPROVAL", "sent for approval"),
48+
("APPROVE_PROJECT", "approved project"),
49+
("ASSIGN_PAF_APPROVER", "assign project form approver"),
50+
("APPROVE_PAF", "approved project form"),
51+
("PROJECT_TRANSITION", "transitioned project"),
52+
("REQUEST_PROJECT_CHANGE", "requested project change"),
53+
("SUBMIT_CONTRACT_DOCUMENTS", "submitted contract documents"),
54+
("UPLOAD_DOCUMENT", "uploaded document to project"),
55+
("UPLOAD_CONTRACT", "uploaded contract to project"),
56+
("APPROVE_CONTRACT", "approved contract"),
57+
("CREATE_INVOICE", "created invoice for project"),
58+
("UPDATE_INVOICE_STATUS", "updated invoice status"),
59+
("APPROVE_INVOICE", "approve invoice"),
60+
("DELETE_INVOICE", "deleted invoice"),
61+
("SENT_TO_COMPLIANCE", "sent project to compliance"),
62+
("UPDATE_INVOICE", "updated invoice"),
63+
("SUBMIT_REPORT", "submitted report"),
64+
("SKIPPED_REPORT", "skipped report"),
65+
("REPORT_FREQUENCY_CHANGED", "changed report frequency"),
66+
("DISABLED_REPORTING", "disabled reporting"),
67+
("REPORT_NOTIFY", "notified report"),
68+
("REVIEW_REMINDER", "reminder to review"),
69+
("BATCH_DELETE_SUBMISSION", "batch deleted submissions"),
70+
("BATCH_ARCHIVE_SUBMISSION", "batch archive submissions"),
71+
("BATCH_INVOICE_STATUS_UPDATE", "batch update invoice status"),
72+
("STAFF_ACCOUNT_CREATED", "created new account"),
73+
("STAFF_ACCOUNT_EDITED", "edited account"),
74+
("ARCHIVE_SUBMISSION", "archived submission"),
75+
("UNARCHIVE_SUBMISSION", "unarchived submission"),
76+
("REMOVE_TASK", "remove task"),
77+
("INVITE_COAPPLICANT", "invite co-applicant"),
78+
],
79+
max_length=50,
80+
verbose_name="verb",
81+
),
82+
),
83+
]

hypha/apply/activity/options.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,4 @@ class MESSAGES(TextChoices):
7979
ARCHIVE_SUBMISSION = "ARCHIVE_SUBMISSION", _("archived submission")
8080
UNARCHIVE_SUBMISSION = "UNARCHIVE_SUBMISSION", _("unarchived submission")
8181
REMOVE_TASK = "REMOVE_TASK", _("remove task")
82+
INVITE_COAPPLICANT = "INVITE_COAPPLICANT", _("invite co-applicant")
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{% extends "messages/email/base.html" %}
2+
{% load i18n activity_tags %}
3+
4+
{% block salutation %}
5+
{% if invited_user %}
6+
{% blocktrans with name=invited_user.get_display_name %}Dear {{ name }},{% endblocktrans %}
7+
{% else %}
8+
{% blocktrans with name=related.invited_user_email|email_name %}Dear {{ name }},{% endblocktrans %}
9+
{% endif %}
10+
{% endblock %}
11+
12+
{% block content %}{# fmt:off #}
13+
{% blocktrans %}You have been invited as a co-applicant to an application on {{ ORG_SHORT_NAME }} by {{ user }}.{% endblocktrans %}
14+
{% if not can_accept %}
15+
{% trans "But You can't accept this invite because you already hold a responsible position in" %} {{ ORG_SHORT_NAME }}
16+
{% else %}
17+
{% blocktrans %}Click on link if you want to accept it.{% endblocktrans %}
18+
19+
{% trans "Link" %}: {{ request.scheme }}://{{ request.get_host }}{{ accept_link }}
20+
{% endif %}
21+
{% endblock %}{# fmt:on #}

hypha/apply/activity/templatetags/activity_tags.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,10 @@ def get_project_creation_message(project) -> str | None:
199199

200200
# If a project is created in any other state that wouldn't be useful to have a message for
201201
return None
202+
203+
204+
@register.filter
205+
def email_name(email):
206+
if isinstance(email, str) and "@" in email:
207+
return email.split("@")[0]
208+
return email

hypha/apply/dashboard/views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.conf import settings
2+
from django.db.models import Q
23
from django.http import HttpResponseForbidden, HttpResponseRedirect
34
from django.shortcuts import render
45
from django.urls import reverse, reverse_lazy
@@ -462,7 +463,7 @@ class ApplicantDashboardView(TemplateView):
462463
def get_context_data(self, **kwargs):
463464
context = super().get_context_data(**kwargs)
464465
context["my_submissions_exists"] = ApplicationSubmission.objects.filter(
465-
user=self.request.user
466+
Q(user=self.request.user) | Q(co_applicants__user=self.request.user)
466467
).exists()
467468

468469
# Number of items to show in skeleton in each section of lazy loading

hypha/apply/dashboard/views_partials.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django.contrib.auth.decorators import login_required
22
from django.core.paginator import Paginator
3-
from django.db.models import Case, When
3+
from django.db.models import Case, Q, When
44
from django.shortcuts import render
55
from django.views.decorators.http import require_GET
66

@@ -12,13 +12,14 @@
1212
def my_active_submissions(user):
1313
active_subs = (
1414
ApplicationSubmission.objects.filter(
15-
user=user,
15+
Q(user=user) | Q(co_applicants__user=user),
1616
)
1717
.annotate(
1818
is_active=Case(When(status__in=active_statuses, then=True), default=False)
1919
)
2020
.select_related("draft_revision")
2121
.order_by("-is_active", "-submit_time")
22+
.distinct()
2223
)
2324
for submission in active_subs:
2425
yield submission.from_draft()

hypha/apply/determinations/views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,10 @@ def dispatch(self, request, *args, **kwargs):
615615
)
616616
determination = self.get_object()
617617

618-
if request.user != self.submission.user:
618+
if (
619+
request.user != self.submission.user
620+
and not self.submission.co_applicants.filter(user=request.user).exists
621+
):
619622
raise PermissionDenied
620623

621624
if determination.is_draft:

hypha/apply/funds/forms.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
from .models import (
1717
ApplicationSubmission,
1818
AssignedReviewers,
19+
CoApplicant,
20+
CoApplicantInvite,
1921
Reminder,
2022
ReviewerRole,
2123
)
24+
from .models.co_applicants import COAPPLICANT_ROLE_CHOICES
2225
from .permissions import can_change_external_reviewers
2326
from .utils import model_form_initial, render_icon
2427
from .widgets import MetaTermWidget, MultiCheckboxesWidget
@@ -456,3 +459,39 @@ def save(self, *args, **kwargs):
456459
class Meta:
457460
model = Reminder
458461
fields = ["title", "description", "time", "action"]
462+
463+
464+
class InviteCoApplicantForm(forms.ModelForm):
465+
invited_user_email = forms.EmailField(required=True, label="Email")
466+
role = forms.ChoiceField(
467+
choices=COAPPLICANT_ROLE_CHOICES, label="Role", required=False
468+
)
469+
470+
submission = forms.ModelChoiceField(
471+
queryset=ApplicationSubmission.objects.filter(),
472+
widget=forms.HiddenInput(),
473+
)
474+
475+
def __init__(self, *args, submission, user=None, **kwargs):
476+
super().__init__(*args, **kwargs)
477+
self.invited_by = user
478+
479+
if submission:
480+
self.fields["submission"].initial = submission.id
481+
482+
class Meta:
483+
model = CoApplicantInvite
484+
fields = ["invited_user_email", "submission"]
485+
486+
487+
class EditCoApplicantForm(forms.ModelForm):
488+
role = forms.ChoiceField(
489+
choices=COAPPLICANT_ROLE_CHOICES, label="Role", required=False
490+
)
491+
492+
def __int__(self, *args, **kwargs):
493+
super().__init__(*args, **kwargs)
494+
495+
class Meta:
496+
model = CoApplicant
497+
fields = ("role",)

0 commit comments

Comments
 (0)