diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 746d5da..7d110d1 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -65,7 +65,8 @@ class OrganizationAdmin(admin.ModelAdmin): def get_readonly_fields(self, request, obj=None): readonly_fields = list(super().get_readonly_fields(request, obj) or []) - readonly_fields.append("namespace") # Always read-only + if obj: # If this is an edit (not a new organization) + readonly_fields.append("namespace") return readonly_fields diff --git a/src/servala/core/migrations/0003_alter_organization_namespace.py b/src/servala/core/migrations/0003_alter_organization_namespace.py deleted file mode 100644 index 3a01990..0000000 --- a/src/servala/core/migrations/0003_alter_organization_namespace.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.2b1 on 2025-04-09 14:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("core", "0002_alter_controlplanecrd_options_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="organization", - name="namespace", - field=models.CharField( - help_text="This namespace will be used for all Kubernetes resources.", - max_length=20, - null=True, - unique=True, - verbose_name="Kubernetes Namespace", - ), - ), - ] diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py index 7ebda11..62894d5 100644 --- a/src/servala/core/models/organization.py +++ b/src/servala/core/models/organization.py @@ -9,19 +9,19 @@ from django_scopes import ScopedManager, scopes_disabled from servala.core import rules as perms from servala.core.models.mixins import ServalaModelMixin +from servala.core.validators import kubernetes_name_validator class Organization(ServalaModelMixin, models.Model): name = models.CharField(max_length=100, verbose_name=_("Name")) - # The namespace is generated as "org-{id}" in accordance with RFC 1035 Label Names. - # It is nullable as we need to write to the database in order to read the ID, but should - # not be null in practical use. namespace = models.CharField( - max_length=20, + max_length=63, verbose_name=_("Kubernetes Namespace"), - null=True, unique=True, - help_text=_("This namespace will be used for all Kubernetes resources."), + help_text=_( + "This namespace will be used for all Kubernetes resources. Cannot be changed after creation." + ), + validators=[kubernetes_name_validator], ) billing_entity = models.ForeignKey( @@ -90,13 +90,6 @@ class Organization(ServalaModelMixin, models.Model): def __str__(self): return self.name - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if not self.namespace: - # Set namespace after initial save to ensure we have an ID - self.namespace = f"org-{self.pk}" - self.save(update_fields=["namespace"]) - class BillingEntity(ServalaModelMixin, models.Model): """ diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 41cc26c..d582ce9 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -1,4 +1,6 @@ +from django import forms from django.forms import ModelForm +from django.utils.translation import gettext_lazy as _ from servala.core.models import Organization from servala.frontend.forms.mixins import HtmxMixin @@ -7,4 +9,19 @@ from servala.frontend.forms.mixins import HtmxMixin class OrganizationForm(HtmxMixin, ModelForm): class Meta: model = Organization - fields = ("name",) + fields = ("name", "namespace") + widgets = { + "namespace": forms.TextInput( + attrs={ + "pattern": "[a-z0-9]([-a-z0-9]*[a-z0-9])?", + "title": _( + 'Lowercase alphanumeric characters or "-", must start and end with alphanumeric' + ), + } + ) + } + help_texts = { + "namespace": _( + "This namespace will be used for all resources and cannot be changed later." + ) + } diff --git a/src/servala/frontend/templates/frontend/organizations/update.html b/src/servala/frontend/templates/frontend/organizations/update.html index 0d55f22..993b76a 100644 --- a/src/servala/frontend/templates/frontend/organizations/update.html +++ b/src/servala/frontend/templates/frontend/organizations/update.html @@ -52,7 +52,7 @@
{{ form.instance.namespace }}
- {% translate "System-generated namespace for Kubernetes resources." %} + {% translate "The namespace cannot be changed after creation." %} diff --git a/src/tests/conftest.py b/src/tests/conftest.py index d88870e..a69e50c 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -15,12 +15,16 @@ def origin(): @pytest.fixture def organization(origin): - return Organization.objects.create(name="Test Org", origin=origin) + return Organization.objects.create( + name="Test Org", namespace="test-org", origin=origin + ) @pytest.fixture def other_organization(origin): - return Organization.objects.create(name="Test Org Alternate", origin=origin) + return Organization.objects.create( + name="Test Org Alternate", namespace="test-org-alternate", origin=origin + ) @pytest.fixture diff --git a/src/tests/test_views.py b/src/tests/test_views.py index 5ec429e..23475fc 100644 --- a/src/tests/test_views.py +++ b/src/tests/test_views.py @@ -23,7 +23,6 @@ def test_root_view_invalid_urls_404(client): def test_owner_can_access_dashboard(client, org_owner, organization): client.force_login(org_owner) response = client.get(organization.urls.base) - assert organization.namespace == f"org-{organization.pk}" assert response.status_code == 200 assert organization.name in response.content.decode()