Skip to content

Commit e420942

Browse files
committed
Add Invite Coapplicant model/feature, list their email ids
1 parent bea3ce0 commit e420942

14 files changed

Lines changed: 295 additions & 28 deletions

File tree

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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.conf import settings
77
from django.contrib.auth import get_user_model
88
from django.template.loader import render_to_string
9+
from django.urls import reverse
910
from django.utils.translation import gettext as _
1011

1112
from hypha.apply.activity import tasks
@@ -41,6 +42,7 @@ class EmailAdapter(AdapterBase):
4142
messages = {
4243
MESSAGES.NEW_SUBMISSION: "messages/email/submission_confirmation.html",
4344
MESSAGES.DRAFT_SUBMISSION: "messages/email/submission_confirmation.html",
45+
MESSAGES.INVITE_COAPPLICANT: "handle_co_applicant_invite",
4446
MESSAGES.COMMENT: "notify_comment",
4547
MESSAGES.EDIT_SUBMISSION: "messages/email/submission_edit.html",
4648
MESSAGES.TRANSITION: "handle_transition",
@@ -87,6 +89,8 @@ def get_subject(self, message_type, source):
8789
subject = _(
8890
"Reminder: Application ready to review: {source.title_text_display}"
8991
).format(source=source)
92+
elif message_type == MESSAGES.INVITE_COAPPLICANT:
93+
subject = _("You are invited as a co-applicant")
9094
elif message_type in [
9195
MESSAGES.SENT_TO_COMPLIANCE,
9296
MESSAGES.APPROVE_PAF,
@@ -164,6 +168,27 @@ def handle_transition(self, old_phase, source, **kwargs):
164168
**kwargs,
165169
)
166170

171+
def handle_co_applicant_invite(self, source, related, **kwargs):
172+
invited_user = User.objects.filter(email=related.invited_user_email).first()
173+
can_accept = True
174+
if invited_user and (
175+
invited_user.is_apply_staff or invited_user.is_apply_staff_admin
176+
):
177+
can_accept = False
178+
179+
accept_link = reverse(
180+
"apply:submissions:accept_coapplicant_invite",
181+
kwargs={"pk": source.id, "token": related.token},
182+
)
183+
return self.render_message(
184+
"messages/email/invite_co_applicant.html",
185+
source=source,
186+
can_accept=can_accept,
187+
accept_link=accept_link,
188+
related=related,
189+
**kwargs,
190+
)
191+
167192
def handle_batch_transition(self, transitions, sources, **kwargs):
168193
submissions = sources
169194
kwargs.pop("source")
@@ -274,6 +299,10 @@ def recipients(self, message_type, source, user, **kwargs):
274299
if not source.phase.permissions.can_view(source.user):
275300
return []
276301

302+
if message_type == MESSAGES.INVITE_COAPPLICANT:
303+
related = kwargs.get("related", None)
304+
return [related.invited_user_email]
305+
277306
if message_type == MESSAGES.PARTNERS_UPDATED_PARTNER:
278307
partners = kwargs["added"]
279308
return [partner.email for partner in partners]

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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{% extends "messages/email/applicant_base.html" %}
2+
3+
{% load i18n %}
4+
{% block content %}{# fmt:off #}
5+
{% blocktrans %}You have been invited as a co-applicant to an application on {{ ORG_SHORT_NAME }} by {{ user }}.{% endblocktrans %}
6+
{% if not can_accept %}
7+
{% trans "But You can't accept this invite because you already hold a responsible position in" %} {{ ORG_SHORT_NAME }}
8+
{% endif %}
9+
{% blocktrans %} Click on link if you want to accept it, otherwise leave it.{% endblocktrans %}
10+
{% trans "Link" %}: {{ accept_link }}
11+
{% endblock %}{# fmt:on #}

hypha/apply/funds/forms.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from collections import OrderedDict
23
from functools import partial
34
from itertools import groupby
@@ -16,6 +17,7 @@
1617
from .models import (
1718
ApplicationSubmission,
1819
AssignedReviewers,
20+
CoApplicantInvite,
1921
Reminder,
2022
ReviewerRole,
2123
)
@@ -456,3 +458,29 @@ def save(self, *args, **kwargs):
456458
class Meta:
457459
model = Reminder
458460
fields = ["title", "description", "time", "action"]
461+
462+
463+
class InviteCoApplicantForm(forms.ModelForm):
464+
invited_user_email = forms.EmailField(required=True, label="Email")
465+
466+
submission = forms.ModelChoiceField(
467+
queryset=ApplicationSubmission.objects.filter(),
468+
widget=forms.HiddenInput(),
469+
)
470+
471+
def __init__(self, *args, submission, user=None, **kwargs):
472+
super().__init__(*args, **kwargs)
473+
self.invited_by = user
474+
475+
if submission:
476+
self.fields["submission"].initial = submission.id
477+
478+
class Meta:
479+
model = CoApplicantInvite
480+
fields = ["invited_user_email", "submission"]
481+
482+
def save(self, *args, **kwargs):
483+
self.instance.submission = self.cleaned_data["submission"]
484+
self.instance.token = str(uuid.uuid4())
485+
self.instance.invited_user_email = self.cleaned_data["invited_user_email"]
486+
return super().save(*args, **kwargs)

hypha/apply/funds/migrations/0124_coapplicant.py renamed to hypha/apply/funds/migrations/0124_coapplicantinvite_coapplicant.py

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 4.2.20 on 2025-04-21 10:12
1+
# Generated by Django 4.2.20 on 2025-04-23 12:47
22

33
from django.conf import settings
44
from django.db import migrations, models
@@ -12,6 +12,45 @@ class Migration(migrations.Migration):
1212
]
1313

1414
operations = [
15+
migrations.CreateModel(
16+
name="CoApplicantInvite",
17+
fields=[
18+
(
19+
"id",
20+
models.AutoField(
21+
auto_created=True,
22+
primary_key=True,
23+
serialize=False,
24+
verbose_name="ID",
25+
),
26+
),
27+
("invited_user_email", models.EmailField(max_length=254)),
28+
("token", models.CharField(max_length=256, unique=True)),
29+
("is_used", models.BooleanField(default=False)),
30+
("created_at", models.DateTimeField(auto_now_add=True)),
31+
(
32+
"invited_by",
33+
models.ForeignKey(
34+
blank=True,
35+
null=True,
36+
on_delete=django.db.models.deletion.SET_NULL,
37+
related_name="co_applicant_invites",
38+
to=settings.AUTH_USER_MODEL,
39+
),
40+
),
41+
(
42+
"submission",
43+
models.ForeignKey(
44+
on_delete=django.db.models.deletion.CASCADE,
45+
related_name="co_applicant_invites",
46+
to="funds.applicationsubmission",
47+
),
48+
),
49+
],
50+
options={
51+
"unique_together": {("submission", "invited_user_email")},
52+
},
53+
),
1554
migrations.CreateModel(
1655
name="CoApplicant",
1756
fields=[
@@ -29,25 +68,21 @@ class Migration(migrations.Migration):
2968
"status",
3069
models.CharField(
3170
choices=[
32-
("pending", "Pending"),
3371
("accepted", "Accepted"),
3472
("rejected", "Rejected"),
3573
("expired", "Expired"),
3674
],
37-
default="pending",
75+
default="accepted",
3876
max_length=20,
3977
),
4078
),
41-
("invited_on", models.DateTimeField(auto_now_add=True)),
42-
("responded_on", models.DateTimeField(blank=True, null=True)),
79+
("accepted_on", models.DateTimeField(blank=True, null=True)),
4380
(
44-
"invited_by",
45-
models.ForeignKey(
46-
blank=True,
47-
null=True,
48-
on_delete=django.db.models.deletion.SET_NULL,
49-
related_name="co_applicant_invites",
50-
to=settings.AUTH_USER_MODEL,
81+
"invite",
82+
models.OneToOneField(
83+
on_delete=django.db.models.deletion.CASCADE,
84+
related_name="co_applicant",
85+
to="funds.coapplicantinvite",
5186
),
5287
),
5388
(

hypha/apply/funds/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
RoundsAndLabs,
1010
)
1111
from .assigned_reviewers import AssignedReviewers
12-
from .co_applicants import CoApplicant
12+
from .co_applicants import CoApplicant, CoApplicantInvite
1313
from .forms import ApplicationForm
1414
from .reminders import Reminder
1515
from .reviewer_role import ReviewerRole, ReviewerSettings
@@ -28,6 +28,7 @@
2828
"RoundsAndLabs",
2929
"ScreeningStatus",
3030
"CoApplicant",
31+
"CoApplicantInvite",
3132
]
3233

3334

hypha/apply/funds/models/co_applicants.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,36 @@
1212

1313

1414
class CoApplicantInviteStatus(models.TextChoices):
15-
PENDING = "pending", "Pending"
1615
ACCEPTED = "accepted", "Accepted"
1716
REJECTED = "rejected", "Rejected"
1817
EXPIRED = "expired", "Expired"
1918

2019

20+
class CoApplicantInvite(models.Model):
21+
submission = models.ForeignKey(
22+
"funds.ApplicationSubmission",
23+
on_delete=models.CASCADE,
24+
related_name="co_applicant_invites",
25+
)
26+
invited_user_email = models.EmailField()
27+
token = models.CharField(max_length=256, unique=True)
28+
is_used = models.BooleanField(default=False)
29+
invited_by = models.ForeignKey(
30+
User,
31+
on_delete=models.SET_NULL,
32+
null=True,
33+
blank=True,
34+
related_name="co_applicant_invites",
35+
)
36+
created_at = models.DateTimeField(auto_now_add=True)
37+
38+
class Meta:
39+
unique_together = ("submission", "invited_user_email")
40+
41+
def __str__(self):
42+
return f"{self.invited_user_email} invited to {self.submission})"
43+
44+
2145
class CoApplicant(models.Model):
2246
submission = models.ForeignKey(
2347
"funds.ApplicationSubmission",
@@ -27,27 +51,19 @@ class CoApplicant(models.Model):
2751
user = models.ForeignKey(
2852
User, on_delete=models.CASCADE, related_name="co_applicants"
2953
)
54+
invite = models.OneToOneField(
55+
CoApplicantInvite, on_delete=models.CASCADE, related_name="co_applicant"
56+
)
3057
role = models.JSONField(default=list)
31-
3258
status = models.CharField(
3359
max_length=20,
3460
choices=CoApplicantInviteStatus.choices,
35-
default=CoApplicantInviteStatus.PENDING,
36-
)
37-
invited_by = models.ForeignKey(
38-
User,
39-
on_delete=models.SET_NULL,
40-
null=True,
41-
blank=True,
42-
related_name="co_applicant_invites",
61+
default=CoApplicantInviteStatus.ACCEPTED,
4362
)
44-
invited_on = models.DateTimeField(auto_now_add=True)
45-
responded_on = models.DateTimeField(null=True, blank=True)
63+
accepted_on = models.DateTimeField(null=True, blank=True)
4664

4765
class Meta:
4866
unique_together = ("submission", "user")
4967

5068
def __str__(self):
51-
return (
52-
f"{self.user} invited to {self.submission} as {self.role} ({self.status})"
53-
)
69+
return self.user

hypha/apply/funds/permissions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,16 @@ def can_view_submission_screening(user, submission):
209209
return True, ""
210210

211211

212+
def can_invite_co_applicants(user, submission):
213+
if user.is_applicant and user == submission.user:
214+
return True, "Applicants can invite co-applicants to their application"
215+
return False, "Forbidden Error"
216+
217+
212218
permissions_map = {
213219
"submission_view": is_user_has_access_to_view_submission,
214220
"submission_edit": can_edit_submission,
215221
"can_view_submission_screening": can_view_submission_screening,
216222
"archive_alter": can_alter_archived_submissions,
223+
"co_applicant_invite": can_invite_co_applicants,
217224
}

hypha/apply/funds/templates/funds/includes/generic_primary_actions.html

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,21 @@ <h5>{% trans "Actions to take" %}</h5>
2121
{% endif %}
2222
</div>
2323
{% endif %}
24+
25+
{% display_coapplicant_section user object as coapplicant_section %}
26+
{% if coapplicant_section %}
27+
<div class="sidebar__inner sidebar__inner--light-blue sidebar__inner--actions" data-testid="sidebar-primary-actions">
28+
<h5>{% trans "Manage Co-applicants" %}</h5>
29+
{% for invite in object.co_applicant_invites.all %}
30+
<p> {{ invite.invited_user_email }}</p>
31+
{% endfor %}
32+
<button
33+
class="button button--primary button--full-width button--bottom-space"
34+
hx-get="{% url 'apply:submissions:invite_co_applicant' pk=object.id %}"
35+
hx-target="#htmx-modal"
36+
{% if object.co_applicant_invites.count >= 5 %}disabled{% endif %}
37+
role="button"
38+
aria-label="{% trans "Invite CoApplicant" %}"
39+
>{% trans "Invite Co-applicant" %}</button>
40+
</div>
41+
{% endif %}

0 commit comments

Comments
 (0)