Organization Namespace Generation #46

Merged
rixx merged 2 commits from 44-namespace-generation into main 2025-04-09 14:07:38 +00:00
7 changed files with 43 additions and 33 deletions

View file

@ -65,8 +65,7 @@ class OrganizationAdmin(admin.ModelAdmin):
def get_readonly_fields(self, request, obj=None):
readonly_fields = list(super().get_readonly_fields(request, obj) or [])
if obj: # If this is an edit (not a new organization)
readonly_fields.append("namespace")
readonly_fields.append("namespace") # Always read-only
return readonly_fields

View file

@ -0,0 +1,24 @@
# 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",
),
),
]

View file

@ -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=63,
max_length=20,
verbose_name=_("Kubernetes Namespace"),
null=True,
unique=True,
help_text=_(
"This namespace will be used for all Kubernetes resources. Cannot be changed after creation."
),
validators=[kubernetes_name_validator],
help_text=_("This namespace will be used for all Kubernetes resources."),
)
billing_entity = models.ForeignKey(
@ -90,6 +90,13 @@ 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):
"""

View file

@ -1,6 +1,4 @@
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
@ -9,19 +7,4 @@ from servala.frontend.forms.mixins import HtmxMixin
class OrganizationForm(HtmxMixin, ModelForm):
class Meta:
model = Organization
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."
)
}
fields = ("name",)

View file

@ -52,7 +52,7 @@
</th>
<td>
<div>{{ form.instance.namespace }}</div>
<small class="text-muted">{% translate "The namespace cannot be changed after creation." %}</small>
<small class="text-muted">{% translate "System-generated namespace for Kubernetes resources." %}</small>
</td>
</tr>
</tbody>

View file

@ -15,16 +15,12 @@ def origin():
@pytest.fixture
def organization(origin):
return Organization.objects.create(
name="Test Org", namespace="test-org", origin=origin
)
return Organization.objects.create(name="Test Org", origin=origin)
@pytest.fixture
def other_organization(origin):
return Organization.objects.create(
name="Test Org Alternate", namespace="test-org-alternate", origin=origin
)
return Organization.objects.create(name="Test Org Alternate", origin=origin)
@pytest.fixture

View file

@ -23,6 +23,7 @@ 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()