Skip to content

Commit a6ffb91

Browse files
committed
Improve account invitation process
1 parent f3983dc commit a6ffb91

16 files changed

+616
-31
lines changed

astra_app/core/account_invitation_reconcile.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,17 @@ def reconcile_account_invitation_for_username(
3737
username: str,
3838
now: datetime,
3939
) -> None:
40-
normalized_username = str(username or "").strip()
40+
normalized_username = str(username or "").strip().lower()
4141
if not normalized_username:
4242
return
4343

4444
update_fields = ["freeipa_matched_usernames", "freeipa_last_checked_at"]
4545
if invitation.organization_id is None:
4646
invitation.accepted_at = invitation.accepted_at or now
4747
update_fields.insert(0, "accepted_at")
48+
if not invitation.accepted_username:
49+
invitation.accepted_username = normalized_username
50+
update_fields.insert(1, "accepted_username")
4851

4952
usernames = set(invitation.freeipa_matched_usernames)
5053
usernames.add(normalized_username)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Generated by Django 6.0.1 on 2026-02-24 10:10
2+
3+
from __future__ import annotations
4+
5+
from django.db import migrations, models
6+
7+
8+
def _backfill_org_claim_accepted_usernames(apps, schema_editor) -> None:
9+
AccountInvitation = apps.get_model("core", "AccountInvitation")
10+
11+
invitations = (
12+
AccountInvitation.objects.filter(
13+
organization_id__isnull=False,
14+
accepted_at__isnull=False,
15+
accepted_username="",
16+
)
17+
.exclude(organization__representative="")
18+
.select_related("organization")
19+
)
20+
21+
for invitation in invitations.iterator():
22+
if invitation.organization is None:
23+
continue
24+
25+
accepted_username = str(invitation.organization.representative or "").strip().lower()
26+
if not accepted_username:
27+
continue
28+
29+
AccountInvitation.objects.filter(pk=invitation.pk, accepted_username="").update(
30+
accepted_username=accepted_username,
31+
)
32+
33+
34+
class Migration(migrations.Migration):
35+
36+
dependencies = [
37+
("core", "0080_organizationcsvimportlink_and_more"),
38+
]
39+
40+
operations = [
41+
migrations.AddField(
42+
model_name="accountinvitation",
43+
name="accepted_username",
44+
field=models.CharField(blank=True, default="", max_length=255),
45+
),
46+
migrations.RunPython(
47+
code=_backfill_org_claim_accepted_usernames,
48+
reverse_code=migrations.RunPython.noop,
49+
),
50+
]

astra_app/core/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ class AccountInvitation(models.Model):
472472
dismissed_at = models.DateTimeField(blank=True, null=True)
473473
dismissed_by_username = models.CharField(max_length=255, blank=True, default="")
474474
accepted_at = models.DateTimeField(blank=True, null=True)
475+
accepted_username = models.CharField(max_length=255, blank=True, default="")
475476
freeipa_matched_usernames = models.JSONField(blank=True, default=list)
476477
freeipa_last_checked_at = models.DateTimeField(blank=True, null=True)
477478

@@ -490,6 +491,7 @@ def save(self, *args, **kwargs) -> None:
490491
self.invited_by_username = str(self.invited_by_username or "").strip()
491492
self.email_template_name = str(self.email_template_name or "").strip()
492493
self.dismissed_by_username = str(self.dismissed_by_username or "").strip()
494+
self.accepted_username = str(self.accepted_username or "").strip().lower()
493495
if not isinstance(self.freeipa_matched_usernames, list):
494496
self.freeipa_matched_usernames = []
495497
else:

astra_app/core/password_reset.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,25 @@ def password_reset_login_url(*, request: HttpRequest) -> str:
3333
return request.build_absolute_uri(reverse("login"))
3434

3535

36-
def send_password_reset_email(*, request: HttpRequest, username: str, email: str, last_password_change: str) -> None:
37-
token = make_signed_token(
38-
{
39-
"p": PASSWORD_RESET_TOKEN_PURPOSE,
40-
"u": username,
41-
"e": email,
42-
"lpc": last_password_change,
43-
}
44-
)
36+
def send_password_reset_email(
37+
*,
38+
request: HttpRequest,
39+
username: str,
40+
email: str,
41+
last_password_change: str,
42+
invitation_token: str | None = None,
43+
) -> None:
44+
token_payload: dict[str, str] = {
45+
"p": PASSWORD_RESET_TOKEN_PURPOSE,
46+
"u": username,
47+
"e": email,
48+
"lpc": last_password_change,
49+
}
50+
normalized_invitation_token = _normalize_str(invitation_token)
51+
if normalized_invitation_token:
52+
token_payload["i"] = normalized_invitation_token
53+
54+
token = make_signed_token(token_payload)
4555
reset_url = password_reset_confirm_url(request=request, token=token)
4656

4757
ttl_seconds = settings.PASSWORD_RESET_TOKEN_TTL_SECONDS

astra_app/core/templates/core/account_invitations.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,14 @@ <h1 class="m-0">Account Invitations</h1>
8989
<a href="{% url 'user-profile' username %}">{{ username }}</a>{% if not forloop.last %}, {% endif %}
9090
{% endfor %}
9191
</div>
92+
{% if invitation.accepted_username %}
93+
<div class="text-muted small">as <a href="{% url 'user-profile' invitation.accepted_username %}">{{ invitation.accepted_username }}</a></div>
94+
{% endif %}
9295
{% else %}
9396
Accepted
97+
{% if invitation.accepted_username %}
98+
<div class="text-muted small">as <a href="{% url 'user-profile' invitation.accepted_username %}">{{ invitation.accepted_username }}</a></div>
99+
{% endif %}
94100
{% endif %}
95101
</td>
96102
<td>{{ invitation.accepted_at }}</td>

astra_app/core/templates/core/login.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
<div class="card-header p-0 pt-1">
1414
<ul class="nav nav-tabs" role="tablist">
1515
<li class="nav-item">
16-
<a class="nav-link active" href="{% url 'login' %}">Login</a>
16+
<a class="nav-link active" href="{% url 'login' %}{% if request.GET.invite %}?invite={{ request.GET.invite|urlencode:'' }}{% endif %}">Login</a>
1717
</li>
1818
<li class="nav-item">
19-
<a class="nav-link" href="{% url 'register' %}">Register</a>
19+
<a class="nav-link" href="{% url 'register' %}{% if request.GET.invite %}?invite={{ request.GET.invite|urlencode:'' }}{% endif %}">Register</a>
2020
</li>
2121
</ul>
2222
</div>

astra_app/core/templates/core/register.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
<div class="card-header p-0 pt-1">
1414
<ul class="nav nav-tabs" role="tablist">
1515
<li class="nav-item">
16-
<a class="nav-link" href="{% url 'login' %}">Login</a>
16+
<a class="nav-link" href="{% url 'login' %}{% if request.GET.invite %}?invite={{ request.GET.invite|urlencode:'' }}{% endif %}">Login</a>
1717
</li>
1818
<li class="nav-item">
19-
<a class="nav-link active" href="{% url 'register' %}">Register</a>
19+
<a class="nav-link active" href="{% url 'register' %}{% if request.GET.invite %}?invite={{ request.GET.invite|urlencode:'' }}{% endif %}">Register</a>
2020
</li>
2121
</ul>
2222
</div>

astra_app/core/tests/test_account_invitation_reconcile.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
from types import SimpleNamespace
23
from unittest.mock import patch
34

45
from django.test import TestCase
@@ -56,9 +57,46 @@ def test_reconcile_account_invitation_for_username_is_idempotent_for_non_org(sel
5657

5758
invitation.refresh_from_db()
5859
self.assertEqual(invitation.accepted_at, first_now)
60+
self.assertEqual(invitation.accepted_username, "alice")
5961
self.assertEqual(invitation.freeipa_last_checked_at, second_now)
6062
self.assertEqual(invitation.freeipa_matched_usernames, ["alice", "zara"])
6163

64+
def test_reconcile_account_invitation_for_username_does_not_overwrite_existing_accepted_username(self) -> None:
65+
invitation = AccountInvitation.objects.create(
66+
email="invitee@example.com",
67+
full_name="Invitee",
68+
invited_by_username="committee",
69+
accepted_username="zara",
70+
)
71+
now = timezone.make_aware(datetime.datetime(2026, 2, 15, 10, 0, 0), datetime.UTC)
72+
73+
reconcile_account_invitation_for_username(invitation=invitation, username="alice", now=now)
74+
75+
invitation.refresh_from_db()
76+
self.assertEqual(invitation.accepted_username, "zara")
77+
self.assertEqual(invitation.accepted_at, now)
78+
self.assertEqual(invitation.freeipa_matched_usernames, ["alice"])
79+
80+
def test_reconcile_account_invitation_for_username_normalizes_username_to_lowercase(self) -> None:
81+
now = timezone.make_aware(datetime.datetime(2026, 2, 15, 10, 0, 0), datetime.UTC)
82+
invitation = SimpleNamespace(
83+
organization_id=None,
84+
accepted_at=None,
85+
accepted_username="",
86+
freeipa_matched_usernames=[],
87+
freeipa_last_checked_at=None,
88+
)
89+
90+
def _save(*, update_fields: list[str]) -> None:
91+
_ = update_fields
92+
93+
invitation.save = _save
94+
95+
reconcile_account_invitation_for_username(invitation=invitation, username="Alice", now=now)
96+
97+
self.assertEqual(invitation.accepted_username, "alice")
98+
self.assertIn("alice", invitation.freeipa_matched_usernames)
99+
62100
def test_reconcile_account_invitation_for_username_keeps_org_invitation_pending(self) -> None:
63101
organization = Organization.objects.create(
64102
name="Pending Claim Org",
@@ -76,5 +114,6 @@ def test_reconcile_account_invitation_for_username_keeps_org_invitation_pending(
76114

77115
invitation.refresh_from_db()
78116
self.assertIsNone(invitation.accepted_at)
117+
self.assertEqual(invitation.accepted_username, "")
79118
self.assertEqual(invitation.freeipa_last_checked_at, now)
80119
self.assertEqual(invitation.freeipa_matched_usernames, ["alice"])

astra_app/core/tests/test_account_invitations.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,71 @@ def test_account_invitations_list_shows_linked_organization_name_for_org_invites
777777
self.assertContains(response, "Visibility Org")
778778
self.assertContains(response, reverse("organization-detail", args=[organization.pk]))
779779

780+
def test_account_invitations_list_shows_accepted_username_link_for_accepted_invitation(self) -> None:
781+
self._login_as_freeipa_user("committee")
782+
783+
invitation = AccountInvitation.objects.create(
784+
email="accepted@example.com",
785+
full_name="Accepted User",
786+
note="",
787+
invited_by_username="committee",
788+
accepted_at=timezone.now(),
789+
accepted_username="accepteduser",
790+
freeipa_matched_usernames=["accepteduser"],
791+
)
792+
793+
with (
794+
patch("core.backends.FreeIPAUser.get", return_value=self._committee_user()),
795+
patch(
796+
"core.views_account_invitations.confirm_existing_usernames",
797+
return_value=(invitation.freeipa_matched_usernames, True),
798+
),
799+
):
800+
response = self.client.get(reverse("account-invitations"))
801+
802+
self.assertEqual(response.status_code, 200)
803+
self.assertContains(response, "Accepted")
804+
accepted_user_url = reverse("user-profile", kwargs={"username": "accepteduser"})
805+
self.assertContains(
806+
response,
807+
f'<div class="text-muted small">as <a href="{accepted_user_url}">accepteduser</a></div>',
808+
html=True,
809+
)
810+
811+
def test_account_invitations_list_shows_accepted_username_link_for_multiple_matches(self) -> None:
812+
self._login_as_freeipa_user("committee")
813+
814+
invitation = AccountInvitation.objects.create(
815+
email="multi@example.com",
816+
full_name="Matched User",
817+
note="",
818+
invited_by_username="committee",
819+
accepted_at=timezone.now(),
820+
accepted_username="accepteduser",
821+
freeipa_matched_usernames=["alphauser", "accepteduser"],
822+
)
823+
824+
with (
825+
patch("core.backends.FreeIPAUser.get", return_value=self._committee_user()),
826+
patch(
827+
"core.views_account_invitations.confirm_existing_usernames",
828+
return_value=(invitation.freeipa_matched_usernames, True),
829+
),
830+
):
831+
response = self.client.get(reverse("account-invitations"))
832+
833+
self.assertEqual(response.status_code, 200)
834+
self.assertContains(response, "Accepted (multiple matches)")
835+
self.assertContains(response, "alphauser")
836+
self.assertContains(response, "accepteduser")
837+
self.assertContains(response, reverse("user-profile", kwargs={"username": "alphauser"}))
838+
accepted_user_url = reverse("user-profile", kwargs={"username": "accepteduser"})
839+
self.assertContains(
840+
response,
841+
f'<div class="text-muted small">as <a href="{accepted_user_url}">accepteduser</a></div>',
842+
html=True,
843+
)
844+
780845
@override_settings(PUBLIC_BASE_URL="")
781846
def test_build_invitation_email_context_raises_when_public_base_url_missing(self) -> None:
782847
invitation = AccountInvitation.objects.create(

astra_app/core/tests/test_organization_user_views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,7 @@ def test_organization_claim_marks_linked_invitation_accepted(self) -> None:
917917
token = make_organization_claim_token(organization)
918918

919919
claimant = FreeIPAUser("claimant", {"uid": ["claimant"], "memberof_group": [], "c": ["US"]})
920-
self._login_as_freeipa_user("claimant")
920+
self._login_as_freeipa_user("ClaImAnt")
921921

922922
with (
923923
patch("core.backends.FreeIPAUser.get", return_value=claimant),
@@ -929,6 +929,7 @@ def test_organization_claim_marks_linked_invitation_accepted(self) -> None:
929929
self.assertEqual(response.status_code, 302)
930930
invitation.refresh_from_db()
931931
self.assertIsNotNone(invitation.accepted_at)
932+
self.assertEqual(invitation.accepted_username, "claimant")
932933

933934
def test_active_org_detail_hides_send_claim_invitation_link(self) -> None:
934935
from core.models import Organization

0 commit comments

Comments
 (0)