diff --git a/web/forms.py b/web/forms.py index 96489d524..eafe30656 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,31 @@ def clean_username(self): raise forms.ValidationError("This username is already taken. Please choose a different one.") return 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: + validator(username) + except ValidationError as e: + # 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): + return self._clean_social_media_username("discord_username", validate_discord_username) + + def clean_slack_username(self): + return self._clean_social_media_username("slack_username", validate_slack_username) + + def clean_github_username(self): + return self._clean_social_media_username("github_username", validate_github_username) + def save(self, commit=True): user = super().save(commit=False) if commit: 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/models.py b/web/models.py index 6b8ea8ef4..e27de9dcb 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=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=21, # Matches Slack's 21 character limit + blank=True, + validators=[validate_slack_username], + help_text="Your Slack username", + ) + github_username = models.CharField( + max_length=39, # Matches GitHub's 39 character limit + 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) @@ -102,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 new file mode 100644 index 000000000..8dbaf2128 --- /dev/null +++ b/web/tests/test_social_media_validators.py @@ -0,0 +1,348 @@ +""" +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.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.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.""" + 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.assertEqual(cm.exception.code, "invalid_slack_length") + + 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.assertEqual(cm.exception.code, "invalid_github_length") + + 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_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 + + +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.""" + # 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.""" + profile = Profile( + user=self.user, + discord_username="A", # Too short + slack_username="valid", + github_username="valid", + ) + 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( + user=self.user, + discord_username="ValidUser", + slack_username="123invalid", # Starts with number + github_username="valid", + ) + 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( + user=self.user, + discord_username="ValidUser", + slack_username="valid", + github_username="-invalid", # Starts with hyphen + ) + with self.assertRaises(ValidationError) as cm: + profile.full_clean() + self.assertIn("github_username", cm.exception.message_dict) + + +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..0d5aa2170 --- /dev/null +++ b/web/validators.py @@ -0,0 +1,116 @@ +""" +Custom validators for Alpha One Labs Education Platform. + +Validates usernames across various social media platforms. +""" + +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: Optional[str]) -> None: + """ + Validate Discord username format. + + Discord usernames: + - Must be 2-32 characters long + - Can contain letters, numbers, underscores, hyphens, and dots + - Optional discriminator format: username#1234 (4 digits) + """ + if not value: + return # Allow empty values; use blank=True in model if needed + + # Validate and extract the discriminator (e.g., "User#1234") + username_part = value + if "#" in 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( + _("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: Optional[str]) -> None: + """ + 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) > 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: Optional[str]) -> None: + """ + 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) > 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, " + "cannot start or end with a hyphen, and cannot contain consecutive hyphens." + ), + code="invalid_github_chars", + )