Skip to content

Commit 95b4c89

Browse files
committed
Implement account invitations
1 parent 70d7a5f commit 95b4c89

13 files changed

+1519
-3
lines changed

astra_app/config/settings.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,28 @@ def _parse_email_url(email_url: str) -> dict[str, Any]:
353353
default="membership-request-rfi",
354354
) or "membership-request-rfi"
355355

356+
357+
ACCOUNT_INVITATION_MAX_CSV_ROWS = _env_int("ACCOUNT_INVITATION_MAX_CSV_ROWS", default=500)
358+
ACCOUNT_INVITATION_MAX_UPLOAD_BYTES = _env_int(
359+
"ACCOUNT_INVITATION_MAX_UPLOAD_BYTES",
360+
default=2 * 1024 * 1024,
361+
)
362+
ACCOUNT_INVITATION_BULK_SEND_LIMIT = _env_int("ACCOUNT_INVITATION_BULK_SEND_LIMIT", default=10)
363+
ACCOUNT_INVITATION_BULK_SEND_WINDOW_SECONDS = _env_int(
364+
"ACCOUNT_INVITATION_BULK_SEND_WINDOW_SECONDS",
365+
default=60 * 10,
366+
)
367+
ACCOUNT_INVITATION_RESEND_LIMIT = _env_int("ACCOUNT_INVITATION_RESEND_LIMIT", default=10)
368+
ACCOUNT_INVITATION_RESEND_WINDOW_SECONDS = _env_int(
369+
"ACCOUNT_INVITATION_RESEND_WINDOW_SECONDS",
370+
default=60 * 10,
371+
)
372+
ACCOUNT_INVITATION_REFRESH_LIMIT = _env_int("ACCOUNT_INVITATION_REFRESH_LIMIT", default=10)
373+
ACCOUNT_INVITATION_REFRESH_WINDOW_SECONDS = _env_int(
374+
"ACCOUNT_INVITATION_REFRESH_WINDOW_SECONDS",
375+
default=60 * 10,
376+
)
377+
356378
MEMBERSHIP_COMMITTEE_PENDING_REQUESTS_EMAIL_TEMPLATE_NAME = _env_str(
357379
"MEMBERSHIP_COMMITTEE_PENDING_REQUESTS_EMAIL_TEMPLATE_NAME",
358380
default="membership-committee-pending-requests",
@@ -639,6 +661,10 @@ def _parse_email_url(email_url: str) -> dict[str, Any]:
639661
"ACCOUNT_INVITE_EMAIL_TEMPLATE_NAME",
640662
default="account-invite",
641663
) or "account-invite"
664+
ACCOUNT_INVITATION_EMAIL_TEMPLATE_NAMES = _env_list(
665+
"ACCOUNT_INVITATION_EMAIL_TEMPLATE_NAMES",
666+
default=[ACCOUNT_INVITE_EMAIL_TEMPLATE_NAME],
667+
)
642668

643669
# Map FreeIPA groups to Django permissions
644670
# Format: {'freeipa_group_name': {'app_label.permission_codename', ...}}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
import csv
4+
import io
5+
import logging
6+
from collections.abc import Callable
7+
8+
from django.core.exceptions import ValidationError
9+
from django.core.validators import validate_email
10+
11+
from core.backends import FreeIPAUser
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
def normalize_invitation_email(value: str) -> str:
17+
return str(value or "").strip().lower()
18+
19+
20+
def parse_invitation_csv(content: str, *, max_rows: int) -> list[dict[str, str]]:
21+
raw = str(content or "")
22+
if not raw.strip():
23+
raise ValueError("CSV is empty.")
24+
25+
sample = raw[:64 * 1024]
26+
try:
27+
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
28+
except Exception:
29+
dialect = csv.excel
30+
31+
reader = csv.reader(io.StringIO(raw), dialect)
32+
rows = [row for row in reader if any(str(cell or "").strip() for cell in row)]
33+
if not rows:
34+
raise ValueError("CSV is empty.")
35+
36+
header = ["".join(ch for ch in str(cell or "").strip().lower() if ch.isalnum()) for cell in rows[0]]
37+
38+
header_map: dict[int, str] = {}
39+
if "email" in header:
40+
for idx, name in enumerate(header):
41+
if name == "email":
42+
header_map[idx] = "email"
43+
elif name == "fullname":
44+
header_map[idx] = "full_name"
45+
elif name in {"note", "notes"}:
46+
header_map[idx] = "note"
47+
else:
48+
header_map = {0: "email", 1: "full_name", 2: "note"}
49+
50+
data_rows = rows[1:] if "email" in header else rows
51+
if max_rows > 0 and len(data_rows) > max_rows:
52+
raise ValueError(f"CSV exceeds the maximum of {max_rows} rows.")
53+
54+
parsed: list[dict[str, str]] = []
55+
for row in data_rows:
56+
entry: dict[str, str] = {"email": "", "full_name": "", "note": ""}
57+
for idx, key in header_map.items():
58+
if idx >= len(row):
59+
continue
60+
entry[key] = str(row[idx] or "").strip()
61+
parsed.append(entry)
62+
63+
return parsed
64+
65+
66+
def classify_invitation_rows(
67+
rows: list[dict[str, str]],
68+
*,
69+
existing_invitations: dict[str, object],
70+
freeipa_lookup: Callable[[str], list[str]],
71+
) -> tuple[list[dict[str, object]], dict[str, int]]:
72+
preview_rows: list[dict[str, object]] = []
73+
counts: dict[str, int] = {"new": 0, "resend": 0, "accepted": 0, "invalid": 0, "duplicate": 0}
74+
seen: set[str] = set()
75+
freeipa_cache: dict[str, list[str]] = {}
76+
77+
for row in rows:
78+
email_raw = str(row.get("email") or "")
79+
full_name = str(row.get("full_name") or "")
80+
note = str(row.get("note") or "")
81+
normalized = normalize_invitation_email(email_raw)
82+
83+
status = ""
84+
reason = ""
85+
matches: list[str] = []
86+
87+
if not normalized:
88+
status = "invalid"
89+
reason = "Missing email"
90+
elif normalized in seen:
91+
status = "duplicate"
92+
reason = "Duplicate email in upload"
93+
else:
94+
seen.add(normalized)
95+
try:
96+
validate_email(normalized)
97+
except ValidationError:
98+
status = "invalid"
99+
reason = "Invalid email"
100+
else:
101+
cached = freeipa_cache.get(normalized)
102+
if cached is None:
103+
cached = sorted(
104+
{str(u or "").strip().lower() for u in freeipa_lookup(normalized) if str(u or "").strip()}
105+
)
106+
freeipa_cache[normalized] = cached
107+
matches = cached
108+
109+
if matches:
110+
status = "accepted"
111+
else:
112+
existing = existing_invitations.get(normalized)
113+
status = "resend" if existing is not None else "new"
114+
115+
counts[status] = counts.get(status, 0) + 1
116+
preview_rows.append(
117+
{
118+
"email": normalized or email_raw,
119+
"full_name": full_name,
120+
"note": note,
121+
"status": status,
122+
"reason": reason,
123+
"freeipa_usernames": matches,
124+
"has_multiple_matches": len(matches) > 1,
125+
}
126+
)
127+
128+
return preview_rows, counts
129+
130+
131+
def find_account_invitation_matches(email: str) -> list[str]:
132+
normalized = normalize_invitation_email(email)
133+
if not normalized:
134+
return []
135+
136+
try:
137+
matches = FreeIPAUser.find_usernames_by_email(normalized)
138+
except Exception:
139+
logger.exception("Account invitation FreeIPA email lookup failed")
140+
return []
141+
142+
return sorted({str(value or "").strip().lower() for value in matches if str(value or "").strip()})

astra_app/core/backends.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,44 @@ def _do(client: ClientMeta):
781781
logger.exception(f"Failed to find user by email email={email}: {e}")
782782
return None
783783

784+
@classmethod
785+
def find_usernames_by_email(cls, email: str) -> list[str]:
786+
normalized = (email or "").strip().lower()
787+
if not normalized:
788+
return []
789+
790+
def _do(client: ClientMeta):
791+
return client.user_find(o_mail=normalized, o_all=True, o_no_members=False)
792+
793+
try:
794+
res = _with_freeipa_service_client_retry(cls.get_client, _do)
795+
except Exception:
796+
logger.exception("Failed to find users by email")
797+
return []
798+
799+
if not isinstance(res, dict) or res.get("count", 0) <= 0:
800+
return []
801+
802+
results = res.get("result")
803+
if not isinstance(results, list):
804+
return []
805+
806+
usernames: set[str] = set()
807+
for item in results:
808+
if not isinstance(item, dict):
809+
continue
810+
uid = item.get("uid")
811+
if isinstance(uid, list):
812+
values = uid
813+
else:
814+
values = [uid]
815+
for value in values:
816+
name = str(value or "").strip().lower()
817+
if name:
818+
usernames.add(name)
819+
820+
return sorted(usernames)
821+
784822
@classmethod
785823
def create(cls, username, **kwargs):
786824
"""
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
import django.db.models.deletion
4+
import django.utils.timezone
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("core", "0059_create_account_invite_email_template"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="AccountInvitation",
16+
fields=[
17+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
18+
("email", models.EmailField(max_length=254, unique=True)),
19+
("full_name", models.CharField(blank=True, default="", max_length=255)),
20+
("note", models.TextField(blank=True, default="")),
21+
("email_template_name", models.CharField(blank=True, default="", max_length=255)),
22+
("invited_by_username", models.CharField(max_length=255)),
23+
("invited_at", models.DateTimeField(auto_now_add=True)),
24+
("last_sent_at", models.DateTimeField(blank=True, null=True)),
25+
("send_count", models.PositiveIntegerField(default=0)),
26+
("dismissed_at", models.DateTimeField(blank=True, null=True)),
27+
("dismissed_by_username", models.CharField(blank=True, default="", max_length=255)),
28+
("accepted_at", models.DateTimeField(blank=True, null=True)),
29+
("freeipa_matched_usernames", models.JSONField(blank=True, default=list)),
30+
("freeipa_last_checked_at", models.DateTimeField(blank=True, null=True)),
31+
],
32+
options={
33+
"ordering": ("-invited_at", "email"),
34+
},
35+
),
36+
migrations.CreateModel(
37+
name="AccountInvitationSend",
38+
fields=[
39+
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
40+
("sent_by_username", models.CharField(max_length=255)),
41+
("sent_at", models.DateTimeField(default=django.utils.timezone.now)),
42+
("template_name", models.CharField(max_length=255)),
43+
("post_office_email_id", models.BigIntegerField(blank=True, null=True)),
44+
(
45+
"result",
46+
models.CharField(
47+
choices=[("queued", "Queued"), ("failed", "Failed")],
48+
max_length=16,
49+
),
50+
),
51+
("error_category", models.CharField(blank=True, default="", max_length=64)),
52+
(
53+
"invitation",
54+
models.ForeignKey(
55+
on_delete=django.db.models.deletion.CASCADE,
56+
related_name="sends",
57+
to="core.accountinvitation",
58+
),
59+
),
60+
],
61+
),
62+
migrations.AddIndex(
63+
model_name="accountinvitation",
64+
index=models.Index(fields=["accepted_at"], name="acct_inv_accept_at"),
65+
),
66+
migrations.AddIndex(
67+
model_name="accountinvitation",
68+
index=models.Index(fields=["dismissed_at"], name="acct_inv_dismiss_at"),
69+
),
70+
migrations.AddIndex(
71+
model_name="accountinvitationsend",
72+
index=models.Index(fields=["sent_at"], name="acct_inv_send_at"),
73+
),
74+
]

0 commit comments

Comments
 (0)