From 1ed5d3133f7f57695add9fb13e9cf6335db99df8 Mon Sep 17 00:00:00 2001 From: nourzakhama2003 Date: Sun, 22 Mar 2026 20:50:41 +0100 Subject: [PATCH 1/5] feat: Add social media username validation --- web/forms.py | 28 ++ web/models.py | 22 +- web/tests/test_social_media_validators.py | 326 ++++++++++++++++++++++ web/validators.py | 98 +++++++ 4 files changed, 471 insertions(+), 3 deletions(-) create mode 100644 web/tests/test_social_media_validators.py create mode 100644 web/validators.py diff --git a/web/forms.py b/web/forms.py index 96489d524..5ac168e58 100644 --- a/web/forms.py +++ b/web/forms.py @@ -58,6 +58,7 @@ WaitingRoom, ) from .referrals import handle_referral +from .validators import validate_discord_username, validate_github_username, validate_slack_username from .widgets import ( TailwindCaptchaTextInput, TailwindCheckboxInput, @@ -746,6 +747,33 @@ def clean_username(self): raise forms.ValidationError("This username is already taken. Please choose a different one.") return username + def clean_discord_username(self): + username = self.cleaned_data.get("discord_username", "") + if username: + try: + validate_discord_username(username) + except ValidationError as e: + raise forms.ValidationError(e.message) + return username + + def clean_slack_username(self): + username = self.cleaned_data.get("slack_username", "") + if username: + try: + validate_slack_username(username) + except ValidationError as e: + raise forms.ValidationError(e.message) + return username + + def clean_github_username(self): + username = self.cleaned_data.get("github_username", "") + if username: + try: + validate_github_username(username) + except ValidationError as e: + raise forms.ValidationError(e.message) + return username + def save(self, commit=True): user = super().save(commit=False) if commit: diff --git a/web/models.py b/web/models.py index 6b8ea8ef4..c861dc55e 100644 --- a/web/models.py +++ b/web/models.py @@ -26,6 +26,7 @@ from PIL import Image from web.utils import calculate_and_update_user_streak +from web.validators import validate_discord_username, validate_github_username, validate_slack_username class Notification(models.Model): @@ -62,9 +63,24 @@ class Profile(models.Model): ) is_teacher = models.BooleanField(default=False) is_social_media_manager = models.BooleanField(default=False) - discord_username = models.CharField(max_length=50, blank=True, help_text="Your Discord username (e.g., User#1234)") - slack_username = models.CharField(max_length=50, blank=True, help_text="Your Slack username") - github_username = models.CharField(max_length=50, blank=True, help_text="Your GitHub username (without @)") + discord_username = models.CharField( + max_length=50, + blank=True, + validators=[validate_discord_username], + help_text="Your Discord username (e.g., User#1234)", + ) + slack_username = models.CharField( + max_length=50, + blank=True, + validators=[validate_slack_username], + help_text="Your Slack username", + ) + github_username = models.CharField( + max_length=50, + blank=True, + validators=[validate_github_username], + help_text="Your GitHub username (without @)", + ) referral_code = models.CharField(max_length=20, unique=True, blank=True) referred_by = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="referrals") referral_earnings = models.DecimalField(max_digits=10, decimal_places=2, default=0) diff --git a/web/tests/test_social_media_validators.py b/web/tests/test_social_media_validators.py new file mode 100644 index 000000000..64ad407e1 --- /dev/null +++ b/web/tests/test_social_media_validators.py @@ -0,0 +1,326 @@ +""" +Tests for custom validators used in the Alpha One Labs platform. + +Tests for social media username validators (Discord, Slack, GitHub). +""" + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.test import TestCase + +from web.forms import ProfileUpdateForm +from web.models import Profile +from web.validators import validate_discord_username, validate_github_username, validate_slack_username + +User = get_user_model() + + +class DiscordUsernameValidatorTests(TestCase): + """Test cases for Discord username validation.""" + + def test_valid_discord_username(self): + """Test valid Discord usernames.""" + valid_usernames = [ + "User", + "User123", + "user_name", + "user-name", + "user.name", + "User#1234", + "ValidUser123", + ] + for username in valid_usernames: + with self.subTest(username=username): + try: + validate_discord_username(username) + except ValidationError: + self.fail(f"'{username}' should be valid, but raised ValidationError") + + def test_discord_username_too_short(self): + """Test Discord username shorter than 2 characters.""" + with self.assertRaises(ValidationError) as cm: + validate_discord_username("A") + self.assertIn("2 and 32", str(cm.exception)) + + def test_discord_username_too_long(self): + """Test Discord username longer than 32 characters.""" + long_username = "A" * 33 + with self.assertRaises(ValidationError) as cm: + validate_discord_username(long_username) + self.assertIn("2 and 32", str(cm.exception)) + + def test_discord_username_invalid_characters(self): + """Test Discord username with invalid characters.""" + invalid_usernames = [ + "User Name", # spaces + "User@Name", # @ + "User!", # ! + "User$", # $ + ] + for username in invalid_usernames: + with self.subTest(username=username): + with self.assertRaises(ValidationError): + validate_discord_username(username) + + def test_empty_discord_username(self): + """Test that empty Discord username is allowed.""" + # Empty values should be allowed (use blank=True in model) + validate_discord_username("") # Should not raise + + +class SlackUsernameValidatorTests(TestCase): + """Test cases for Slack username validation.""" + + def test_valid_slack_username(self): + """Test valid Slack usernames.""" + valid_usernames = [ + "john", + "john.doe", + "john-doe", + "john_doe", + "a", + "user123", + ] + for username in valid_usernames: + with self.subTest(username=username): + try: + validate_slack_username(username) + except ValidationError: + self.fail(f"'{username}' should be valid, but raised ValidationError") + + def test_slack_username_too_long(self): + """Test Slack username longer than 21 characters.""" + long_username = "a" * 22 + with self.assertRaises(ValidationError) as cm: + validate_slack_username(long_username) + self.assertIn("1 and 21", str(cm.exception)) + + def test_slack_username_starts_with_number(self): + """Test Slack username starting with a number.""" + with self.assertRaises(ValidationError): + validate_slack_username("123user") + + def test_slack_username_starts_with_hyphen(self): + """Test Slack username starting with a hyphen.""" + with self.assertRaises(ValidationError): + validate_slack_username("-user") + + def test_slack_username_uppercase(self): + """Test Slack username with uppercase letters.""" + with self.assertRaises(ValidationError): + validate_slack_username("User") + + def test_slack_username_with_spaces(self): + """Test Slack username with spaces.""" + with self.assertRaises(ValidationError): + validate_slack_username("user name") + + def test_empty_slack_username(self): + """Test that empty Slack username is allowed.""" + validate_slack_username("") # Should not raise + + +class GitHubUsernameValidatorTests(TestCase): + """Test cases for GitHub username validation.""" + + def test_valid_github_username(self): + """Test valid GitHub usernames.""" + valid_usernames = [ + "octocat", + "octo-cat", + "octocat123", + "a", + "github-user", + "github123user", + ] + for username in valid_usernames: + with self.subTest(username=username): + try: + validate_github_username(username) + except ValidationError: + self.fail(f"'{username}' should be valid, but raised ValidationError") + + def test_github_username_too_long(self): + """Test GitHub username longer than 39 characters.""" + long_username = "a" * 40 + with self.assertRaises(ValidationError) as cm: + validate_github_username(long_username) + self.assertIn("1 and 39", str(cm.exception)) + + def test_github_username_starts_with_hyphen(self): + """Test GitHub username starting with a hyphen.""" + with self.assertRaises(ValidationError): + validate_github_username("-octocat") + + def test_github_username_ends_with_hyphen(self): + """Test GitHub username ending with a hyphen.""" + with self.assertRaises(ValidationError): + validate_github_username("octocat-") + + def test_github_username_with_spaces(self): + """Test GitHub username with spaces.""" + with self.assertRaises(ValidationError): + validate_github_username("octo cat") + + def test_github_username_with_underscore(self): + """Test GitHub username with underscore (not allowed).""" + with self.assertRaises(ValidationError): + validate_github_username("octo_cat") + + def test_github_username_with_dots(self): + """Test GitHub username with dots (not allowed).""" + with self.assertRaises(ValidationError): + validate_github_username("octo.cat") + + def test_empty_github_username(self): + """Test that empty GitHub username is allowed.""" + validate_github_username("") # Should not raise + + +class ProfileModelValidationTests(TestCase): + """Test Profile model validation with validators.""" + + def setUp(self): + """Create a test user.""" + self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") + + def test_profile_with_valid_social_usernames(self): + """Test creating a profile with valid social media usernames.""" + profile = Profile.objects.create( + user=self.user, + discord_username="TestUser#1234", + slack_username="test.user", + github_username="test-user", + ) + profile.full_clean() # Should not raise + self.assertEqual(profile.discord_username, "TestUser#1234") + + def test_profile_with_invalid_discord_username(self): + """Test creating a profile with invalid Discord username.""" + profile = Profile.objects.create( + user=self.user, + discord_username="A", # Too short + slack_username="valid", + github_username="valid", + ) + with self.assertRaises(ValidationError): + profile.full_clean() + + def test_profile_with_invalid_slack_username(self): + """Test creating a profile with invalid Slack username.""" + profile = Profile.objects.create( + user=self.user, + discord_username="ValidUser", + slack_username="123invalid", # Starts with number + github_username="valid", + ) + with self.assertRaises(ValidationError): + profile.full_clean() + + def test_profile_with_invalid_github_username(self): + """Test creating a profile with invalid GitHub username.""" + profile = Profile.objects.create( + user=self.user, + discord_username="ValidUser", + slack_username="valid", + github_username="-invalid", # Starts with hyphen + ) + with self.assertRaises(ValidationError): + profile.full_clean() + + +class ProfileUpdateFormValidationTests(TestCase): + """Test ProfileUpdateForm validation with social media fields.""" + + def setUp(self): + """Create a test user.""" + self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123") + self.user.profile.save() + + def test_form_with_valid_social_usernames(self): + """Test form with valid social media usernames.""" + form_data = { + "username": "testuser", + "email": "test@example.com", + "first_name": "", + "last_name": "", + "bio": "Test bio", + "expertise": "Python", + "is_profile_public": "True", + "discord_username": "TestUser#1234", + "slack_username": "test.user", + "github_username": "test-user", + } + form = ProfileUpdateForm(form_data, instance=self.user) + self.assertTrue(form.is_valid()) + + def test_form_with_invalid_discord_username(self): + """Test form with invalid Discord username.""" + form_data = { + "username": "testuser", + "email": "test@example.com", + "first_name": "", + "last_name": "", + "bio": "Test bio", + "expertise": "Python", + "is_profile_public": "True", + "discord_username": "A", # Too short + "slack_username": "", + "github_username": "", + } + form = ProfileUpdateForm(form_data, instance=self.user) + self.assertFalse(form.is_valid()) + self.assertIn("discord_username", form.errors) + + def test_form_with_invalid_slack_username(self): + """Test form with invalid Slack username.""" + form_data = { + "username": "testuser", + "email": "test@example.com", + "first_name": "", + "last_name": "", + "bio": "Test bio", + "expertise": "Python", + "is_profile_public": "True", + "discord_username": "", + "slack_username": "123invalid", # Starts with number + "github_username": "", + } + form = ProfileUpdateForm(form_data, instance=self.user) + self.assertFalse(form.is_valid()) + self.assertIn("slack_username", form.errors) + + def test_form_with_invalid_github_username(self): + """Test form with invalid GitHub username.""" + form_data = { + "username": "testuser", + "email": "test@example.com", + "first_name": "", + "last_name": "", + "bio": "Test bio", + "expertise": "Python", + "is_profile_public": "True", + "discord_username": "", + "slack_username": "", + "github_username": "-invalid", # Starts with hyphen + } + form = ProfileUpdateForm(form_data, instance=self.user) + self.assertFalse(form.is_valid()) + self.assertIn("github_username", form.errors) + + def test_form_with_empty_social_usernames(self): + """Test form with empty social media usernames.""" + form_data = { + "username": "testuser", + "email": "test@example.com", + "first_name": "", + "last_name": "", + "bio": "Test bio", + "expertise": "Python", + "is_profile_public": "False", + "discord_username": "", + "slack_username": "", + "github_username": "", + } + form = ProfileUpdateForm(form_data, instance=self.user) + self.assertTrue(form.is_valid()) diff --git a/web/validators.py b/web/validators.py new file mode 100644 index 000000000..3524bd056 --- /dev/null +++ b/web/validators.py @@ -0,0 +1,98 @@ +""" +Custom validators for Alpha One Labs Education Platform. + +Validates usernames across various social media platforms. +""" + +import re + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + + +def validate_discord_username(value): + """ + Validate Discord username format. + + Discord usernames: + - Must be 2-32 characters long + - Can contain letters, numbers, underscores, hyphens, and dots + - Cannot contain spaces + """ + if not value: + return # Allow empty values; use blank=True in model if needed + + # Remove the discriminator (e.g., "User#1234") + if "#" in value: + username_part = value.split("#")[0] + else: + username_part = value + + if len(username_part) < 2 or len(username_part) > 32: + raise ValidationError( + _("Discord username must be between 2 and 32 characters long (excluding discriminator)."), + code="invalid_discord_length", + ) + + # Discord usernames can contain letters, numbers, underscores, hyphens, and dots + if not re.match(r"^[a-zA-Z0-9_\-\.]+$", username_part): + raise ValidationError( + _("Discord username can only contain letters, numbers, underscores, hyphens, and dots."), + code="invalid_discord_chars", + ) + + +def validate_slack_username(value): + """ + Validate Slack username format. + + Slack usernames: + - Must be 1-21 characters long + - Can only contain lowercase letters, numbers, periods, hyphens, and underscores + - Must start with a letter + """ + if not value: + return + + if len(value) < 1 or len(value) > 21: + raise ValidationError( + _("Slack username must be between 1 and 21 characters long."), + code="invalid_slack_length", + ) + + if not re.match(r"^[a-z][a-z0-9._-]*$", value): + raise ValidationError( + _( + "Slack username must start with a letter and can only contain " + "lowercase letters, numbers, periods, hyphens, and underscores." + ), + code="invalid_slack_chars", + ) + + +def validate_github_username(value): + """ + Validate GitHub username format. + + GitHub usernames: + - Must be 1-39 characters long + - Can only contain alphanumeric characters and hyphens + - Cannot start or end with a hyphen + """ + if not value: + return + + if len(value) < 1 or len(value) > 39: + raise ValidationError( + _("GitHub username must be between 1 and 39 characters long."), + code="invalid_github_length", + ) + + if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$", value): + raise ValidationError( + _( + "GitHub username can only contain alphanumeric characters and hyphens, " + "and cannot start or end with a hyphen." + ), + code="invalid_github_chars", + ) From e00d4a1690846dfed80788605b8cdf95e33404a1 Mon Sep 17 00:00:00 2001 From: nourzakhama2003 Date: Sun, 22 Mar 2026 21:32:15 +0100 Subject: [PATCH 2/5] fix: Address CodeRabbit review feedback on social media validators --- web/forms.py | 35 ++++++++++--------- web/models.py | 6 ++-- web/tests/test_social_media_validators.py | 41 +++++++++++++++++------ web/validators.py | 26 +++++++++++--- 4 files changed, 71 insertions(+), 37 deletions(-) diff --git a/web/forms.py b/web/forms.py index 5ac168e58..aef38f5b7 100644 --- a/web/forms.py +++ b/web/forms.py @@ -747,32 +747,31 @@ def clean_username(self): raise forms.ValidationError("This username is already taken. Please choose a different one.") return username - def clean_discord_username(self): - username = self.cleaned_data.get("discord_username", "") + def _clean_social_media_username(self, field_name, validator): + """ + Helper method to validate social media usernames. + + Reduces code duplication by centralizing the validation logic + for all social media platform usernames. + """ + username = self.cleaned_data.get(field_name, "") if username: try: - validate_discord_username(username) + validator(username) except ValidationError as e: - raise forms.ValidationError(e.message) + # Get the message from ValidationError + message = e.message if hasattr(e, 'message') else str(e.messages[0] if e.messages else e) + raise forms.ValidationError(message) return username + def clean_discord_username(self): + return self._clean_social_media_username("discord_username", validate_discord_username) + def clean_slack_username(self): - username = self.cleaned_data.get("slack_username", "") - if username: - try: - validate_slack_username(username) - except ValidationError as e: - raise forms.ValidationError(e.message) - return username + return self._clean_social_media_username("slack_username", validate_slack_username) def clean_github_username(self): - username = self.cleaned_data.get("github_username", "") - if username: - try: - validate_github_username(username) - except ValidationError as e: - raise forms.ValidationError(e.message) - return username + return self._clean_social_media_username("github_username", validate_github_username) def save(self, commit=True): user = super().save(commit=False) diff --git a/web/models.py b/web/models.py index c861dc55e..0311b505d 100644 --- a/web/models.py +++ b/web/models.py @@ -64,19 +64,19 @@ class Profile(models.Model): is_teacher = models.BooleanField(default=False) is_social_media_manager = models.BooleanField(default=False) discord_username = models.CharField( - max_length=50, + max_length=37, # 32 (username) + 1 (#) + 4 (discriminator) blank=True, validators=[validate_discord_username], help_text="Your Discord username (e.g., User#1234)", ) slack_username = models.CharField( - max_length=50, + max_length=21, # Matches Slack's 21 character limit blank=True, validators=[validate_slack_username], help_text="Your Slack username", ) github_username = models.CharField( - max_length=50, + max_length=39, # Matches GitHub's 39 character limit blank=True, validators=[validate_github_username], help_text="Your GitHub username (without @)", diff --git a/web/tests/test_social_media_validators.py b/web/tests/test_social_media_validators.py index 64ad407e1..d794f3f5f 100644 --- a/web/tests/test_social_media_validators.py +++ b/web/tests/test_social_media_validators.py @@ -40,14 +40,29 @@ def test_discord_username_too_short(self): """Test Discord username shorter than 2 characters.""" with self.assertRaises(ValidationError) as cm: validate_discord_username("A") - self.assertIn("2 and 32", str(cm.exception)) + self.assertEqual(cm.exception.code, "invalid_discord_length") def test_discord_username_too_long(self): """Test Discord username longer than 32 characters.""" long_username = "A" * 33 with self.assertRaises(ValidationError) as cm: validate_discord_username(long_username) - self.assertIn("2 and 32", str(cm.exception)) + self.assertEqual(cm.exception.code, "invalid_discord_length") + + def test_discord_discriminator_invalid_format(self): + """Test Discord username with invalid discriminator format.""" + invalid_cases = [ + "User#", # Missing discriminator + "User#12", # Too few digits + "User#12345", # Too many digits + "User#abcd", # Letters instead of digits + "User#1234#extra", # Multiple # symbols + ] + for username in invalid_cases: + with self.subTest(username=username): + with self.assertRaises(ValidationError) as cm: + validate_discord_username(username) + self.assertIn(cm.exception.code, ["invalid_discord_format", "invalid_discord_discriminator"]) def test_discord_username_invalid_characters(self): """Test Discord username with invalid characters.""" @@ -93,7 +108,7 @@ def test_slack_username_too_long(self): long_username = "a" * 22 with self.assertRaises(ValidationError) as cm: validate_slack_username(long_username) - self.assertIn("1 and 21", str(cm.exception)) + self.assertEqual(cm.exception.code, "invalid_slack_length") def test_slack_username_starts_with_number(self): """Test Slack username starting with a number.""" @@ -145,7 +160,7 @@ def test_github_username_too_long(self): long_username = "a" * 40 with self.assertRaises(ValidationError) as cm: validate_github_username(long_username) - self.assertIn("1 and 39", str(cm.exception)) + self.assertEqual(cm.exception.code, "invalid_github_length") def test_github_username_starts_with_hyphen(self): """Test GitHub username starting with a hyphen.""" @@ -186,47 +201,51 @@ def setUp(self): def test_profile_with_valid_social_usernames(self): """Test creating a profile with valid social media usernames.""" - profile = Profile.objects.create( + profile = Profile( user=self.user, discord_username="TestUser#1234", slack_username="test.user", github_username="test-user", ) profile.full_clean() # Should not raise + profile.save() self.assertEqual(profile.discord_username, "TestUser#1234") def test_profile_with_invalid_discord_username(self): """Test creating a profile with invalid Discord username.""" - profile = Profile.objects.create( + profile = Profile( user=self.user, discord_username="A", # Too short slack_username="valid", github_username="valid", ) - with self.assertRaises(ValidationError): + with self.assertRaises(ValidationError) as cm: profile.full_clean() + self.assertIn("discord_username", cm.exception.message_dict) def test_profile_with_invalid_slack_username(self): """Test creating a profile with invalid Slack username.""" - profile = Profile.objects.create( + profile = Profile( user=self.user, discord_username="ValidUser", slack_username="123invalid", # Starts with number github_username="valid", ) - with self.assertRaises(ValidationError): + with self.assertRaises(ValidationError) as cm: profile.full_clean() + self.assertIn("slack_username", cm.exception.message_dict) def test_profile_with_invalid_github_username(self): """Test creating a profile with invalid GitHub username.""" - profile = Profile.objects.create( + profile = Profile( user=self.user, discord_username="ValidUser", slack_username="valid", github_username="-invalid", # Starts with hyphen ) - with self.assertRaises(ValidationError): + with self.assertRaises(ValidationError) as cm: profile.full_clean() + self.assertIn("github_username", cm.exception.message_dict) class ProfileUpdateFormValidationTests(TestCase): diff --git a/web/validators.py b/web/validators.py index 3524bd056..7d8df7239 100644 --- a/web/validators.py +++ b/web/validators.py @@ -17,16 +17,32 @@ def validate_discord_username(value): Discord usernames: - Must be 2-32 characters long - Can contain letters, numbers, underscores, hyphens, and dots - - Cannot contain spaces + - Optional discriminator format: username#1234 (4 digits) """ if not value: return # Allow empty values; use blank=True in model if needed - # Remove the discriminator (e.g., "User#1234") + # Validate and extract the discriminator (e.g., "User#1234") + username_part = value if "#" in value: - username_part = value.split("#")[0] - else: - username_part = value + parts = value.split("#") + + # Only one # allowed + if len(parts) != 2: + raise ValidationError( + _("Discord username can only contain one '#' symbol."), + code="invalid_discord_format", + ) + + username_part = parts[0] + discriminator = parts[1] + + # Discriminator must be 4 digits if present + if not discriminator or not discriminator.isdigit() or len(discriminator) != 4: + raise ValidationError( + _("Discord discriminator must be exactly 4 digits (e.g., User#1234)."), + code="invalid_discord_discriminator", + ) if len(username_part) < 2 or len(username_part) > 32: raise ValidationError( From 7f346a6b3807f0041a4cf89efb29f69ee08384a7 Mon Sep 17 00:00:00 2001 From: nourzakhama2003 Date: Sun, 22 Mar 2026 22:07:13 +0100 Subject: [PATCH 3/5] fix: address CodeRabbit review feedback round2 --- web/forms.py | 5 ++--- web/models.py | 2 ++ web/tests/test_social_media_validators.py | 21 ++++++++++++--------- web/validators.py | 2 +- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/web/forms.py b/web/forms.py index aef38f5b7..eafe30656 100644 --- a/web/forms.py +++ b/web/forms.py @@ -759,9 +759,8 @@ def _clean_social_media_username(self, field_name, validator): try: validator(username) except ValidationError as e: - # Get the message from ValidationError - message = e.message if hasattr(e, 'message') else str(e.messages[0] if e.messages else e) - raise forms.ValidationError(message) + # Preserve error codes and full error structure + raise forms.ValidationError(e.message, code=e.code if hasattr(e, 'code') else None) return username def clean_discord_username(self): diff --git a/web/models.py b/web/models.py index 0311b505d..e27de9dcb 100644 --- a/web/models.py +++ b/web/models.py @@ -118,6 +118,8 @@ def __str__(self): def save(self, *args, **kwargs): if not self.referral_code: self.referral_code = self.generate_referral_code() + # Validate social media usernames and other field constraints + self.full_clean() # Skip image processing for SVG files super().save(*args, **kwargs) diff --git a/web/tests/test_social_media_validators.py b/web/tests/test_social_media_validators.py index d794f3f5f..8dbaf2128 100644 --- a/web/tests/test_social_media_validators.py +++ b/web/tests/test_social_media_validators.py @@ -187,6 +187,11 @@ def test_github_username_with_dots(self): with self.assertRaises(ValidationError): validate_github_username("octo.cat") + def test_github_username_with_consecutive_hyphens(self): + """Test GitHub username with consecutive hyphens (not allowed).""" + with self.assertRaises(ValidationError): + validate_github_username("octo--cat") + def test_empty_github_username(self): """Test that empty GitHub username is allowed.""" validate_github_username("") # Should not raise @@ -201,15 +206,13 @@ def setUp(self): def test_profile_with_valid_social_usernames(self): """Test creating a profile with valid social media usernames.""" - profile = Profile( - user=self.user, - discord_username="TestUser#1234", - slack_username="test.user", - github_username="test-user", - ) - profile.full_clean() # Should not raise - profile.save() - self.assertEqual(profile.discord_username, "TestUser#1234") + # Update the existing profile created by post_save signal + self.user.profile.discord_username = "TestUser#1234" + self.user.profile.slack_username = "test.user" + self.user.profile.github_username = "test-user" + self.user.profile.full_clean() # Should not raise + self.user.profile.save() + self.assertEqual(self.user.profile.discord_username, "TestUser#1234") def test_profile_with_invalid_discord_username(self): """Test creating a profile with invalid Discord username.""" diff --git a/web/validators.py b/web/validators.py index 7d8df7239..57953e49c 100644 --- a/web/validators.py +++ b/web/validators.py @@ -104,7 +104,7 @@ def validate_github_username(value): code="invalid_github_length", ) - if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$", value): + if not re.match(r"^[a-zA-Z0-9](?!.*--)([a-zA-Z0-9-]*[a-zA-Z0-9])?$", value): raise ValidationError( _( "GitHub username can only contain alphanumeric characters and hyphens, " From eb34ef420156eccd8803e5b5f8a79ee5f6ba8e3f Mon Sep 17 00:00:00 2001 From: nourzakhama2003 Date: Sun, 22 Mar 2026 22:38:33 +0100 Subject: [PATCH 4/5] fix: add data migration and update GitHub error message --- .../0064_cleanup_social_media_usernames.py | 65 +++++++++++++++++++ ...65_alter_profile_social_username_fields.py | 44 +++++++++++++ web/validators.py | 2 +- 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 web/migrations/0064_cleanup_social_media_usernames.py create mode 100644 web/migrations/0065_alter_profile_social_username_fields.py diff --git a/web/migrations/0064_cleanup_social_media_usernames.py b/web/migrations/0064_cleanup_social_media_usernames.py new file mode 100644 index 000000000..686ba70c6 --- /dev/null +++ b/web/migrations/0064_cleanup_social_media_usernames.py @@ -0,0 +1,65 @@ +# Generated migration to clean up social media usernames before enforcing new length constraints + +from django.db import migrations + + +def cleanup_social_media_usernames(apps, schema_editor): + """ + Clean up social media usernames that exceed new length limits: + - discord_username: 37 chars (down from 50) + - slack_username: 21 chars (down from 50) + - github_username: 39 chars (down from 50) + + This migration truncates over-limit usernames to fit the new constraints. + """ + Profile = apps.get_model("web", "Profile") + updated_count = 0 + + for profile in Profile.objects.all(): + modified = False + + # Truncate discord_username to 37 chars + if profile.discord_username and len(profile.discord_username) > 37: + profile.discord_username = profile.discord_username[:37] + modified = True + + # Truncate slack_username to 21 chars + if profile.slack_username and len(profile.slack_username) > 21: + profile.slack_username = profile.slack_username[:21] + modified = True + + # Truncate github_username to 39 chars + if profile.github_username and len(profile.github_username) > 39: + profile.github_username = profile.github_username[:39] + modified = True + + if modified: + # Use update() to bypass validators and Profile.save() with full_clean() + Profile.objects.filter(pk=profile.pk).update( + discord_username=profile.discord_username, + slack_username=profile.slack_username, + github_username=profile.github_username, + ) + updated_count += 1 + + if updated_count > 0: + print(f"✓ Cleaned up {updated_count} profiles with over-limit usernames") + + +def reverse_cleanup(apps, schema_editor): + """ + Reverse migration - no action needed as truncation is one-way. + Original usernames are lost, so we cannot restore them. + """ + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0063_virtualclassroom_virtualclassroomcustomization_and_more"), + ] + + operations = [ + migrations.RunPython(cleanup_social_media_usernames, reverse_cleanup), + ] diff --git a/web/migrations/0065_alter_profile_social_username_fields.py b/web/migrations/0065_alter_profile_social_username_fields.py new file mode 100644 index 000000000..c8a212da5 --- /dev/null +++ b/web/migrations/0065_alter_profile_social_username_fields.py @@ -0,0 +1,44 @@ +# Schema migration to enforce new field length constraints on social media usernames + +from django.db import migrations, models +import web.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("web", "0064_cleanup_social_media_usernames"), + ] + + operations = [ + migrations.AlterField( + model_name="profile", + name="discord_username", + field=models.CharField( + blank=True, + help_text="Your Discord username (e.g., User#1234)", + max_length=37, + validators=[web.validators.validate_discord_username], + ), + ), + migrations.AlterField( + model_name="profile", + name="slack_username", + field=models.CharField( + blank=True, + help_text="Your Slack username", + max_length=21, + validators=[web.validators.validate_slack_username], + ), + ), + migrations.AlterField( + model_name="profile", + name="github_username", + field=models.CharField( + blank=True, + help_text="Your GitHub username (without @)", + max_length=39, + validators=[web.validators.validate_github_username], + ), + ), + ] diff --git a/web/validators.py b/web/validators.py index 57953e49c..80fc6cc6a 100644 --- a/web/validators.py +++ b/web/validators.py @@ -108,7 +108,7 @@ def validate_github_username(value): raise ValidationError( _( "GitHub username can only contain alphanumeric characters and hyphens, " - "and cannot start or end with a hyphen." + "cannot start or end with a hyphen, and cannot contain consecutive hyphens." ), code="invalid_github_chars", ) From b06a618bba006da77ebbab9ef42a0a4ee21bf70a Mon Sep 17 00:00:00 2001 From: nourzakhama2003 Date: Sun, 22 Mar 2026 22:43:58 +0100 Subject: [PATCH 5/5] refactor: add type hints and clean up validator functions - Add explicit type hints to all validator function signatures: Optional[str] -> None - Remove redundant len(value) < 1 checks after early returns for empty values - Improves code clarity and enables better static analysis --- web/validators.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/web/validators.py b/web/validators.py index 80fc6cc6a..0d5aa2170 100644 --- a/web/validators.py +++ b/web/validators.py @@ -7,10 +7,12 @@ import re from django.core.exceptions import ValidationError +from typing import Optional + from django.utils.translation import gettext_lazy as _ -def validate_discord_username(value): +def validate_discord_username(value: Optional[str]) -> None: """ Validate Discord username format. @@ -58,7 +60,7 @@ def validate_discord_username(value): ) -def validate_slack_username(value): +def validate_slack_username(value: Optional[str]) -> None: """ Validate Slack username format. @@ -70,7 +72,7 @@ def validate_slack_username(value): if not value: return - if len(value) < 1 or len(value) > 21: + if len(value) > 21: raise ValidationError( _("Slack username must be between 1 and 21 characters long."), code="invalid_slack_length", @@ -86,7 +88,7 @@ def validate_slack_username(value): ) -def validate_github_username(value): +def validate_github_username(value: Optional[str]) -> None: """ Validate GitHub username format. @@ -98,7 +100,7 @@ def validate_github_username(value): if not value: return - if len(value) < 1 or len(value) > 39: + if len(value) > 39: raise ValidationError( _("GitHub username must be between 1 and 39 characters long."), code="invalid_github_length",