diff --git a/trojstenid/settings.py b/trojstenid/settings.py index edfb670..0f9a337 100644 --- a/trojstenid/settings.py +++ b/trojstenid/settings.py @@ -142,6 +142,7 @@ "reset_password_from_key": "trojstenid.users.forms.allauth.OurResetPasswordKeyForm", "set_password": "trojstenid.users.forms.allauth.OurSetPasswordForm", } +ACCOUNT_USERNAME_VALIDATORS = "trojstenid.users.models.username_validators" SOCIALACCOUNT_PROVIDERS = { "openid_connect": { diff --git a/trojstenid/users/management/__init__.py b/trojstenid/users/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/trojstenid/users/management/commands/__init__.py b/trojstenid/users/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/trojstenid/users/management/commands/normalize_usernames.py b/trojstenid/users/management/commands/normalize_usernames.py new file mode 100644 index 0000000..c8d2cbe --- /dev/null +++ b/trojstenid/users/management/commands/normalize_usernames.py @@ -0,0 +1,112 @@ +import re +import unicodedata + +from django.core.management.base import BaseCommand +from django.db import transaction + +from trojstenid.users.models import User, UsernameValidator + + +def normalize_username(username: str) -> str: + normalized = ( + unicodedata.normalize("NFKD", username).encode("ASCII", "ignore").decode() + ) + + if "@" in normalized: + parts = normalized.split("@", 1) + if parts[0]: + normalized = parts[0] + else: + normalized = parts[1] + + normalized = re.sub(r"[^\w.-]", "", normalized) + + return normalized + + +class Command(BaseCommand): + help = "Validate and normalize usernames" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Run without making changes to the database", + ) + + @transaction.atomic + def handle(self, *args, **options): + dry_run = options["dry_run"] + validator = UsernameValidator() + + users = User.objects.all() + + for user in users: + try: + validator(user.username) + except Exception: + new_username = normalize_username(user.username) + + if new_username == user.username: + continue + + # Check if normalized username becomes empty + if not new_username: + new_username = self.prompt_for_username( + user, + reason="Normalized username would be empty", + ) + + # Check for duplicates + duplicate_user = ( + User.objects.filter(username=new_username) + .exclude(id=user.id) + .first() + ) + if duplicate_user: + new_username = self.prompt_for_username( + user, + reason=f"Username '{new_username}' already exists (user ID: {duplicate_user.id})", + ) + + self.stdout.write(f"{user.username} ({user.id}) -> {new_username}") + + if not dry_run: + user.username = new_username + user.save() + + def prompt_for_username(self, user: User, reason: str) -> str: + """Prompt the user for a new username.""" + self.stdout.write(self.style.WARNING(f"\n{reason}")) + self.stdout.write(f"Old username: {user.username}") + self.stdout.write(f"Email: {user.email}") + self.stdout.write(f"Full name: {user.get_full_name() or '(not set)'}") + + while True: + new_username = input("Enter new username: ").strip() + + if not new_username: + self.stdout.write(self.style.ERROR("Username cannot be empty.")) + continue + + # Validate the username format + validator = UsernameValidator() + try: + validator(new_username) + except Exception as e: + self.stdout.write(self.style.ERROR(f"Invalid username: {e}")) + continue + + # Check for duplicates + duplicate_user = ( + User.objects.filter(username=new_username).exclude(id=user.id).first() + ) + if duplicate_user: + self.stdout.write( + self.style.ERROR( + f"Username '{new_username}' already in use by user ID {duplicate_user.id}" + ) + ) + continue + + return new_username diff --git a/trojstenid/users/models.py b/trojstenid/users/models.py index ae4f8f7..c205524 100644 --- a/trojstenid/users/models.py +++ b/trojstenid/users/models.py @@ -1,13 +1,16 @@ +import re from datetime import date from pathlib import PurePath from typing import TYPE_CHECKING from django.contrib.auth.models import AbstractUser, Group +from django.core import validators from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models import Q from django.urls import reverse from django.utils import timezone +from django.utils.deconstruct import deconstructible from oauth2_provider.models import AbstractApplication from ulid import ULID @@ -40,6 +43,16 @@ def get_db_converters(self, connection): return [] +@deconstructible() +class UsernameValidator(validators.RegexValidator): + regex = r"^[\w.-]+\Z" + message = "Používateľské meno môže obsahovať len písmená, čísla a znaky ./-/_" + flags = re.ASCII + + +username_validators = [UsernameValidator()] + + class User(AbstractUser): id: int