diff --git a/src/backend/activation_codes/admin.py b/src/backend/activation_codes/admin.py index 48d8cfc..106b71b 100644 --- a/src/backend/activation_codes/admin.py +++ b/src/backend/activation_codes/admin.py @@ -33,7 +33,6 @@ class ActivationCodeAdmin(admin.ModelAdmin): readonly_fields = ( "id", - "code", "current_uses", "created_at", "updated_at", @@ -79,6 +78,19 @@ class ActivationCodeAdmin(admin.ModelAdmin): actions = ["recompute_current_uses"] + def get_readonly_fields(self, request, obj=None): + """Make `code` readonly when editing an existing ActivationCode. + + When obj is None (creation form), `code` remains editable. When obj is + provided (editing), add `code` to readonly fields so it cannot be + changed after creation. + """ + # Start from the configured readonly_fields to preserve other read-only fields + ro_fields = list(self.readonly_fields) + if obj is not None: + ro_fields.append("code") + return tuple(ro_fields) + def usage_display(self, obj): """Display usage statistics.""" max_uses = obj.max_uses if obj.max_uses > 0 else "∞" diff --git a/src/backend/activation_codes/migrations/0001_initial.py b/src/backend/activation_codes/migrations/0001_initial.py index 23e918e..42fd40f 100644 --- a/src/backend/activation_codes/migrations/0001_initial.py +++ b/src/backend/activation_codes/migrations/0001_initial.py @@ -54,6 +54,12 @@ class Migration(migrations.Migration): help_text="The activation code that users will enter", max_length=50, unique=True, + validators=[ + django.core.validators.RegexValidator( + message="Code must be alphanumeric and contain no spaces or special characters", + regex="^[A-Z0-9]+$", + ) + ], verbose_name="activation code", ), ), diff --git a/src/backend/activation_codes/models.py b/src/backend/activation_codes/models.py index cc38f1d..fc8be85 100644 --- a/src/backend/activation_codes/models.py +++ b/src/backend/activation_codes/models.py @@ -7,6 +7,7 @@ import secrets import string from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator from django.db import IntegrityError, models, transaction from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -37,6 +38,12 @@ class ActivationCode(BaseModel): max_length=50, unique=True, default=generate_activation_code, + validators=[ + RegexValidator( + regex=r"^[A-Z0-9]+$", + message=_("Code must be alphanumeric and contain no spaces or special characters"), + ) + ], ) max_uses = models.PositiveIntegerField(