Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
bb10b9c
Add `bio` field to `accounts.Profile` model
ertgl Oct 19, 2025
2c29158
Apply black to migrations
ertgl Oct 19, 2025
093ed87
Add a nullable one-to-one `user` field linking `IndividualMember` to …
ertgl Oct 19, 2025
8b918c0
Display `IndividualMember` names as links to their profiles when asso…
ertgl Oct 20, 2025
6e90cd5
Fix vertical split of profile name caused by floated image
ertgl Oct 20, 2025
6a2322d
Make the `user` field on `IndividualMemberAdmin` auto-completable
ertgl Oct 20, 2025
a55a0f5
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 20, 2025
9a180f1
Update `UserProfileTests.test_username_is_page_title` to reflect temp…
ertgl Oct 20, 2025
a352eda
Add profile link tests for current and former individual members
ertgl Oct 20, 2025
36b3f90
Add admin action to send account invite mail to individual members
ertgl Oct 21, 2025
b7ad21f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 21, 2025
da706a4
Fix Flake8 E501 (line too long) errors
ertgl Oct 21, 2025
d4d8873
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 21, 2025
b918122
Introduce several improvements (please see the commit message for det…
ertgl Oct 22, 2025
e46a504
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 22, 2025
f1a4781
Fix `ngettext` usages to correctly handle singular forms for all lang…
ertgl Oct 22, 2025
20117b6
Add management command `send_individual_member_account_invite_mails`
ertgl Oct 22, 2025
84c0826
Add management command `link_individual_members_to_users_by_email`
ertgl Oct 22, 2025
bc0467e
Add migration that links individual members to users by email
ertgl Oct 22, 2025
7365e8b
Add tests for displaying user bio in profile
ertgl Oct 22, 2025
46cb8cd
Convert `contrib.django.forms` module into an app for testing
ertgl Oct 22, 2025
a69e9e1
Add note to describe why we need value normalization in the `BoundFie…
ertgl Oct 22, 2025
85c211d
Add tests for `BoundFieldWithCharacterCounter` class
ertgl Oct 22, 2025
65950f7
Add tests for `edit_profile` view
ertgl Oct 22, 2025
9fc7fd1
Add test for `IndividualMember.match_and_set_users_by_email` classmethod
ertgl Oct 23, 2025
bb963f6
Add test for `IndividualMember.send_account_invite_mails` classmethod
ertgl Oct 23, 2025
2588d94
Add test for `IndividualMember.send_account_invite_mail` method
ertgl Oct 23, 2025
94ba067
Fix Flake8 F541 (f-string is missing placeholders) error
ertgl Oct 23, 2025
81d7137
Add test to verify `IndividualMember.send_account_invite_mails` preve…
ertgl Oct 23, 2025
db9a7dd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 23, 2025
aa9d1cb
Reduce resource usage of some tests
ertgl Oct 23, 2025
f37e3f8
Add note to account-invite mail about matching GitHub username for Tr…
ertgl Oct 23, 2025
ef54d5b
Update subject of Individual Membership account-invite mail
ertgl Oct 23, 2025
b956bfb
Introduce `get_trac_username` function (please see the commit message…
ertgl Oct 23, 2025
f922710
Rename `get_trac_username` to `get_user_trac_username`
ertgl Oct 23, 2025
a0f2d47
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 23, 2025
b91a2a0
Fix Flake8 F401 (imported but unused) error
ertgl Oct 23, 2025
26eed00
Reduce resource usage of `IndividualMemberTransactionTests.test_send_…
ertgl Oct 23, 2025
c9cc83e
Add tests to ensure overriding user's Trac username works for every t…
ertgl Oct 23, 2025
a8984d5
Remove the 'noreply' sender address from the individual member accoun…
ertgl Oct 23, 2025
4725cfa
Format code
ertgl Oct 23, 2025
5b67032
Add tests for management command `send_individual_member_account_invi…
ertgl Oct 23, 2025
a42b481
Remove unnecessary test `test_trac_username_overrides_user_username`
ertgl Oct 24, 2025
58ed3bc
Fix test `BoundFieldWithCharacterCounterTests.test_characters_remaini…
ertgl Oct 24, 2025
29c77ed
Prevent rendering Trac stats for a user when the username is used by …
ertgl Oct 24, 2025
e13bd18
Fix Flake8 F401 (imported but unused) error
ertgl Oct 24, 2025
f314007
Rename `check_if_trac_username_is_overridden_for_another_user` to `ch…
ertgl Oct 24, 2025
86d44c4
Move code block inside `if` to prevent unnecessary execution
ertgl Oct 24, 2025
c3ae853
Add tests for `ProfileAdminForm`
ertgl Nov 7, 2025
38f3ca3
Prevent unnecessary decrease of test coverage rate in the `Individual…
ertgl Nov 7, 2025
ea1ee73
Add tests for the management command `send_individual_member_account_…
ertgl Nov 7, 2025
6b0d2c3
Add tests for `IndividualMemberAdmin`
ertgl Nov 7, 2025
dd4e5d8
Add temporary tests for the migration `members.0012`
ertgl Nov 7, 2025
511c922
Add tests for the management command `link_individual_members_to_user…
ertgl Nov 7, 2025
32a44ed
Improve tests for the management command `send_individual_member_acco…
ertgl Nov 7, 2025
e77cf23
Use fixed db records number instead of randint in some tests
ertgl Nov 7, 2025
c50371e
Add note that the migration test can be removed after deployment
ertgl Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ class ProfileForm(forms.ModelForm):
email = forms.EmailField(
required=False, widget=forms.TextInput(attrs={"placeholder": _("Email")})
)
bio = forms.CharField(
required=False, widget=forms.Textarea(attrs={"placeholder": _("Bio")})
)

class Meta:
model = Profile
fields = ["name"]
fields = ["name", "bio"]

def __init__(self, *args, **kwargs):
instance = kwargs.get("instance", None)
Expand Down
18 changes: 18 additions & 0 deletions accounts/migrations/0003_profile_bio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-10-19 01:39

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("accounts", "0002_migrate_sha1_passwords"),
]

operations = [
migrations.AddField(
model_name="profile",
name="bio",
field=models.TextField(blank=True),
),
]
1 change: 1 addition & 0 deletions accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
name = models.CharField(max_length=200, blank=True)
bio = models.TextField(blank=True)

def __str__(self):
return self.name or str(self.user)
2 changes: 1 addition & 1 deletion accounts/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def setUpTestData(cls):

def test_username_is_page_title(self):
response = self.client.get(self.user1_url)
self.assertContains(response, "<h1>user1</h1>", html=True)
self.assertContains(response, '<h1 class="name">user1</h1>', html=True)

def test_stat_commits(self):
Revision.objects.create(
Expand Down
34 changes: 33 additions & 1 deletion djangoproject/scss/_style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3189,9 +3189,41 @@ hr {
}

.user-info {
// Keep this value in sync with the width value of the img element in the HTML.
$user_info_avatar_img_width: 150px;

.avatar {
@include framed-image();
float: right;
margin: 4rem 1rem 0rem 1rem;

// Using `float: right` causes the image to separate words vertically by its own height on some screen sizes.
@include respond-min(811px) {
float: right;
margin: 4rem 1rem 1rem 1rem;
}
}

.name {
margin-bottom: 0;

@include respond-min(811px) {
// These rules prevents the unwanted vertical space between words as described in the `.avatar` class.
display: inline-block;
max-width: calc(100% - $user_info_avatar_img_width);
}

@include respond-max(810px) {
// Reposition the name when it's below the image on small screens.
margin-top: 2rem;
}
}

.bio {
@include font-size(16);
font-weight: normal;
line-height: 1.5;
white-space: pre-line;
margin-top: 0;
}
}

Expand Down
2 changes: 2 additions & 0 deletions djangoproject/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@

DEFAULT_FROM_EMAIL = "noreply@djangoproject.com"
FUNDRAISING_DEFAULT_FROM_EMAIL = "fundraising@djangoproject.com"
# TODO(ertgl): Update `settings.INDIVIDUAL_MEMBER_ACCOUNT_INVITE_DEFAULT_FROM_EMAIL`.
INDIVIDUAL_MEMBER_ACCOUNT_INVITE_DEFAULT_FROM_EMAIL = DEFAULT_FROM_EMAIL

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

Expand Down
7 changes: 7 additions & 0 deletions djangoproject/templates/accounts/edit_profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ <h1>{% translate "Edit your profile" %}</h1>
<p>{{ form.email }}</p>
</div>

<div>
{% if form.bio.errors %}
<p class="errors">{{ form.bio.errors.as_text }}</p>
{% endif %}
<p>{{ form.bio }}</p>
</div>

<div class="submit">
<input class="cta" type="submit" value="{% translate "Save" %}"/>
</div>
Expand Down
10 changes: 8 additions & 2 deletions djangoproject/templates/accounts/user_profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ <h2 id="aside-header">

<p>{% translate "Need to edit something? Here's how:" %}</p>
<ul>
<li><a href="{% url 'edit_profile' %}">{% translate "Edit your name and email here." %}</a></li>
<li><a href="{% url 'edit_profile' %}">{% translate "Edit your name, email and bio here." %}</a></li>
<li>{% blocktranslate trimmed %}
The image is the <a href="https://en.gravatar.com/">Gravatar</a> linked
to the email address you signed up with. You can change the image over
Expand All @@ -37,10 +37,16 @@ <h2 id="aside-header">
<img class='avatar' width='150' height='150'
src="https://secure.gravatar.com/avatar/{{ email_hash }}?s=150&amp;d=https%3A%2F%2Frobohash.org%2F{{ email_hash }}%3Fset%3Dset3%26size%3D150x150">

<h1>
<h1 class="name">
{% firstof user_obj.profile.name user_obj.username %}
</h1>

{% if user_obj.profile.bio %}
<p class="bio">
{{ user_obj.profile.bio }}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think given you wanted to add links it might be nice to use urlize here. I also think we should have a character limit on the bio, something like 3,000 characters 🤔
A different thought was whether we would let folks use markdown but maybe let's keep it simple

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that using urlize would be nice. So, both that this PR wouldn't get bigger and you can start collecting feedback from members sooner and decide later whether to support Markdown. If my contribution would be helpful, I'd be happy to implement it as well at any time.

Copy link
Author

@ertgl ertgl Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been done, but let's not mark it as resolved yet. I'll add tests for this.

Screen.Recording.2025-10-22.at.03.44.29.mov

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. I've added tests for this (hopefully everything is covered).

</p>
{% endif %}

{% if stats %}
<h2>{% translate "Statistics on Django core contributions:" %}</h2>
<ul>
Expand Down
17 changes: 17 additions & 0 deletions djangoproject/templates/members/email/account_invite.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% spaceless %}
Hello {{ name }},

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.

To have your name linked, please create an account (if you don't already have one) at:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's worth mentioning that their account name should match their github username if they use their github account for Trac. This let's us show stats about their contributions in their profile page

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, thank you! That's something I wasn't aware of. I'll work on it. Additionally, I've checked the code and I'm thinking that we could provide another optional input to let user override the parameter passed to trac_stats.get_user_stats(username) function (also by displaying the username that overrides the user.username, on the page). Do you think these would be useful?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure as I guess folks could try to claim the stats of others as their own 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, displaying the username on the page wouldn't be enough. What if we put the input to the admin only instead, and mention in the email that if they have a mismatch they can request a fix?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure that's an idea 👍

Copy link
Author

@ertgl ertgl Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've done the following:

  1. Add trac_username field to Profile.
  2. Add ProfileAdmin class to allow overriding Trac username for a specific user (for retrieving stats).
  3. Add a utility function get_user_trac_username, to be used as a single source of truth.
  4. Refactor the profile view to use the function for retrieving the stats.
  5. Add test to verify that usernames can be overridden.
  6. Add another test to ensure trac_username field is not included by the publicly used ProfileForm.
  7. Add note about usernames to the email (but I didn't mention yet that it can be overridden when needed).

Additional screenshots:

Image 12: Profiles - admin page Screenshot 2025-10-23 at 09 11 04
Image 13: Update profile - admin page Screenshot 2025-10-23 at 09 11 22

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've refactored the profile view to prevent rendering false Trac stats in certain situations. Here's the step-by-step details:

  1. If the user has a trac_username set, this indicates a verified username, so retrieve and render the stats for that specific trac_username.
  2. Otherwise, check if the user's username is used as trac_username by another user. If so, prevent rendering the stats.
  3. Otherwise, retrieve and render the stats for the user's username.
  4. All in a cached way.

Since Profile.trac_username field is not unique, this can also tolerate rare/temporal cases where multiple accounts belong to the same person and share the same trac_username.

Please feel free to let me know if anything should be handled differently.


{% url 'registration_register' %}

Once your account is ready, let us know your username so we can connect it to the members list.

This small step helps the community grow more connected and makes it easier for everyone to explore and engage with it.

Thank you for being part of the Django community.

Django Software Foundation
{% endspaceless %}
17 changes: 15 additions & 2 deletions djangoproject/templates/members/individualmember_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,13 @@ <h1>{% translate "Individual members" %}</h1>

<ul>
{% for member in members %}
<li>{{ member.name }}</li>
<li>
{% if member.user %}
<a href="{% url 'user_profile' username=member.user.username %}">{{ member.name }}</a>
{% else %}
{{ member.name }}
{% endif %}
</li>
{% endfor %}
</ul>

Expand All @@ -87,7 +93,14 @@ <h2>{% translate "Former members" %}</h2>

<ul>
{% for member in former_members %}
<li>{{ member.name }}{% if member.reason_for_leaving %} ({{ member.reason_for_leaving }}){% endif %}</li>
<li>
{% if member.user %}
<a href="{% url 'user_profile' username=member.user.username %}">{{ member.name }}</a>
{% else %}
{{ member.name }}
{% endif %}
{% if member.reason_for_leaving %}({{ member.reason_for_leaving }}){% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
Expand Down
83 changes: 80 additions & 3 deletions members/admin.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
from datetime import date, timedelta

from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.auth import get_permission_codename
from django.db import transaction
from django.templatetags.static import static
from django.utils.formats import localize
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, ngettext

from members.models import CorporateMember, IndividualMember, Invoice, Team
from members.models import (
CorporateMember,
IndividualMember,
IndividualMemberAccountInviteSendMailStatus,
Invoice,
Team,
)


@admin.register(IndividualMember)
Expand All @@ -17,8 +25,77 @@ class IndividualMemberAdmin(admin.ModelAdmin):
"is_active",
"member_since",
"member_until",
"account_invite_mail_sent_at",
]
search_fields = ["name"]
list_filter = ["member_until", "account_invite_mail_sent_at"]
autocomplete_fields = ["user"]
actions = ["send_account_invite_mail"]

@admin.action(
description=_("Send account invite mail to selected individual members"),
permissions=["send_account_invite_mail"],
)
def send_account_invite_mail(self, request, queryset):
with transaction.atomic():
results = IndividualMember.send_account_invite_mails(queryset)
sent_count = results.get(
IndividualMemberAccountInviteSendMailStatus.SENT,
0,
)
failed_count = results.get(
IndividualMemberAccountInviteSendMailStatus.FAILED,
0,
)
skipped_count = results.get(
IndividualMemberAccountInviteSendMailStatus.SKIPPED,
0,
)
if sent_count > 0:
self.message_user(
request,
ngettext(
"Sent account invite mail to 1 individual member.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a quick drive-by comment to say that:

  1. I love this PR, thanks so much for working on it! 💚
  2. When using ngettext, you should still be using %(count)d in the first argument (this is because some languages use the singular form for amounts other than 1):
Suggested change
"Sent account invite mail to 1 individual member.",
"Sent account invite mail to %(count)d individual member.",

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right! I missed that detail, and I'll fix it accordingly. Thanks so much for catching it and for the kind words. 💚

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK! I've fixed it.

"Sent account invite mail to %(count)d individual members.",
sent_count,
)
% {"count": sent_count},
messages.SUCCESS,
)
if failed_count > 0:
self.message_user(
request,
ngettext(
"Failed to send account invite mail to 1 individual member.",
"Failed to send account invite mail to %(count)d individual members.",
failed_count,
)
% {"count": failed_count},
messages.ERROR,
)
if skipped_count > 0:
self.message_user(
request,
ngettext(
(
"Skipped sending account invite mail to 1 individual member"
" (already has an account linked or an invite mail has been"
" sent)."
),
(
"Skipped sending account invite mail to %(count)d individual"
" members (already have accounts linked or invite mails have"
" been sent)."
),
skipped_count,
)
% {"count": skipped_count},
messages.INFO,
)

def has_send_account_invite_mail_permission(self, request):
codename = get_permission_codename("send_account_invite_mail", self.opts)
return request.user.has_perm(f"{self.opts.app_label}.{codename}")


class InvoiceInline(admin.TabularInline):
Expand Down
26 changes: 26 additions & 0 deletions members/migrations/0010_individualmember_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 5.2.7 on 2025-10-19 18:16

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("members", "0009_alter_individualmember_add_reason_help_text"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddField(
model_name="individualmember",
name="user",
field=models.OneToOneField(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As an idea, we could have a custom migration which links existing IndividualMember and User based off matching emails 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's done too. I also made it reusable to handle occasional stale states by adding a management command.

blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
]
35 changes: 35 additions & 0 deletions members/migrations/0011_alter_individualmember_options_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 5.2.7 on 2025-10-20 19:04

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("members", "0010_individualmember_user"),
]

operations = [
migrations.AlterModelOptions(
name="individualmember",
options={
"ordering": ["name"],
"permissions": [
(
"send_account_invite_mail",
"Can send account invite mail to an individual member",
)
],
},
),
migrations.AddField(
model_name="individualmember",
name="account_invite_mail_sent_at",
field=models.DateTimeField(blank=True, db_index=True, null=True),
),
migrations.AlterField(
model_name="individualmember",
name="member_until",
field=models.DateField(blank=True, db_index=True, null=True),
),
]
Loading