Repository: uchicago-cs/chigame
Pull request #1493: [Users] Add bio character limit validation and form handling
Author: login: harriet-fisher
Labels: name: x/user-profiles-friending
Created: 2025-05-24T00:45:07Z | Merged: 2025-05-27T03:51:38Z

Addresses issue #1488
This PR implements bio character limit validation for user profiles. I added a 500-character limit with form validation and user feedback, implemented new views for bio editing for users with profiles, as well as implemented tests to validate bio length.

Backend Changes:
- users/models.py: Added max_length=500 constraint to the bio field in UserProfile model
- users/forms.py: Created new UserProfileForm with bio validation and textarea widget
- users/views.py: Added edit_profile view to handle profile editing with form validation
- users/urls.py: Added url for the edit profile functionality

Frontend Changes:
- src/templates/users/edit_profile.html: New template for editing user profiles with clean form layout
- src/templates/users/userprofile_detail.html: Added "Edit Profile" button for users viewing their own profile

Added tests for UserProfileForm including:
- Valid bio submission
- Bio character limit enforcement (500+ characters rejected)
- Edge case testing  at exactly 500 characters
- Empty bio handling


Diff:
diff --git a/src/chigame/users/forms.py b/src/chigame/users/forms.py
index 707dae3d3..a20efb18c 100644
--- a/src/chigame/users/forms.py
+++ b/src/chigame/users/forms.py
@@ -5,9 +5,12 @@
 from django import forms
 from django.contrib.auth import forms as admin_forms
 from django.contrib.auth import get_user_model
-from django.forms import EmailField
+from django.core.exceptions import ValidationError
+from django.forms import EmailField, ModelForm, Textarea
 from django.utils.translation import gettext_lazy as _
 
+from .models import UserProfile
+
 POKEMON_NAMES = [
     "pikachu",
     "bulbasaur",
@@ -87,6 +90,34 @@ def generate_unique_username():
             return username
 
 
+class UserProfileForm(ModelForm):
+    """
+    Form for editing user profile information.
+    """
+
+    class Meta:
+        model = UserProfile
+        fields = ["bio"]
+        widgets = {
+            "bio": Textarea(
+                attrs={
+                    "class": "form-control",
+                    "rows": 4,
+                    "placeholder": "Tell us about yourself...",
+                    "maxlength": 500,
+                    "id": "bio-textarea",
+                }
+            )
+        }
+        help_texts = {"bio": "Maximum 500 characters"}
+
+    def clean_bio(self):
+        bio = self.cleaned_data.get("bio", "")
+        if len(bio) > 500:
+            raise ValidationError(_("Bio cannot exceed 500 characters."))
+        return bio
+
+
 class FriendInvitationForm(forms.Form):
     """
     Form for sending friend invitations with optional personal messages.
diff --git a/src/chigame/users/migrations/0015_alter_userprofile_bio.py b/src/chigame/users/migrations/0015_alter_userprofile_bio.py
new file mode 100644
index 000000000..b2f1664b7
--- /dev/null
+++ b/src/chigame/users/migrations/0015_alter_userprofile_bio.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.20 on 2025-05-24 00:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("users", "0013_recommendationpreferences"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="userprofile",
+            name="bio",
+            field=models.TextField(blank=True, max_length=500),
+        ),
+    ]
diff --git a/src/chigame/users/migrations/0017_merge_20250526_2239.py b/src/chigame/users/migrations/0017_merge_20250526_2239.py
new file mode 100644
index 000000000..43101f6dd
--- /dev/null
+++ b/src/chigame/users/migrations/0017_merge_20250526_2239.py
@@ -0,0 +1,12 @@
+# Generated by Django 4.2.20 on 2025-05-27 03:39
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("users", "0015_alter_userprofile_bio"),
+        ("users", "0016_merge_20250526_2228"),
+    ]
+
+    operations = []
diff --git a/src/chigame/users/models.py b/src/chigame/users/models.py
index b00d2a2c2..dfc060367 100644
--- a/src/chigame/users/models.py
+++ b/src/chigame/users/models.py
@@ -158,7 +158,7 @@ class UserProfile(models.Model):
     """
 
     user = models.OneToOneField(User, on_delete=models.CASCADE)
-    bio = models.TextField(blank=True)
+    bio = models.TextField(blank=True, max_length=500)
     date_joined = models.DateTimeField(auto_now_add=True)
     profile_photo = models.ImageField(upload_to="profile_photos/", blank=True, null=True)
 
diff --git a/src/chigame/users/tests/test_forms.py b/src/chigame/users/tests/test_forms.py
index 2a9ea4fe4..bef8269ca 100644
--- a/src/chigame/users/tests/test_forms.py
+++ b/src/chigame/users/tests/test_forms.py
@@ -1,50 +1,98 @@
 """
 Module for all Form Tests.
 """
-
 import pytest
 from django.forms import EmailField
 from django.test import RequestFactory
-from django.utils.translation import gettext_lazy as _
+from django.utils import timezone
 from faker import Faker
 
-from chigame.users.forms import UserAdminChangeForm, UserAdminCreationForm, UserSignupForm, generate_unique_username
-from chigame.users.models import User
+from chigame.users.forms import (
+    UserAdminChangeForm,
+    UserAdminCreationForm,
+    UserProfileForm,
+    UserSignupForm,
+    generate_unique_username,
+)
+from chigame.users.models import User, UserProfile
 from chigame.users.tests.factories import UserFactory
 
 faker = Faker()
 
+pytestmark = pytest.mark.django_db
+
 
-class TestUserAdminCreationForm:
+class TestUserAdminChangeForm:
     """
-    Test class for all tests related to the UserAdminCreationForm
+    Test class for UserAdminChangeForm
     """
 
-    def test_username_validation_error_msg(self, user: User):
+    def test_email_validation_error_msg(self, user: User):
         """
-        Tests UserAdminCreation Form's unique validator functions correctly by testing:
-            1) A new user with an existing username cannot be added.
-            2) Only 1 error is raised by the UserCreation Form
-            3) The desired error message is raised
+        Tests UserAdminChangeForm's unique email validation error message
         """
-
-        email = faker.unique.email()
-        password = faker.password(length=12)
-
-        UserFactory(email=email)
-
-        form = UserAdminCreationForm(
-            {
-                "email": email,
-                "password1": password,
-                "password2": password,
-            }
+        form = UserAdminChangeForm(
+            {"email": user.email, "date_joined": "2024-01-01", "tokens": 0, "last_seen": timezone.now()}
         )
 
         assert not form.is_valid()
         assert len(form.errors) == 1
         assert "email" in form.errors
-        assert form.errors["email"][0] == _("This email has already been taken.")
+        assert form.errors["email"][0] == "User with this Email address already exists."
+
+    def test_email_field_present(self):
+        """
+        Ensure the admin change form includes the email field and it's of the right type.
+        """
+        form = UserAdminChangeForm()
+        assert "email" in form.fields
+        assert isinstance(form.fields["email"], EmailField)
+
+
+class TestUserProfileForm:
+    """
+    Test class for UserProfileForm
+    """
+
+    def test_valid_bio(self, user: User):
+        """
+        Tests that a valid bio (under 500 characters) is accepted
+        """
+        profile = UserProfile.objects.create(user=user, bio="")
+        form = UserProfileForm({"bio": "This is a valid bio that is under 500 characters."}, instance=profile)
+
+        assert form.is_valid()
+
+    def test_bio_character_limit(self, user: User):
+        """
+        Tests that bio over 500 characters is rejected
+        """
+        profile = UserProfile.objects.create(user=user, bio="")
+        long_bio = "a" * 501  # 501 characters
+        form = UserProfileForm({"bio": long_bio}, instance=profile)
+
+        assert not form.is_valid()
+        assert "bio" in form.errors
+        assert "Ensure this value has at most 500 characters" in form.errors["bio"][0]
+
+    def test_bio_exactly_500_characters(self, user: User):
+        """
+        Tests that bio with exactly 500 characters is accepted
+        """
+        profile = UserProfile.objects.create(user=user, bio="")
+        bio_500_chars = "a" * 500  # Exactly 500 characters
+        form = UserProfileForm({"bio": bio_500_chars}, instance=profile)
+
+        assert form.is_valid()
+
+    def test_empty_bio(self, user: User):
+        """
+        Tests that empty bio is valid
+        """
+        profile = UserProfile.objects.create(user=user, bio="")
+        form = UserProfileForm({"bio": ""}, instance=profile)
+
+        assert form.is_valid()
 
     @pytest.mark.django_db
     def test_valid_creation_form(self):
@@ -63,16 +111,6 @@ def test_valid_creation_form(self):
         assert form.is_valid()
 
 
-class TestUserAdminChangeForm:
-    def test_email_field_present(self):
-        """
-        Ensure the admin change form includes the email field and it's of the right type.
-        """
-        form = UserAdminChangeForm()
-        assert "email" in form.fields
-        assert isinstance(form.fields["email"], EmailField)
-
-
 class TestUserSignupForm:
     @pytest.mark.django_db
     def test_auto_username_is_generated(self):
diff --git a/src/chigame/users/urls.py b/src/chigame/users/urls.py
index c6bcd5714..8eedab3f8 100644
--- a/src/chigame/users/urls.py
+++ b/src/chigame/users/urls.py
@@ -9,6 +9,7 @@
     bulk_inbox,
     cancel_friend_invitation,
     decline_friend_invitation,
+    edit_profile,
     friend_list_view,
     name_update_view,
     notification_detail,
@@ -63,6 +64,7 @@
     path("labels/create/", views.create_notification_label, name="create-notification-label"),
     path("labels/<int:label_id>/notifications/", views.notifications_by_label, name="notifications-by-label"),
     path("inbox/<int:pk>/<str:category>/", views.user_inbox_view, name="user-inbox-category"),
+    path("profile/<int:pk>/edit/", edit_profile, name="edit-profile"),
     path("labels/manage/", views.manage_labels_page_view, name="manage-labels-page"),
     path(
         "notifications/<int:notification_id>/assign-label/",
diff --git a/src/chigame/users/views.py b/src/chigame/users/views.py
index 7d09600a1..160f7014e 100644
--- a/src/chigame/users/views.py
+++ b/src/chigame/users/views.py
@@ -19,7 +19,7 @@
 
 from chigame.games.models import GameList, Lobby, Player, Tournament
 
-from .forms import FriendInvitationForm
+from .forms import FriendInvitationForm, UserProfileForm
 from .models import (
     FriendInvitation,
     FriendRequestNotification,
@@ -860,6 +860,34 @@ def notifications_by_label(request, label_id):
     return render(request, "users/notifications_by_label.html", context)
 
 
+@login_required
+def edit_profile(request, pk):
+    """
+    View for editing user profile information including bio.
+    """
+    if request.user.pk != pk:
+        messages.error(request, "You can only edit your own profile.")
+        return redirect("users:user-profile", pk=request.user.pk)
+
+    profile = UserProfile.get_or_create_profile(request.user)
+
+    if request.method == "POST":
+        form = UserProfileForm(request.POST, instance=profile)
+        if form.is_valid():
+            form.save()
+            messages.success(request, "Profile updated successfully!")
+            return redirect("users:user-profile", pk=request.user.pk)
+    else:
+        form = UserProfileForm(instance=profile)
+
+    context = {
+        "form": form,
+        "profile": profile,
+    }
+
+    return render(request, "users/edit_profile.html", context)
+
+
 @login_required
 @require_POST
 @csrf_protect
diff --git a/src/templates/users/edit_profile.html b/src/templates/users/edit_profile.html
new file mode 100644
index 000000000..97ba293af
--- /dev/null
+++ b/src/templates/users/edit_profile.html
@@ -0,0 +1,40 @@
+{% extends "base.html" %}
+
+{% load static %}
+
+{% block title %}
+  Edit Profile
+{% endblock title %}
+{% block content %}
+  <div class="container mt-4">
+    <div class="row justify-content-center">
+      <div class="col-md-8">
+        <div class="card">
+          <div class="card-header">
+            <h3 class="mb-0">Edit Profile</h3>
+          </div>
+          <div class="card-body">
+            <form method="post">
+              {% csrf_token %}
+              <div class="mb-3">
+                <label for="{{ form.bio.id_for_label }}" class="form-label">Bio</label>
+                {{ form.bio }}
+                <div class="form-text text-muted">Maximum characters: 500</div>
+                {% if form.bio.errors %}
+                  <div class="text-danger">
+                    {% for error in form.bio.errors %}<small>{{ error }}</small>{% endfor %}
+                  </div>
+                {% endif %}
+              </div>
+              <div class="d-flex justify-content-between">
+                <a href="{% url 'users:user-profile' profile.user.pk %}"
+                   class="btn btn-secondary">Cancel</a>
+                <button type="submit" class="btn btn-primary">Save Changes</button>
+              </div>
+            </form>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+{% endblock content %}
diff --git a/src/templates/users/userprofile_detail.html b/src/templates/users/userprofile_detail.html
index 4d3764aa3..5b52c6806 100644
--- a/src/templates/users/userprofile_detail.html
+++ b/src/templates/users/userprofile_detail.html
@@ -186,7 +186,14 @@ <h3>
                   <a href="{% url 'users:detail' request.user.pk %}"
                      class="btn btn-maroon">Account</a>
                   <a href="{% url 'users:friend-list' profile.user.id %}"
-                     class="btn btn-maroon">Friend List</a>
+                     class="btn btn-primary">Friend List</a>
+                  <a href="{% url 'users:user-history' profile.user.pk %}"
+                     class="btn btn-primary">Stats</a>
+                  <a href="{% url 'users:edit-profile' profile.user.pk %}"
+                     class="btn btn-primary">Edit Profile</a>
+                {% elif is_friend %}
+                  <a href="{% url 'users:remove-friend' profile.user.pk %}"
+                     class="btn btn-primary">Remove Friend</a>
                   <a href="{% url 'users:user-history' profile.user.pk %}"
                      class="btn btn-maroon">Stats</a>
                   <a href="{% url 'users:recommendation-preferences' profile.user.pk %}"


Reviews:
user: login: davdma
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-24T17:07:33Z
user: login: davdma
body: Great work Harriet on flushing out the user profile UI - the bio limit and profile editing was sorely needed, so I'm happy to see that you took the initiative.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-24T17:09:23Z
user: login: cmarshall282
body: Looks great!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T01:13:38Z

Review comments:
diff_hunk: @@ -1,36 +1,101 @@
 """
 Module for all Form Tests.
 """
-from django.utils.translation import gettext_lazy as _
+import pytest
 
-from chigame.users.forms import UserAdminCreationForm
-from chigame.users.models import User
+from chigame.users.forms import UserAdminChangeForm, UserAdminCreationForm, UserProfileForm
+from chigame.users.models import User, UserProfile
 
+pytestmark = pytest.mark.django_db
 
-class TestUserAdminCreationForm:
+
+class TestUserAdminChangeForm:
     """
-    Test class for all tests related to the UserAdminCreationForm
+    Test class for UserAdminChangeForm
     """
 
-    def test_username_validation_error_msg(self, user: User):
+    def test_email_validation_error_msg(self, user: User):
         """
-        Tests UserAdminCreation Form's unique validator functions correctly by testing:
-            1) A new user with an existing username cannot be added.
-            2) Only 1 error is raised by the UserCreation Form
-            3) The desired error message is raised
+        Tests UserAdminChangeForm's unique email validation error message
         """
+        form = UserAdminChangeForm(
+            {
+                "email": user.email,
+                "date_joined": "2024-01-01",
+                "tokens": 0,
+            }
+        )
+
+        assert not form.is_valid()
+        assert len(form.errors) == 1
+        assert "email" in form.errors
+        assert form.errors["email"][0] == "User with this Email address already exists."
 
-        # The user already exists,
-        # hence cannot be created.
+
+class TestUserAdminCreationForm:
+    """
+    Test class for UserAdminCreationForm
+    """
+
+    def test_email_validation_error_msg(self, user: User):
+        """
+        Tests UserAdminCreationForm's unique email validation error message
+        """
         form = UserAdminCreationForm(
             {
                 "email": user.email,
-                "password1": user.password,
-                "password2": user.password,
+                "password1": "My_R@ndom-P@ssw0rd",
+                "password2": "My_R@ndom-P@ssw0rd",
             }
         )
 
         assert not form.is_valid()
         assert len(form.errors) == 1
         assert "email" in form.errors
-        assert form.errors["email"][0] == _("This email has already been taken.")
+        assert form.errors["email"][0] == "This email has already been taken."
+
+
+class TestUserProfileForm:
+    """
+    Test class for UserProfileForm
+    """
+
+    def test_valid_bio(self, user: User):
+        """
+        Tests that a valid bio (under 500 characters) is accepted
+        """
+        profile = UserProfile.objects.create(user=user, bio="")
+        form = UserProfileForm({"bio": "This is a valid bio that is under 500 characters."}, instance=profile)
+
+        assert form.is_valid()
+
+    def test_bio_character_limit(self, user: User):
+        """
+        Tests that bio over 500 characters is rejected
+        """
+        profile = UserProfile.objects.create(user=user, bio="")
+        long_bio = "a" * 501  # 501 characters
+        form = UserProfileForm({"bio": long_bio}, instance=profile)
+
+        assert not form.is_valid()
+        assert "bio" in form.errors
+        assert "Ensure this value has at most 500 characters" in form.errors["bio"][0]
+
+    def test_bio_exactly_500_characters(self, user: User):
path: src/chigame/users/tests/test_forms.py
user: login: davdma
body: Kudos for adding tests as well for your new implementation!
created_at: 2025-05-24T17:07:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -792,3 +793,31 @@ def notifications_by_label(request, label_id):
         "notifications": notifications,
     }
     return render(request, "users/notifications_by_label.html", context)
+
+
+@login_required
+def edit_profile(request, pk):
+    """
+    View for editing user profile information including bio.
path: src/chigame/users/views.py
user: login: davdma
body: This was something very much needed! We had views for editing user account info, but not profile (as I wanted to keep them separate). Being able to edit the bio was a crucially missing feature so thank you!
created_at: 2025-05-24T17:08:34Z
author_association: CONTRIBUTOR

PR comments:
user: login: cmarshall282
created_at: 2025-05-31T02:35:10Z
body: Issue Score: Excellent

Comments:
Great job! Here is some feedback:

- Make sure that all commits follow the format specified in the ChiGame Developer Guidelines

<!-- {"score": "excellent", "minor": false} -->

author_association: CONTRIBUTOR
user: login: cmarshall282
created_at: 2025-05-31T02:35:17Z
body: Issue Score: Excellent

Comments:
Great job! Here is some feedback:

- Make sure that all commits follow the format specified in the ChiGame Developer Guidelines

<!-- {"score": "excellent", "minor": false} -->

author_association: CONTRIBUTOR
user: login: cmarshall282
created_at: 2025-05-31T02:37:10Z
body: Issue Score: Excellent

Comments:
Great job! Here is some feedback:

- Make sure that all commits follow the format specified in the ChiGame Developer Guidelines

<!-- {"score": "excellent", "minor": false} -->
author_association: CONTRIBUTOR