diff --git a/Makefile b/Makefile index 910ce300e4..d194739d5f 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000000..c90ab4e72d --- /dev/null +++ b/accounts/admin.py @@ -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"] diff --git a/accounts/forms.py b/accounts/forms.py index f863c06787..99e7319f87 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -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 @@ -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) diff --git a/accounts/migrations/0003_profile_bio.py b/accounts/migrations/0003_profile_bio.py new file mode 100644 index 0000000000..85d6e561f3 --- /dev/null +++ b/accounts/migrations/0003_profile_bio.py @@ -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), + ), + ] diff --git a/accounts/migrations/0004_profile_trac_username.py b/accounts/migrations/0004_profile_trac_username.py new file mode 100644 index 0000000000..c350d4e4e8 --- /dev/null +++ b/accounts/migrations/0004_profile_trac_username.py @@ -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 + ), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 5372b2caa3..5f76ea16ba 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -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) diff --git a/accounts/test_admin.py b/accounts/test_admin.py new file mode 100644 index 0000000000..e761c7ef07 --- /dev/null +++ b/accounts/test_admin.py @@ -0,0 +1,18 @@ +from django.test import TestCase +from django.utils.functional import Promise + +from .admin import ProfileAdminForm + + +class ProfileAdminFormTests(TestCase): + def test_bio_field_has_max_length(self): + form = ProfileAdminForm() + self.assertIn("bio", form.fields) + self.assertIn("maxlength", form.fields["bio"].widget.attrs) + self.assertIsInstance(form.fields["bio"].widget.attrs["maxlength"], int) + + def test_bio_field_has_help_text(self): + form = ProfileAdminForm() + self.assertIn("bio", form.fields) + self.assertIsInstance(form.fields["bio"].help_text, (str, Promise)) + self.assertGreater(len(form.fields["bio"].help_text), 0) diff --git a/accounts/tests.py b/accounts/tests.py index c41d893b1a..6c6e419a3c 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,15 +1,20 @@ 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): @@ -17,14 +22,75 @@ class UserProfileTests(TracDBCreateDatabaseMixin, TestCase): @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: user1@example.com", + "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, "
') + + def test_page_hides_bio_when_absent(self): + response = self.client.get(self.user2_url) + self.assertNotContains(response, '
') + + def test_bio_contains_mail_addresses_clickable(self): + response = self.client.get(self.user1_url) + self.assertContains( + response, + 'user1@example.com', + html=True, + ) + + def test_bio_contains_links_without_protocol_clickable(self): + response = self.client.get(self.user1_url) + self.assertContains( + response, + ( + '' + "user1.example.com" + ), + html=True, + ) + + def test_bio_contains_links_with_protocol_clickable(self): + response = self.client.get(self.user1_url) + self.assertContains( + response, + ( + '' + "https://github.com/ghost" + ), + html=True, + ) + + def test_same_trac_username_can_be_used_for_multiple_users(self): + # For the rare/temporal cases of when multiple accounts belong to + # the same user. + # + # Please also see the comment in the function: + # `tracdb.utils.check_if_public_trac_stats_are_renderable_for_user` + + self.assertFalse(Profile._meta.get_field("trac_username").unique) def test_stat_commits(self): Revision.objects.create( @@ -53,6 +119,57 @@ def test_stat_commits(self): ) self.assertNotContains(user2_response, "Commits") + def test_stat_commits_for_custom_trac_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, + ) + Revision.objects.create( + author=trac_username, + rev="da2432cccae841f0d7629f17a5d79ec47ed7b7cb", + _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, + 'Commits: 2.', + html=True, + ) + + def test_stat_commits_for_custom_trac_username_used_by_another_user(self): + djangoproject_username1 = "djangoproject_user1" + trac_username1 = "trac_user1" + user1 = User.objects.create_user(username=djangoproject_username1) + Profile.objects.create(user=user1, trac_username=trac_username1) + + djangoproject_username2 = trac_username1 + user2 = User.objects.create_user(username=djangoproject_username2) + Profile.objects.create(user=user2) + + Revision.objects.create( + author=trac_username1, + rev="91c879eda595c12477bbfa6f51115e88b75ddf88", + _time=1731669560, + ) + Revision.objects.create( + author=trac_username1, + rev="da2432cccae841f0d7629f17a5d79ec47ed7b7cb", + _time=1731669560, + ) + + user_profile_url2 = reverse("user_profile", args=[djangoproject_username2]) + user_profile_response2 = self.client.get(user_profile_url2) + self.assertNotContains(user_profile_response2, "Commits") + def test_stat_tickets(self): Ticket.objects.create(status="new", reporter="user1") Ticket.objects.create(status="new", reporter="user2") @@ -100,6 +217,82 @@ def test_stat_tickets(self): html=True, ) + def test_stat_tickets_for_custom_trac_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) + + Ticket.objects.create(status="new", reporter=trac_username) + Ticket.objects.create(status="new", reporter="user2") + Ticket.objects.create( + status="closed", + reporter=trac_username, + owner=trac_username, + resolution="fixed", + ) + Ticket.objects.create( + status="closed", reporter="user2", owner=trac_username, resolution="fixed" + ) + Ticket.objects.create( + status="closed", reporter="user2", owner="user2", resolution="fixed" + ) + Ticket.objects.create( + status="closed", reporter="user2", owner=trac_username, resolution="wontfix" + ) + + user_profile_url = reverse("user_profile", args=[djangoproject_username]) + user_profile_response = self.client.get(user_profile_url) + self.assertContains( + user_profile_response, + '' + "Tickets fixed: 2.", + html=True, + ) + self.assertContains( + user_profile_response, + '' + "Tickets opened: 2.", + html=True, + ) + + def test_stat_tickets_for_custom_trac_username_used_by_another_user(self): + djangoproject_username1 = "djangoproject_user1" + trac_username1 = "trac_user1" + user1 = User.objects.create_user(username=djangoproject_username1) + Profile.objects.create(user=user1, trac_username=trac_username1) + + djangoproject_username2 = trac_username1 + user2 = User.objects.create_user(username=djangoproject_username2) + Profile.objects.create(user=user2) + + Ticket.objects.create(status="new", reporter=trac_username1) + Ticket.objects.create(status="new", reporter="user2") + Ticket.objects.create( + status="closed", + reporter=trac_username1, + owner=trac_username1, + resolution="fixed", + ) + Ticket.objects.create( + status="closed", reporter="user2", owner=trac_username1, resolution="fixed" + ) + Ticket.objects.create( + status="closed", reporter="user2", owner="user2", resolution="fixed" + ) + Ticket.objects.create( + status="closed", + reporter="user2", + owner=trac_username1, + resolution="wontfix", + ) + + user_profile_url2 = reverse("user_profile", args=[djangoproject_username2]) + user_profile_response2 = self.client.get(user_profile_url2) + self.assertNotContains(user_profile_response2, "Tickets fixed:") + def test_stat_tickets_triaged(self): # Possible values are from trac.ini in code.djangoproject.com. initial_ticket_values = { @@ -111,30 +304,104 @@ 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) self.assertContains(response, "New tickets triaged: 3.") + def test_stat_tickets_triaged_for_custom_trac_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) + + # Possible values are from trac.ini in code.djangoproject.com. + initial_ticket_values = { + "field": "stage", + "oldvalue": "Unreviewed", + "_time": 1731669560, + } + TicketChange.objects.create( + author=trac_username, + newvalue="Accepted", + ticket=Ticket.objects.create(), + **initial_ticket_values, + ) + TicketChange.objects.create( + author=trac_username, + newvalue="Someday/Maybe", + ticket=Ticket.objects.create(), + **initial_ticket_values, + ) + TicketChange.objects.create( + author=trac_username, + newvalue="Ready for checkin", + ticket=Ticket.objects.create(), + **initial_ticket_values, + ) + + user_profile_url = reverse("user_profile", args=[djangoproject_username]) + user_profile_response = self.client.get(user_profile_url) + self.assertContains(user_profile_response, "New tickets triaged: 3.") + + def test_stat_tickets_triaged_for_custom_trac_username_used_by_another_user(self): + djangoproject_username1 = "djangoproject_user1" + trac_username1 = "trac_user1" + user1 = User.objects.create_user(username=djangoproject_username1) + Profile.objects.create(user=user1, trac_username=trac_username1) + + djangoproject_username2 = trac_username1 + user2 = User.objects.create_user(username=djangoproject_username2) + Profile.objects.create(user=user2) + + # Possible values are from trac.ini in code.djangoproject.com. + initial_ticket_values = { + "field": "stage", + "oldvalue": "Unreviewed", + "_time": 1731669560, + } + TicketChange.objects.create( + author=trac_username1, + newvalue="Accepted", + ticket=Ticket.objects.create(), + **initial_ticket_values, + ) + TicketChange.objects.create( + author=trac_username1, + newvalue="Someday/Maybe", + ticket=Ticket.objects.create(), + **initial_ticket_values, + ) + TicketChange.objects.create( + author=trac_username1, + newvalue="Ready for checkin", + ticket=Ticket.objects.create(), + **initial_ticket_values, + ) + + user_profile_url2 = reverse("user_profile", args=[djangoproject_username2]) + user_profile_response2 = self.client.get(user_profile_url2) + self.assertNotContains(user_profile_response2, "New tickets triaged:") + def test_stat_tickets_triaged_unaccepted_not_counted(self): common_ticket_values = { "field": "stage", @@ -145,13 +412,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) @@ -166,7 +433,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)) @@ -175,6 +442,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, + "Characters remaining:", + html=True, + ) + self.assertContains( + response, + f""" + class="character-counter__indicator" + > + {expected_characters_remaining_count} + + """.strip(), + ) + + class ViewsTests(TestCase): def test_login_redirect(self): diff --git a/accounts/views.py b/accounts/views.py index 18442c9448..d839ef11d3 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -8,20 +8,43 @@ from django.shortcuts import get_object_or_404, redirect, render from tracdb import stats as trac_stats +from tracdb.utils import ( + check_if_public_trac_stats_are_renderable_for_user, + get_user_trac_username, +) from .forms import DeleteProfileForm, ProfileForm from .models import Profile +def get_public_user_trac_stats(user): + 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 = {} + if check_if_public_trac_stats_are_renderable_for_user(user): + 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()): + if v.count == 0: + info.pop(k) + cache.set(key, info, 60 * 60) + return info + + def user_profile(request, username): user = get_object_or_404(User, username=username) + stats = get_public_user_trac_stats(user) return render( request, "accounts/user_profile.html", { "user_obj": user, "email_hash": hashlib.md5(user.email.encode("ascii", "ignore")).hexdigest(), - "stats": get_user_stats(user), + "stats": stats, }, ) @@ -69,18 +92,3 @@ def delete_profile(request): def delete_profile_success(request): return render(request, "accounts/delete_profile_success.html") - - -def get_user_stats(user): - username = user.username.encode("ascii", "ignore") - key = "user_vital_status:%s" % hashlib.md5(username).hexdigest() - info = cache.get(key) - if info is None: - info = trac_stats.get_user_stats(user.username) - # Hide any stat with a value = 0 so that we don't accidentally insult - # non-contributors. - for k, v in list(info.items()): - if v.count == 0: - info.pop(k) - cache.set(key, info, 60 * 60) - return info diff --git a/contrib/__init__.py b/contrib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/django/__init__.py b/contrib/django/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/django/forms/__init__.py b/contrib/django/forms/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/django/forms/apps.py b/contrib/django/forms/apps.py new file mode 100644 index 0000000000..9359fe7651 --- /dev/null +++ b/contrib/django/forms/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FormsContribConfig(AppConfig): + label = "forms_contrib" + name = "contrib.django.forms" diff --git a/contrib/django/forms/boundfields.py b/contrib/django/forms/boundfields.py new file mode 100644 index 0000000000..4a4ef0aec7 --- /dev/null +++ b/contrib/django/forms/boundfields.py @@ -0,0 +1,26 @@ +from contextlib import suppress + +from django.forms.boundfield import BoundField + + +class BoundFieldWithCharacterCounter(BoundField): + + def get_characters_remaining_count(self): + characters_remaining_count = None + max_length = None + with suppress(TypeError, ValueError): + max_length = int(self.field.max_length) + if isinstance(max_length, int): + value = self.value() + if value is None: + return max_length + + # Sometimes, when this method runs, Django may not have normalized + # the value yet, so it causes length mismatch between the server + # and the browser, because of the line ending characters. + # + # Ensure the value is normalized. + value = value.replace("\r\n", "\n").replace("\r", "\n") + + characters_remaining_count = max_length - len(value) + return characters_remaining_count diff --git a/contrib/django/forms/tests.py b/contrib/django/forms/tests.py new file mode 100644 index 0000000000..a6395af40b --- /dev/null +++ b/contrib/django/forms/tests.py @@ -0,0 +1,81 @@ +from random import randint +from unittest import TestCase + +from django.forms import Form, fields + +from .boundfields import BoundFieldWithCharacterCounter + + +class BoundFieldWithCharacterCounterTests(TestCase): + + @classmethod + def prepare_sample_form_class(cls, max_length=None): + class SampleFormContainsBoundFieldWithCharacterCounter(Form): + content = fields.CharField( + max_length=max_length, + bound_field_class=BoundFieldWithCharacterCounter, + ) + + class Meta: + fields = ["content"] + + return SampleFormContainsBoundFieldWithCharacterCounter + + @classmethod + def prepare_sample_bound_field(cls, max_length=None, content=None): + form_class = cls.prepare_sample_form_class(max_length=max_length) + form = form_class(data={"content": content}) + bound_field = form.fields["content"].get_bound_field(form, "content") + return bound_field + + def test_get_characters_remaining_count_is_callable(self): + bound_field = self.__class__.prepare_sample_bound_field() + self.assertTrue( + hasattr(bound_field, "get_characters_remaining_count"), + "Bound field does not have `get_characters_remaining_count` method", + ) + self.assertTrue( + callable(bound_field.get_characters_remaining_count), + "Expected `get_characters_remaining_count` to be callable, but it's not", + ) + + def test_characters_remaining_count_without_max_length(self): + bound_field = self.__class__.prepare_sample_bound_field(max_length=None) + remaining_count = bound_field.get_characters_remaining_count() + self.assertIsNone(remaining_count) + + def test_characters_remaining_count_with_max_length(self): + max_length = randint(0, 20) + bound_field = self.__class__.prepare_sample_bound_field(max_length=max_length) + remaining_count = bound_field.get_characters_remaining_count() + self.assertGreaterEqual(remaining_count, 0) + self.assertEqual(remaining_count, max_length) + + def test_characters_remaining_is_negative_when_content_exceeds_max_length(self): + max_length = randint(0, 20) + content_length = max_length + 1 + content = "*" * content_length + bound_field = self.__class__.prepare_sample_bound_field( + max_length=max_length, + content=content, + ) + remaining_count = bound_field.get_characters_remaining_count() + self.assertLess(remaining_count, 0) + self.assertEqual(remaining_count, max_length - content_length) + + def test_characters_remaining_matches_client_side_js_implementation(self): + ending = "\r\n\r\r\n" + ending_length = len(ending) + max_length = randint(ending_length, ending_length + 10) + visual_content_length = max_length - ending_length + visual_content = "*" * visual_content_length + content = visual_content + ending + normalized_ending = ending.replace("\r\n", "\n").replace("\r", "\n") + normalized_content = visual_content + normalized_ending + bound_field = self.__class__.prepare_sample_bound_field( + max_length=max_length, + content=content, + ) + remaining_count = bound_field.get_characters_remaining_count() + self.assertGreaterEqual(remaining_count, 0) + self.assertEqual(remaining_count, max_length - len(normalized_content)) diff --git a/djangoproject/scss/_style.scss b/djangoproject/scss/_style.scss index 2fe7c0cfd1..46725d311b 100644 --- a/djangoproject/scss/_style.scss +++ b/djangoproject/scss/_style.scss @@ -2967,6 +2967,10 @@ form { background: var(--primary); } } + + .help-text { + @include font-size(14); + } } .form-general { @@ -3189,9 +3193,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; } } diff --git a/djangoproject/settings/common.py b/djangoproject/settings/common.py index fff6e1dfee..83bc3acbac 100644 --- a/djangoproject/settings/common.py +++ b/djangoproject/settings/common.py @@ -53,6 +53,7 @@ DEFAULT_FROM_EMAIL = "noreply@djangoproject.com" FUNDRAISING_DEFAULT_FROM_EMAIL = "fundraising@djangoproject.com" +INDIVIDUAL_MEMBER_ACCOUNT_INVITE_DEFAULT_FROM_EMAIL = DEFAULT_FROM_EMAIL FIXTURE_DIRS = [str(PROJECT_PACKAGE.joinpath("fixtures"))] @@ -61,6 +62,7 @@ "aggregator", "blog", "contact", + "contrib.django.forms", "dashboard", "docs", "foundation", diff --git a/djangoproject/static/js/mod/character-counter.js b/djangoproject/static/js/mod/character-counter.js new file mode 100644 index 0000000000..05c8839057 --- /dev/null +++ b/djangoproject/static/js/mod/character-counter.js @@ -0,0 +1,74 @@ +let CHARACTER_COUNTER_INPUT_SELECTOR_ATTR = + 'data-character-counter-input-selector'; + +let CHARACTER_COUNTER_INDICATOR_SELECTOR_ATTR = + 'data-character-counter-indicator-selector'; + +let CharacterCounter = function (inputElement, indicatorElement) { + this.inputElement = inputElement; + this.indicatorElement = indicatorElement; +}; + +CharacterCounter.prototype = { + handleInputEvent: function (ev) { + this.updateDOM(); + }, + + updateDOM: function () { + if (typeof this.inputElement.maxLength !== 'number') { + return; + } + + if (typeof this.inputElement.value !== 'string') { + return; + } + + const remaining = + this.inputElement.maxLength - this.inputElement.value.length; + this.indicatorElement.innerText = String(remaining); + }, +}; + +function setupCharacterCounter(counterElement) { + let inputSelector = counterElement.getAttribute( + CHARACTER_COUNTER_INPUT_SELECTOR_ATTR, + ); + if (typeof inputSelector !== 'string') { + return; + } + + let indicatorSelector = counterElement.getAttribute( + CHARACTER_COUNTER_INDICATOR_SELECTOR_ATTR, + ); + if (typeof indicatorSelector !== 'string') { + return; + } + + let inputElement = document.querySelector(inputSelector); + if (inputElement == null) { + return; + } + + let indicatorElement = document.querySelector(indicatorSelector); + if (indicatorElement == null) { + return; + } + + let characterCounter = new CharacterCounter(inputElement, indicatorElement); + + inputElement.addEventListener( + 'input', + characterCounter.handleInputEvent.bind(characterCounter), + ); +} + +function setupCharacterCounters() { + let counterElements = document.getElementsByClassName('character-counter'); + for (let i = 0; i < counterElements.length; i++) { + setupCharacterCounter(counterElements[i]); + } +} + +document.addEventListener('DOMContentLoaded', function () { + setupCharacterCounters(); +}); diff --git a/djangoproject/templates/accounts/edit_profile.html b/djangoproject/templates/accounts/edit_profile.html index 29d5dfc2d9..ba3f703506 100644 --- a/djangoproject/templates/accounts/edit_profile.html +++ b/djangoproject/templates/accounts/edit_profile.html @@ -1,8 +1,12 @@ {% extends "registration/base.html" %} -{% load i18n %} +{% load i18n static %} {% block title %}{% translate "Edit your profile" %}{% endblock %} +{% block head_extra %} + +{% endblock head_extra %} + {% block content %} {% if form.errors %} @@ -27,6 +31,35 @@
{{ form.email }}
++ {{ form.bio.errors.as_text }} +
+ {% endif %} +{{ form.bio }}
+{% translate "Need to edit something? Here's how:" %}
+ {{ user_obj.profile.bio|urlize }} +
+ {% endif %} + {% if stats %}