Skip to content

Commit 36b3f90

Browse files
committed
Add admin action to send account invite mail to individual members
1 parent a352eda commit 36b3f90

File tree

5 files changed

+188
-5
lines changed

5 files changed

+188
-5
lines changed

djangoproject/settings/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353

5454
DEFAULT_FROM_EMAIL = "noreply@djangoproject.com"
5555
FUNDRAISING_DEFAULT_FROM_EMAIL = "fundraising@djangoproject.com"
56+
# TODO(ertgl): Update `settings.INDIVIDUAL_MEMBER_ACCOUNT_INVITE_DEFAULT_FROM_EMAIL`.
57+
INDIVIDUAL_MEMBER_ACCOUNT_INVITE_DEFAULT_FROM_EMAIL = DEFAULT_FROM_EMAIL
5658

5759
FIXTURE_DIRS = [str(PROJECT_PACKAGE.joinpath("fixtures"))]
5860

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{% spaceless %}
2+
Hello {{ name }},
3+
4+
We're updating the Django Individual Members list so each member's name can link to their djangoproject.com profile. On your profile, you can share a short bio, highlight your contributions to Django or the community, and provide ways for others to get in touch.
5+
6+
To have your name linked, please create an account (if you don't already have one) at:
7+
8+
{% url 'registration_register' %}
9+
10+
Once your account is ready, let us know your username so we can connect it to the members list.
11+
12+
This small step helps the community grow more connected and makes it easier for everyone to explore and engage with it.
13+
14+
Thank you for being part of the Django community.
15+
16+
Django Software Foundation
17+
{% endspaceless %}

members/admin.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
from datetime import date, timedelta
22

3-
from django.contrib import admin
3+
from django.contrib import admin, messages
4+
from django.contrib.auth import get_permission_codename
5+
from django.db import transaction
46
from django.templatetags.static import static
57
from django.utils.formats import localize
68
from django.utils.html import format_html
7-
from django.utils.translation import gettext_lazy as _
9+
from django.utils.translation import gettext_lazy as _, ngettext
810

9-
from members.models import CorporateMember, IndividualMember, Invoice, Team
11+
from members.models import (
12+
CorporateMember,
13+
IndividualMember,
14+
IndividualMemberAccountInviteSendMailStatus,
15+
Invoice,
16+
Team,
17+
)
1018

1119

1220
@admin.register(IndividualMember)
@@ -17,9 +25,69 @@ class IndividualMemberAdmin(admin.ModelAdmin):
1725
"is_active",
1826
"member_since",
1927
"member_until",
28+
"account_invite_mail_sent_at",
2029
]
2130
search_fields = ["name"]
31+
list_filter = ["member_until", "account_invite_mail_sent_at"]
2232
autocomplete_fields = ["user"]
33+
actions = ["send_account_invite_mail"]
34+
35+
@admin.action(
36+
description=_("Send account invite mail to selected individual members"),
37+
permissions=["send_account_invite_mail"],
38+
)
39+
def send_account_invite_mail(self, request, queryset):
40+
with transaction.atomic():
41+
results = IndividualMember.send_account_invite_mails(queryset)
42+
sent_count = results.get(
43+
IndividualMemberAccountInviteSendMailStatus.SENT,
44+
0,
45+
)
46+
failed_count = results.get(
47+
IndividualMemberAccountInviteSendMailStatus.FAILED,
48+
0,
49+
)
50+
skipped_count = results.get(
51+
IndividualMemberAccountInviteSendMailStatus.SKIPPED,
52+
0,
53+
)
54+
if sent_count > 0:
55+
self.message_user(
56+
request,
57+
ngettext(
58+
"Sent account invite mail to 1 individual member.",
59+
"Sent account invite mail to %(count)d individual members.",
60+
sent_count,
61+
)
62+
% {"count": sent_count},
63+
messages.SUCCESS,
64+
)
65+
if failed_count > 0:
66+
self.message_user(
67+
request,
68+
ngettext(
69+
"Failed to send account invite mail to 1 individual member.",
70+
"Failed to send account invite mail to %(count)d individual members.",
71+
failed_count,
72+
)
73+
% {"count": failed_count},
74+
messages.ERROR,
75+
)
76+
if skipped_count > 0:
77+
self.message_user(
78+
request,
79+
ngettext(
80+
"Skipped sending account invite mail to 1 individual member (already has an account linked or an invite mail has been sent).",
81+
"Skipped sending account invite mail to %(count)d individual members (already have accounts linked or invite mails have been sent).",
82+
skipped_count,
83+
)
84+
% {"count": skipped_count},
85+
messages.INFO,
86+
)
87+
88+
def has_send_account_invite_mail_permission(self, request):
89+
codename = get_permission_codename("send_account_invite_mail", self.opts)
90+
return request.user.has_perm("%s.%s" % (self.opts.app_label, codename))
2391

2492

2593
class InvoiceInline(admin.TabularInline):
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 5.2.7 on 2025-10-20 19:04
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("members", "0010_individualmember_user"),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name="individualmember",
15+
options={
16+
"ordering": ["name"],
17+
"permissions": [
18+
(
19+
"send_account_invite_mail",
20+
"Can send account invite mail to an individual member",
21+
)
22+
],
23+
},
24+
),
25+
migrations.AddField(
26+
model_name="individualmember",
27+
name="account_invite_mail_sent_at",
28+
field=models.DateTimeField(blank=True, db_index=True, null=True),
29+
),
30+
migrations.AlterField(
31+
model_name="individualmember",
32+
name="member_until",
33+
field=models.DateField(blank=True, db_index=True, null=True),
34+
),
35+
]

members/models.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
from collections import defaultdict
2+
from enum import StrEnum
23

34
from django.conf import settings
45
from django.core import signing
5-
from django.db import models
6+
from django.core.mail import send_mail
7+
from django.db import models, transaction
68
from django.db.models.signals import post_save
79
from django.dispatch import receiver
10+
from django.template.loader import render_to_string
811
from django.utils.safestring import mark_safe
12+
from django.utils.timezone import now as timezone_now
913
from django.utils.translation import gettext_lazy as _
1014
from django.views.generic.dates import timezone_today
1115
from django_hosts import reverse
@@ -38,6 +42,12 @@
3842
MEMBERSHIP_TO_KEY = {k: v.lower() for k, v in MEMBERSHIP_LEVELS}
3943

4044

45+
class IndividualMemberAccountInviteSendMailStatus(StrEnum):
46+
SENT = "SENT"
47+
FAILED = "FAILED"
48+
SKIPPED = "SKIPPED"
49+
50+
4151
class IndividualMember(models.Model):
4252
user = models.OneToOneField(
4353
settings.AUTH_USER_MODEL,
@@ -48,7 +58,7 @@ class IndividualMember(models.Model):
4858
name = models.CharField(max_length=250)
4959
email = models.EmailField(unique=True)
5060
member_since = models.DateField(default=timezone_today)
51-
member_until = models.DateField(null=True, blank=True)
61+
member_until = models.DateField(null=True, blank=True, db_index=True)
5262
reason_for_leaving = models.TextField(
5363
blank=True,
5464
help_text=mark_safe(
@@ -58,9 +68,33 @@ class IndividualMember(models.Model):
5868
)
5969
),
6070
)
71+
account_invite_mail_sent_at = models.DateTimeField(
72+
blank=True,
73+
null=True,
74+
db_index=True,
75+
)
6176

6277
class Meta:
6378
ordering = ["name"]
79+
permissions = [
80+
(
81+
"send_account_invite_mail",
82+
_("Can send account invite mail to an individual member"),
83+
),
84+
]
85+
86+
@classmethod
87+
def send_account_invite_mails(cls, queryset):
88+
results = {}
89+
# Wait for other active transactions to prevent race conditions.
90+
queryset = queryset.select_for_update(nowait=False)
91+
for individual_member in queryset.iterator():
92+
status = individual_member.send_account_invite_mail()
93+
if status not in results:
94+
results[status] = 1
95+
else:
96+
results[status] += 1
97+
return results
6498

6599
def __str__(self):
66100
return self.name
@@ -69,6 +103,33 @@ def __str__(self):
69103
def is_active(self):
70104
return self.member_until is None
71105

106+
def send_account_invite_mail(self):
107+
if self.user_id or self.account_invite_mail_sent_at:
108+
return IndividualMemberAccountInviteSendMailStatus.SKIPPED
109+
mail_subject = "Make your Django Individual Membership profile visible"
110+
mail_content = render_to_string(
111+
"members/email/account_invite.txt",
112+
{
113+
"name": self.name,
114+
},
115+
)
116+
# Create an inner savepoint to prevent any outer successful operation to be rolled back in case of failure here.
117+
with transaction.atomic():
118+
sent = send_mail(
119+
mail_subject,
120+
mail_content,
121+
settings.INDIVIDUAL_MEMBER_ACCOUNT_INVITE_DEFAULT_FROM_EMAIL,
122+
[
123+
settings.INDIVIDUAL_MEMBER_ACCOUNT_INVITE_DEFAULT_FROM_EMAIL,
124+
self.email,
125+
],
126+
)
127+
if sent:
128+
self.account_invite_mail_sent_at = timezone_now()
129+
self.save()
130+
return IndividualMemberAccountInviteSendMailStatus.SENT
131+
return IndividualMemberAccountInviteSendMailStatus.FAILED
132+
72133

73134
class Team(models.Model):
74135
name = models.CharField(max_length=250)

0 commit comments

Comments
 (0)