Skip to content
Open
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
48 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
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.PHONY: all ci clean collectstatics compile-scss compile-scss-debug install run test watch-scss

APP_LIST ?= accounts aggregator blog contact dashboard djangoproject docs foundation fundraising legacy members releases svntogit tracdb
APP_LIST ?= accounts aggregator blog contact contrib.django.forms dashboard djangoproject docs foundation fundraising legacy members releases svntogit tracdb
SCSS = djangoproject/scss
STATIC = djangoproject/static

Expand Down
31 changes: 31 additions & 0 deletions accounts/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django import forms
from django.contrib import admin

from .forms import ProfileForm
from .models import Profile


class ProfileAdminForm(forms.ModelForm):
class Meta:
model = Profile
fields = "__all__"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["bio"].widget.attrs["maxlength"] = ProfileForm.base_fields[
"bio"
].max_length
self.fields["bio"].help_text = ProfileForm.base_fields["bio"].help_text


@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
list_display = [
"user__username",
"name",
"trac_username",
]
list_select_related = ["user"]
search_fields = ["user__username", "name", "trac_username"]
form = ProfileAdminForm
autocomplete_fields = ["user"]
13 changes: 12 additions & 1 deletion accounts/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from django.db.models import ProtectedError
from django.utils.translation import gettext_lazy as _

from contrib.django.forms.boundfields import BoundFieldWithCharacterCounter

from .models import Profile


Expand All @@ -20,10 +22,19 @@ class ProfileForm(forms.ModelForm):
email = forms.EmailField(
required=False, widget=forms.TextInput(attrs={"placeholder": _("Email")})
)
bio = forms.CharField(
bound_field_class=BoundFieldWithCharacterCounter,
required=False,
max_length=3_000,
widget=forms.Textarea(attrs={"placeholder": _("Bio")}),
help_text=_(
"URLs and email addresses are automatically converted into clickable links.",
),
)

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),
),
]
20 changes: 20 additions & 0 deletions accounts/migrations/0004_profile_trac_username.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2025-10-22 23:07

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("accounts", "0003_profile_bio"),
]

operations = [
migrations.AddField(
model_name="profile",
name="trac_username",
field=models.CharField(
blank=True, db_index=True, default="", max_length=150
),
),
]
8 changes: 8 additions & 0 deletions accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
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)
trac_username = models.CharField(
max_length=150,
blank=True,
null=False,
default="",
db_index=True,
)

def __str__(self):
return self.name or str(self.user)
147 changes: 136 additions & 11 deletions accounts/tests.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,107 @@
import hashlib
from random import randint

from django.contrib.auth.models import AnonymousUser, User
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.test import RequestFactory, TestCase, override_settings
from django_hosts.resolvers import reverse

from accounts.forms import DeleteProfileForm
from accounts.models import Profile
from foundation import models as foundationmodels
from tracdb.models import Revision, Ticket, TicketChange
from tracdb.testutils import TracDBCreateDatabaseMixin

from .forms import ProfileForm
from .views import edit_profile


@override_settings(TRAC_URL="https://code.djangoproject.com/")
class UserProfileTests(TracDBCreateDatabaseMixin, TestCase):
databases = {"default", "trac"}

@classmethod
def setUpTestData(cls):
User.objects.create_user(username="user1", password="password")
User.objects.create_user(username="user2", password="password")
user1_bio = "\n".join(
[
"[pre]",
"\n",
"Email: [email protected]",
"Website: user1.example.com",
"GitHub: https://github.com/ghost",
"\n",
"[post]",
],
)
user2_bio = ""
user1 = User.objects.create_user(username="user1", password="password")
user2 = User.objects.create_user(username="user2", password="password")
Profile.objects.create(user=user1, bio=user1_bio)
Profile.objects.create(user=user2, bio=user2_bio)
cls.user1_url = reverse("user_profile", args=["user1"])
cls.user2_url = reverse("user_profile", args=["user2"])

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_page_displays_bio_when_present(self):
response = self.client.get(self.user1_url)
self.assertContains(response, '<p class="bio">')

def test_page_hides_bio_when_absent(self):
response = self.client.get(self.user2_url)
self.assertNotContains(response, '<p class="bio">')

def test_bio_contains_mail_addresses_clickable(self):
response = self.client.get(self.user1_url)
self.assertContains(
response,
'<a href="mailto:[email protected]">[email protected]</a>',
html=True,
)

def test_bio_contains_links_without_protocol_clickable(self):
response = self.client.get(self.user1_url)
self.assertContains(
response,
(
'<a href="http://user1.example.com" rel="nofollow">'
"user1.example.com</a>"
),
html=True,
)

def test_bio_contains_links_with_protocol_clickable(self):
response = self.client.get(self.user1_url)
self.assertContains(
response,
(
'<a href="https://github.com/ghost" rel="nofollow">'
"https://github.com/ghost</a>"
),
html=True,
)

def test_trac_username_overrides_user_username(self):
djangoproject_username = "djangoproject_user"
trac_username = "trac_user"
user = User.objects.create_user(username=djangoproject_username)
Profile.objects.create(user=user, trac_username=trac_username)
Revision.objects.create(
author=trac_username,
rev="91c879eda595c12477bbfa6f51115e88b75ddf88",
_time=1731669560,
)

user_profile_url = reverse("user_profile", args=[djangoproject_username])
user_profile_response = self.client.get(user_profile_url)
self.assertContains(
user_profile_response,
'<a href="https://github.com/django/django/commits/main/'
f'?author={trac_username}">Commits: 1.</a>',
html=True,
)

def test_stat_commits(self):
Revision.objects.create(
Expand Down Expand Up @@ -111,25 +188,25 @@ def test_stat_tickets_triaged(self):
author="user1",
newvalue="Accepted",
ticket=Ticket.objects.create(),
**initial_ticket_values
**initial_ticket_values,
)
TicketChange.objects.create(
author="user1",
newvalue="Someday/Maybe",
ticket=Ticket.objects.create(),
**initial_ticket_values
**initial_ticket_values,
)
TicketChange.objects.create(
author="user1",
newvalue="Ready for checkin",
ticket=Ticket.objects.create(),
**initial_ticket_values
**initial_ticket_values,
)
TicketChange.objects.create(
author="user2",
newvalue="Accepted",
ticket=Ticket.objects.create(),
**initial_ticket_values
**initial_ticket_values,
)

response = self.client.get(self.user1_url)
Expand All @@ -145,13 +222,13 @@ def test_stat_tickets_triaged_unaccepted_not_counted(self):
oldvalue="Unreviewed",
newvalue="Accepted",
ticket=Ticket.objects.create(),
**common_ticket_values
**common_ticket_values,
)
TicketChange.objects.create(
oldvalue="Accepted",
newvalue="Unreviewed",
ticket=Ticket.objects.create(),
**common_ticket_values
**common_ticket_values,
)

response = self.client.get(self.user1_url)
Expand All @@ -166,7 +243,7 @@ def test_stat_tickets_triaged_unaccepted_not_counted(self):
}
)
def test_caches_trac_stats(self):
key = "user_vital_status:%s" % hashlib.md5(b"user1").hexdigest()
key = "trac_user_vital_status:%s" % hashlib.md5(b"user1").hexdigest()

self.assertIsNone(cache.get(key))

Expand All @@ -175,6 +252,54 @@ def test_caches_trac_stats(self):
self.assertIsNotNone(cache.get(key))


class UserProfileUpdateFormTests(TestCase):
def setUp(self):
self.request_factory = RequestFactory()
self.edit_profile_url = reverse("edit_profile")

def test_trac_username_field_is_excluded(self):
form = ProfileForm()
self.assertNotIn(
"trac_username",
form.fields,
(
"`ProfileForm` includes the field `trac_username`."
" This may lead to security vulnerabilities."
),
)

def test_bio_field_has_max_length(self):
form = ProfileForm()
self.assertIsInstance(form.fields["bio"].max_length, int)

def test_page_shows_characters_remaining_count_for_bio(self):
profile_edit_form = ProfileForm()
bio_field = profile_edit_form.fields["bio"]
bio_length = randint(0, max(bio_field.max_length, 20))
bio = "*" * bio_length
expected_characters_remaining_count = bio_field.max_length - bio_length
self.assertGreaterEqual(expected_characters_remaining_count, 0)
user = User.objects.create_user(username="user", password="password")
Profile.objects.create(user=user, bio=bio)
request = self.request_factory.get(self.edit_profile_url)
request.user = user
response = edit_profile(request)
self.assertContains(
response,
"<span>Characters remaining:</span>",
html=True,
)
self.assertContains(
response,
f"""
class="character-counter__indicator"
>
{expected_characters_remaining_count}
</span>
""".strip(),
)


class ViewsTests(TestCase):

def test_login_redirect(self):
Expand Down
8 changes: 5 additions & 3 deletions accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.shortcuts import get_object_or_404, redirect, render

from tracdb import stats as trac_stats
from tracdb.utils import get_user_trac_username

from .forms import DeleteProfileForm, ProfileForm
from .models import Profile
Expand Down Expand Up @@ -72,11 +73,12 @@ def delete_profile_success(request):


def get_user_stats(user):
username = user.username.encode("ascii", "ignore")
key = "user_vital_status:%s" % hashlib.md5(username).hexdigest()
trac_username = get_user_trac_username(user)
encoded_trac_username = trac_username.encode("ascii", "ignore")
key = "trac_user_vital_status:%s" % hashlib.md5(encoded_trac_username).hexdigest()
info = cache.get(key)
if info is None:
info = trac_stats.get_user_stats(user.username)
info = trac_stats.get_user_stats(trac_username)
# Hide any stat with a value = 0 so that we don't accidentally insult
# non-contributors.
for k, v in list(info.items()):
Expand Down
Empty file added contrib/__init__.py
Empty file.
Empty file added contrib/django/__init__.py
Empty file.
Empty file.
6 changes: 6 additions & 0 deletions contrib/django/forms/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class FormsContribConfig(AppConfig):
label = "forms_contrib"
name = "contrib.django.forms"
Loading