Compare commits

...

10 commits

Author SHA1 Message Date
848e5162bc Fix update view
All checks were successful
Tests / test (push) Successful in 29s
2025-12-10 10:21:04 +01:00
8da8f2fc44 Fix FQDN generation 2025-12-10 09:04:25 +01:00
16680aada0 Fix custom form config 2025-12-10 09:04:25 +01:00
6956c625e3 Fix default form config 2025-12-10 09:04:25 +01:00
896b16282f Use instance name where appropriate 2025-12-10 09:04:25 +01:00
8410b49bc5 Make display name editable 2025-12-10 09:04:25 +01:00
d598da9bf1 Use display name where appropriate 2025-12-10 09:04:25 +01:00
a9dbd80e53 Add model field, migrations, and backend methods 2025-12-10 09:04:25 +01:00
bdce85feae Add instance name prefix setting 2025-12-10 09:04:25 +01:00
a7d301c857 Add missing test fixture 2025-12-10 09:04:25 +01:00
20 changed files with 201 additions and 92 deletions

View file

@ -77,3 +77,7 @@ SERVALA_ODOO_HELPDESK_TEAM_ID='5'
# OSB API authentication settings # OSB API authentication settings
SERVALA_OSB_USERNAME='' SERVALA_OSB_USERNAME=''
SERVALA_OSB_PASSWORD='' SERVALA_OSB_PASSWORD=''
# Prefix for auto-generated Kubernetes resource names for service instances.
# Format: {prefix}-{hash}. Defaults to 'si' (service instance).
SERVALA_INSTANCE_NAME_PREFIX='si'

View file

@ -301,7 +301,7 @@ The Servala Team"""
"core_serviceinstance_change", service_instance.pk "core_serviceinstance_change", service_instance.pk
) )
description_parts.append( description_parts.append(
f"Instance: {service_instance.name} - {instance_url}" f"Instance: {service_instance.display_name} ({service_instance.name}) - {instance_url}"
) )
else: else:
description_parts.append(f"Instance: {instance_id}") description_parts.append(f"Instance: {instance_id}")

View file

@ -9,17 +9,17 @@ from servala.core.models import ControlPlaneCRD
from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon
# Fields that must be present in every form # Fields that must be present in every form
MANDATORY_FIELDS = ["name"] MANDATORY_FIELDS = ["display_name"]
# Default field configurations - fields that can be included with just a mapping # Default field configurations - fields that can be included with just a mapping
# to avoid administrators having to duplicate common information # to avoid administrators having to duplicate common information
DEFAULT_FIELD_CONFIGS = { DEFAULT_FIELD_CONFIGS = {
"name": { "display_name": {
"type": "text", "type": "text",
"label": "Instance Name", "label": "Instance Name",
"help_text": "Unique name for the new instance", "help_text": "",
"required": True, "required": True,
"max_length": 63, "max_length": 100,
}, },
"spec.parameters.service.fqdn": { "spec.parameters.service.fqdn": {
"type": "array", "type": "array",
@ -51,11 +51,6 @@ class FormGeneratorMixin:
crd = getattr(crd, "pk", crd) # can be int or object crd = getattr(crd, "pk", crd) # can be int or object
self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd) self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd)
if self.instance and hasattr(self.instance, "name") and self.instance.name:
if "name" in self.fields:
self.fields["name"].disabled = True
self.fields["name"].widget = forms.HiddenInput()
def has_mandatory_fields(self, field_list): def has_mandatory_fields(self, field_list):
for field_name in field_list: for field_name in field_list:
if field_name in self.fields and self.fields[field_name].required: if field_name in self.fields and self.fields[field_name].required:
@ -307,15 +302,6 @@ class CustomFormMixin(FormGeneratorMixin):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._apply_field_config() self._apply_field_config()
if (
self.instance
and hasattr(self.instance, "name")
and self.instance.name
and "name" in self.fields
):
self.fields["name"].widget = forms.HiddenInput()
self.fields["name"].disabled = True
self.fields.pop("context", None)
def _apply_field_config(self): def _apply_field_config(self):
for fieldset in self.form_config.get("fieldsets", []): for fieldset in self.form_config.get("fieldsets", []):
@ -472,7 +458,7 @@ def generate_custom_form_class(form_config, model):
""" """
Generate a custom (user-friendly) form class from form_config JSON. Generate a custom (user-friendly) form class from form_config JSON.
""" """
field_list = ["context", "name"] field_list = ["context", "display_name"]
for fieldset in form_config.get("fieldsets", []): for fieldset in form_config.get("fieldsets", []):
for field_config in fieldset.get("fields", []): for field_config in fieldset.get("fields", []):

View file

@ -23,9 +23,9 @@ def generate_django_model(schema, group, version, kind):
""" """
Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema.
""" """
# We always need these three fields to know our own name and our full namespace # We always need these fields to know our display name and our full namespace
model_fields = {"__module__": "crd_models"} model_fields = {"__module__": "crd_models"}
for field_name in ("name", "context"): for field_name in ("display_name", "context"):
model_fields[field_name] = duplicate_field(field_name, ServiceInstance) model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
# All other fields are generated from the schema, except for the # All other fields are generated from the schema, except for the

View file

@ -254,7 +254,7 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
if not schema or not (spec_schema := schema.get("properties", {}).get("spec")): if not schema or not (spec_schema := schema.get("properties", {}).get("spec")):
return return
valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"} valid_paths = self._extract_field_paths(spec_schema, "spec") | {"display_name"}
included_mappings = set() included_mappings = set()
errors = [] errors = []
for fieldset in form_config.get("fieldsets", []): for fieldset in form_config.get("fieldsets", []):

View file

@ -195,6 +195,7 @@ class Command(BaseCommand):
compute_plan_assignment=instance.compute_plan_assignment, compute_plan_assignment=instance.compute_plan_assignment,
control_plane=instance.context.control_plane, control_plane=instance.context.control_plane,
instance_name=instance.name, instance_name=instance.name,
display_name=instance.display_name,
organization=instance.organization, organization=instance.organization,
service=instance.context.service_offering.service, service=instance.context.service_offering.service,
) )

View file

@ -0,0 +1,46 @@
from django.db import migrations, models
import servala.core.validators
def populate_display_name(apps, schema_editor):
"""
For existing instances, copy name to display_name.
Existing instances already have their name matching the k8s resource,
so we cannot add a prefix.
"""
ServiceInstance = apps.get_model("core", "ServiceInstance")
for instance in ServiceInstance.objects.all():
instance.display_name = instance.name
instance.save(update_fields=["display_name"])
class Migration(migrations.Migration):
dependencies = [
("core", "0018_add_invoice_grouping_to_organization_origin"),
]
operations = [
migrations.AlterField(
model_name="serviceinstance",
name="name",
field=models.CharField(
max_length=63,
validators=[servala.core.validators.kubernetes_name_validator],
verbose_name="Resource Name",
),
),
# Add display_name field with a temporary default
migrations.AddField(
model_name="serviceinstance",
name="display_name",
field=models.CharField(
default="",
help_text="Display name for this instance",
max_length=100,
verbose_name="Name",
),
preserve_default=False,
),
migrations.RunPython(populate_display_name, migrations.RunPython.noop),
]

View file

@ -18,6 +18,10 @@ from encrypted_fields.fields import EncryptedJSONField
from kubernetes import client, config from kubernetes import client, config
from kubernetes.client.rest import ApiException from kubernetes.client.rest import ApiException
import hashlib
from django.conf import settings
from servala.core import rules as perms from servala.core import rules as perms
from servala.core.models.mixins import ServalaModelMixin from servala.core.models.mixins import ServalaModelMixin
from servala.core.validators import kubernetes_name_validator from servala.core.validators import kubernetes_name_validator
@ -615,8 +619,16 @@ class ServiceInstance(ServalaModelMixin, models.Model):
on the fly. on the fly.
""" """
# The Kubernetes resource name (metadata.name). This field is immutable after
# creation and is auto-generated for new instances. Do not modify directly!
name = models.CharField( name = models.CharField(
max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator] max_length=63,
verbose_name=_("Resource Name"),
validators=[kubernetes_name_validator],
)
display_name = models.CharField(
max_length=100,
verbose_name=_("Name"),
) )
organization = models.ForeignKey( organization = models.ForeignKey(
to="core.Organization", to="core.Organization",
@ -686,6 +698,24 @@ class ServiceInstance(ServalaModelMixin, models.Model):
spec_data = prune_empty_data(spec_data) spec_data = prune_empty_data(spec_data)
return spec_data return spec_data
@staticmethod
def generate_resource_name(organization, display_name, service, attempt=0):
"""
Generate a unique Kubernetes-compatible resource name.
Format: {prefix}-{sha256[:8]}
The hash input is: org_slug:display_name:service_slug[:attempt if > 0]
On collision, we retry with an incremented attempt number included in hash.
"""
hash_input = (
f"{organization.slug}:{display_name.lower().strip()}:{service.slug}"
)
if attempt > 0:
hash_input += f":{attempt}"
hash_value = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()[:8]
return f"{settings.SERVALA_INSTANCE_NAME_PREFIX}-{hash_value}"
@staticmethod @staticmethod
def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment): def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment):
""" """
@ -719,16 +749,20 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment, compute_plan_assignment,
control_plane, control_plane,
instance_name=None, instance_name=None,
display_name=None,
organization=None, organization=None,
service=None, service=None,
): ):
""" """
Build Kubernetes annotations for billing integration. Build Kubernetes annotations for billing integration and display name.
""" """
from servala.core.models.organization import InvoiceGroupingChoice from servala.core.models.organization import InvoiceGroupingChoice
annotations = {} annotations = {}
if display_name:
annotations["servala.com/displayName"] = display_name
if compute_plan_assignment: if compute_plan_assignment:
annotations["servala.com/erp_product_id_resource"] = str( annotations["servala.com/erp_product_id_resource"] = str(
compute_plan_assignment.odoo_product_id compute_plan_assignment.odoo_product_id
@ -830,18 +864,35 @@ class ServiceInstance(ServalaModelMixin, models.Model):
@transaction.atomic @transaction.atomic
def create_instance( def create_instance(
cls, cls,
name, display_name,
organization, organization,
context, context,
created_by, created_by,
spec_data, spec_data,
compute_plan_assignment=None, compute_plan_assignment=None,
): ):
service = context.service_offering.service
name = None
for attempt in range(10):
name = cls.generate_resource_name(
organization, display_name, service, attempt
)
if not cls.objects.filter(
name=name, organization=organization, context=context
).exists():
break
else:
message = _(
"Could not generate a unique resource name. Please try a different display name."
)
raise ValidationError(organization.add_support_message(message))
# Ensure the namespace exists # Ensure the namespace exists
context.control_plane.get_or_create_namespace(organization) context.control_plane.get_or_create_namespace(organization)
try: try:
instance = cls.objects.create( instance = cls.objects.create(
name=name, name=name,
display_name=display_name,
organization=organization, organization=organization,
created_by=created_by, created_by=created_by,
context=context, context=context,
@ -883,8 +934,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment=compute_plan_assignment, compute_plan_assignment=compute_plan_assignment,
control_plane=context.control_plane, control_plane=context.control_plane,
instance_name=name, instance_name=name,
display_name=display_name,
organization=organization, organization=organization,
service=context.service_offering.service, service=service,
) )
if annotations: if annotations:
create_data["metadata"]["annotations"] = annotations create_data["metadata"]["annotations"] = annotations
@ -941,6 +993,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment=plan_to_use, compute_plan_assignment=plan_to_use,
control_plane=self.context.control_plane, control_plane=self.context.control_plane,
instance_name=self.name, instance_name=self.name,
display_name=self.display_name,
organization=self.organization, organization=self.organization,
service=self.context.service_offering.service, service=self.context.service_offering.service,
) )
@ -1065,7 +1118,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
if not self.context.django_model: if not self.context.django_model:
return return
return self.context.django_model( return self.context.django_model(
name=self.name, display_name=self.display_name,
context=self.context, context=self.context,
spec=self.spec, spec=self.spec,
# We pass -1 as ID in order to make it clear that a) this object exists (remotely), # We pass -1 as ID in order to make it clear that a) this object exists (remotely),

View file

@ -123,7 +123,7 @@ class ServiceInstanceFilterForm(forms.Form):
class ServiceInstanceDeleteForm(forms.ModelForm): class ServiceInstanceDeleteForm(forms.ModelForm):
name = forms.CharField( name = forms.CharField(
label=_("Instance Name"), label=_("Resource Name"),
max_length=63, max_length=63,
widget=forms.TextInput(attrs={"class": "form-control"}), widget=forms.TextInput(attrs={"class": "form-control"}),
) )
@ -132,7 +132,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
kwargs["initial"] = {"name": ""} kwargs["initial"] = {"name": ""}
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["name"].help_text = _( self.fields["name"].help_text = _(
"To confirm deletion, please type the instance name: <strong>{instance_name}</strong>" "To confirm deletion, please type the resource name: <strong>{instance_name}</strong>"
).format(instance_name=self.instance.name) ).format(instance_name=self.instance.name)
def clean_name(self): def clean_name(self):
@ -140,7 +140,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
if entered_name != self.instance.name: if entered_name != self.instance.name:
raise forms.ValidationError( raise forms.ValidationError(
_( _(
"The entered name does not match the instance name. Deletion not confirmed." "The entered name does not match the resource name. Deletion not confirmed."
) )
) )
return entered_name return entered_name

View file

@ -96,7 +96,7 @@
<tr> <tr>
<td> <td>
<a href="{{ instance.urls.base }}" <a href="{{ instance.urls.base }}"
class="fw-semibold text-decoration-none">{{ instance.name }}</a> class="fw-semibold text-decoration-none">{{ instance.display_name }}</a>
</td> </td>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">

View file

@ -7,7 +7,7 @@
{% csrf_token %} {% csrf_token %}
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="deleteInstanceModalLabel"> <h5 class="modal-title" id="deleteInstanceModalLabel">
{% blocktranslate with instance_name=instance.name %}Confirm Deletion of {{ instance_name }}{% endblocktranslate %} {% blocktranslate with instance_name=instance.display_name %}Confirm Deletion of {{ instance_name }}{% endblocktranslate %}
</h5> </h5>
<button type="button" <button type="button"
class="btn-close" class="btn-close"

View file

@ -2,7 +2,7 @@
{% load i18n static pprint_filters %} {% load i18n static pprint_filters %}
{% block html_title %} {% block html_title %}
{% block page_title %} {% block page_title %}
{{ instance.name }} {{ instance.display_name }}
{% endblock page_title %} {% endblock page_title %}
{% endblock html_title %} {% endblock html_title %}
{% block page_title_extra %} {% block page_title_extra %}
@ -39,6 +39,10 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<dl class="row"> <dl class="row">
<dt class="col-sm-4">{% translate "Resource Name" %}</dt>
<dd class="col-sm-8">
<code>{{ instance.name }}</code>
</dd>
<dt class="col-sm-4">{% translate "Service" %}</dt> <dt class="col-sm-4">{% translate "Service" %}</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
{{ instance.context.service_definition.service.name }} {{ instance.context.service_definition.service.name }}

View file

@ -5,7 +5,7 @@
{% block html_title %} {% block html_title %}
{% block page_title %} {% block page_title %}
{% block title %} {% block title %}
{% blocktranslate with instance_name=instance.name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %} {% blocktranslate with instance_name=instance.display_name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %}
{% endblock %} {% endblock %}
{% endblock page_title %} {% endblock page_title %}
{% endblock html_title %} {% endblock html_title %}

View file

@ -23,6 +23,7 @@
<thead> <thead>
<tr> <tr>
<th>{% translate "Name" %}</th> <th>{% translate "Name" %}</th>
<th>{% translate "Resource Name" %}</th>
<th>{% translate "Service" %}</th> <th>{% translate "Service" %}</th>
<th>{% translate "Service Provider" %}</th> <th>{% translate "Service Provider" %}</th>
<th>{% translate "Service Provider Zone" %}</th> <th>{% translate "Service Provider Zone" %}</th>
@ -33,8 +34,9 @@
{% for instance in instances %} {% for instance in instances %}
<tr> <tr>
<td> <td>
<a href="{{ instance.urls.base }}">{{ instance.name }}</a> <a href="{{ instance.urls.base }}">{{ instance.display_name }}</a>
</td> </td>
<td><code>{{ instance.name }}</code></td>
<td>{{ instance.context.service_definition.service.name }}</td> <td>{{ instance.context.service_definition.service.name }}</td>
<td>{{ instance.context.service_offering.provider.name }}</td> <td>{{ instance.context.service_offering.provider.name }}</td>
<td>{{ instance.context.control_plane.name }}</td> <td>{{ instance.context.control_plane.name }}</td>
@ -42,7 +44,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5">{% translate "No service instances found." %}</td> <td colspan="6">{% translate "No service instances found." %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -276,7 +276,7 @@ class ServiceInstanceCreateView(OrganizationViewMixin, HtmxViewMixin, DetailView
try: try:
service_instance = ServiceInstance.create_instance( service_instance = ServiceInstance.create_instance(
organization=self.request.organization, organization=self.request.organization,
name=form.cleaned_data["name"], display_name=form.cleaned_data["display_name"],
context=self.context_object, context=self.context_object,
created_by=request.user, created_by=request.user,
spec_data=form.get_nested_data().get("spec"), spec_data=form.get_nested_data().get("spec"),
@ -618,7 +618,7 @@ class ServiceInstanceUpdateView(
messages.success( messages.success(
self.request, self.request,
_("Service instance '{name}' updated successfully.").format( _("Service instance '{name}' updated successfully.").format(
name=self.object.name name=self.object.display_name
), ),
) )
return redirect(self.object.urls.base) return redirect(self.object.urls.base)
@ -679,7 +679,7 @@ class ServiceInstanceDeleteView(
messages.success( messages.success(
self.request, self.request,
_("Service instance '{name}' has been scheduled for deletion.").format( _("Service instance '{name}' has been scheduled for deletion.").format(
name=self.object.name name=self.object.display_name
), ),
) )
response = HttpResponse() response = HttpResponse()
@ -690,7 +690,7 @@ class ServiceInstanceDeleteView(
self.request, self.request,
self.organization.add_support_message( self.organization.add_support_message(
_( _(
f"An error occurred while trying to delete instance '{self.object.name}': {str(e)}." f"An error occurred while trying to delete instance '{self.object.display_name}': {str(e)}."
) )
), ),
) )

View file

@ -270,6 +270,9 @@ SESSION_COOKIE_SECURE = not DEBUG
DEFAULT_LABEL_KEY = "appcat.vshn.io/provider-config" DEFAULT_LABEL_KEY = "appcat.vshn.io/provider-config"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Prefix for auto-generated Kubernetes resource names for service instances
SERVALA_INSTANCE_NAME_PREFIX = os.environ.get("SERVALA_INSTANCE_NAME_PREFIX", "si")
# TODO # TODO
TIME_ZONE = "UTC" TIME_ZONE = "UTC"

View file

@ -1,6 +1,6 @@
const initializeFqdnGeneration = (prefix) => { const initializeFqdnGeneration = (prefix) => {
const nameField = document.querySelector(`input#id_${prefix}-name`); const nameField = document.querySelector(`input#id_${prefix}-display_name`);
if (!nameField) return if (!nameField) return
// Try to find array input first (DynamicArrayWidget), then fallback to regular text input // Try to find array input first (DynamicArrayWidget), then fallback to regular text input

View file

@ -74,6 +74,11 @@ def org_member(organization):
return member return member
@pytest.fixture
def user():
return User.objects.create(email="generic-user@example.org", password="example")
@pytest.fixture @pytest.fixture
def test_service_category(): def test_service_category():
return ServiceCategory.objects.create( return ServiceCategory.objects.create(

View file

@ -717,8 +717,10 @@ def test_ticket_includes_organization_and_instance_when_found(
service_definition=service_definition, service_definition=service_definition,
) )
instance_name = "test-instance-123" instance_name = "test-instance-123"
instance_display_name = "Test Instance 123"
service_instance = ServiceInstance.objects.create( service_instance = ServiceInstance.objects.create(
name=instance_name, name=instance_name,
display_name=instance_display_name,
organization=organization, organization=organization,
context=crd, context=crd,
) )
@ -739,7 +741,10 @@ def test_ticket_includes_organization_and_instance_when_found(
assert "admin/core/organization" in call_kwargs["description"] assert "admin/core/organization" in call_kwargs["description"]
assert f"/{organization.pk}/" in call_kwargs["description"] assert f"/{organization.pk}/" in call_kwargs["description"]
# Check instance is included # Check instance is included (format: "display_name (resource_name)")
assert f"Instance: {service_instance.name}" in call_kwargs["description"] assert (
f"Instance: {service_instance.display_name} ({service_instance.name})"
in call_kwargs["description"]
)
assert "admin/core/serviceinstance" in call_kwargs["description"] assert "admin/core/serviceinstance" in call_kwargs["description"]
assert f"/{service_instance.pk}/" in call_kwargs["description"] assert f"/{service_instance.pk}/" in call_kwargs["description"]

View file

@ -22,7 +22,7 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
} }
], ],
@ -32,7 +32,7 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
crd.service_definition = service_def crd.service_definition = service_def
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
class Meta: class Meta:
app_label = "test" app_label = "test"
@ -193,7 +193,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
"""Test that choice fields use custom choices when provided in form_config""" """Test that choice fields use custom choices when provided in form_config"""
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
environment = models.CharField( environment = models.CharField(
max_length=20, max_length=20,
choices=[ choices=[
@ -215,7 +215,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -246,7 +246,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
def test_choice_field_uses_control_plane_choices_when_no_custom_choices(): def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
environment = models.CharField( environment = models.CharField(
max_length=20, max_length=20,
choices=[ choices=[
@ -267,7 +267,7 @@ def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -292,7 +292,7 @@ def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
def test_choice_field_validates_against_control_plane_choices(): def test_choice_field_validates_against_control_plane_choices():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
environment = models.CharField( environment = models.CharField(
max_length=20, max_length=20,
choices=[ choices=[
@ -313,7 +313,7 @@ def test_choice_field_validates_against_control_plane_choices():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -330,15 +330,15 @@ def test_choice_field_validates_against_control_plane_choices():
form_class = generate_custom_form_class(form_config, TestModel) form_class = generate_custom_form_class(form_config, TestModel)
form = form_class(data={"name": "test-service", "environment": "dev"}) form = form_class(data={"display_name": "test-service", "environment": "dev"})
form.fields["context"].required = False # Skip context validation form.fields["context"].required = False # Skip context validation
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
form = form_class(data={"name": "test-service", "environment": "prod"}) form = form_class(data={"display_name": "test-service", "environment": "prod"})
form.fields["context"].required = False # Skip context validation form.fields["context"].required = False # Skip context validation
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
form = form_class(data={"name": "test-service", "environment": "invalid"}) form = form_class(data={"display_name": "test-service", "environment": "invalid"})
form.fields["context"].required = False # Skip context validation form.fields["context"].required = False # Skip context validation
assert not form.is_valid() assert not form.is_valid()
assert "environment" in form.errors assert "environment" in form.errors
@ -368,7 +368,7 @@ def test_admin_form_validates_choice_values_against_schema():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
}, },
{ {
"type": "choice", "type": "choice",
@ -399,7 +399,7 @@ def test_admin_form_validates_choice_values_against_schema():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
}, },
{ {
"type": "choice", "type": "choice",
@ -431,7 +431,7 @@ def test_admin_form_validates_choice_values_against_schema():
def test_number_field_min_max_sets_widget_attributes(): def test_number_field_min_max_sets_widget_attributes():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
port = models.IntegerField() port = models.IntegerField()
replica_count = models.IntegerField() replica_count = models.IntegerField()
@ -446,7 +446,7 @@ def test_number_field_min_max_sets_widget_attributes():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -494,7 +494,7 @@ def test_number_field_min_max_sets_widget_attributes():
def test_default_value_for_all_field_types(): def test_default_value_for_all_field_types():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
description = models.TextField() description = models.TextField()
port = models.IntegerField() port = models.IntegerField()
environment = models.CharField( environment = models.CharField(
@ -518,7 +518,7 @@ def test_default_value_for_all_field_types():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"default_value": "default-name", "default_value": "default-name",
}, },
{ {
@ -559,7 +559,7 @@ def test_default_value_for_all_field_types():
form_class = generate_custom_form_class(form_config, TestModel) form_class = generate_custom_form_class(form_config, TestModel)
form = form_class() form = form_class()
assert form.fields["name"].initial == "default-name" assert form.fields["display_name"].initial == "default-name"
assert form.fields["description"].initial == "Default description text" assert form.fields["description"].initial == "Default description text"
assert form.fields["port"].initial == "8080" assert form.fields["port"].initial == "8080"
assert form.fields["environment"].initial == "dev" assert form.fields["environment"].initial == "dev"
@ -570,7 +570,7 @@ def test_default_value_for_all_field_types():
def test_default_value_not_override_existing_instance(): def test_default_value_not_override_existing_instance():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
port = models.IntegerField() port = models.IntegerField()
class Meta: class Meta:
@ -583,7 +583,7 @@ def test_default_value_not_override_existing_instance():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"default_value": "default-name", "default_value": "default-name",
}, },
{ {
@ -597,11 +597,11 @@ def test_default_value_not_override_existing_instance():
] ]
} }
instance = TestModel(name="existing-name", port=3000) instance = TestModel(display_name="existing-name", port=3000)
form_class = generate_custom_form_class(form_config, TestModel) form_class = generate_custom_form_class(form_config, TestModel)
form = form_class(instance=instance) form = form_class(instance=instance)
assert form.initial["name"] == "existing-name" assert form.initial["display_name"] == "existing-name"
assert form.initial["port"] == 3000 assert form.initial["port"] == 3000
@ -708,7 +708,7 @@ def test_form_config_handles_empty_string_as_none():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"max_length": "", # Empty string "max_length": "", # Empty string
}, },
] ]
@ -744,7 +744,7 @@ def test_single_element_choices_are_normalized():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
}, },
{ {
"type": "choice", "type": "choice",
@ -879,7 +879,7 @@ def test_three_plus_element_choices_fail_validation():
def test_field_with_default_config_only_needs_mapping(): def test_field_with_default_config_only_needs_mapping():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
class Meta: class Meta:
app_label = "test" app_label = "test"
@ -889,7 +889,7 @@ def test_field_with_default_config_only_needs_mapping():
{ {
"fields": [ "fields": [
{ {
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
}, },
] ]
} }
@ -899,16 +899,16 @@ def test_field_with_default_config_only_needs_mapping():
form_class = generate_custom_form_class(minimal_config, TestModel) form_class = generate_custom_form_class(minimal_config, TestModel)
form = form_class() form = form_class()
name_field = form.fields["name"] name_field = form.fields["display_name"]
assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"] assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"]
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
assert name_field.required == DEFAULT_FIELD_CONFIGS["name"]["required"] assert name_field.required == DEFAULT_FIELD_CONFIGS["display_name"]["required"]
def test_field_with_default_config_can_override_defaults(): def test_field_with_default_config_can_override_defaults():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
class Meta: class Meta:
app_label = "test" app_label = "test"
@ -918,7 +918,7 @@ def test_field_with_default_config_can_override_defaults():
{ {
"fields": [ "fields": [
{ {
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"label": "Custom Name Label", "label": "Custom Name Label",
"required": False, "required": False,
}, },
@ -930,16 +930,16 @@ def test_field_with_default_config_can_override_defaults():
form_class = generate_custom_form_class(override_config, TestModel) form_class = generate_custom_form_class(override_config, TestModel)
form = form_class() form = form_class()
name_field = form.fields["name"] name_field = form.fields["display_name"]
assert name_field.label == "Custom Name Label" assert name_field.label == "Custom Name Label"
assert name_field.required is False assert name_field.required is False
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
def test_empty_values_dont_override_default_configs(): def test_empty_values_dont_override_default_configs():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
class Meta: class Meta:
app_label = "test" app_label = "test"
@ -949,7 +949,7 @@ def test_empty_values_dont_override_default_configs():
{ {
"fields": [ "fields": [
{ {
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"type": "", "type": "",
"label": "", "label": "",
"help_text": None, "help_text": None,
@ -964,11 +964,11 @@ def test_empty_values_dont_override_default_configs():
form_class = generate_custom_form_class(admin_form_config, TestModel) form_class = generate_custom_form_class(admin_form_config, TestModel)
form = form_class() form = form_class()
name_field = form.fields["name"] name_field = form.fields["display_name"]
assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"] assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"]
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"] assert name_field.max_length == DEFAULT_FIELD_CONFIGS["display_name"]["max_length"]
assert name_field.required is False # Was overridden by explicit False assert name_field.required is False # Was overridden by explicit False
@ -976,7 +976,7 @@ def test_empty_values_dont_override_default_configs():
def test_number_field_validates_min_max_values(): def test_number_field_validates_min_max_values():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
port = models.IntegerField() port = models.IntegerField()
class Meta: class Meta:
@ -990,7 +990,7 @@ def test_number_field_validates_min_max_values():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -1009,26 +1009,26 @@ def test_number_field_validates_min_max_values():
form_class = generate_custom_form_class(form_config, TestModel) form_class = generate_custom_form_class(form_config, TestModel)
# Test value below minimum fails validation # Test value below minimum fails validation
form = form_class(data={"name": "test-service", "port": 0}) form = form_class(data={"display_name": "test-service", "port": 0})
form.fields["context"].required = False form.fields["context"].required = False
assert not form.is_valid() assert not form.is_valid()
assert "port" in form.errors assert "port" in form.errors
# Test value above maximum fails validation # Test value above maximum fails validation
form = form_class(data={"name": "test-service", "port": 65536}) form = form_class(data={"display_name": "test-service", "port": 65536})
form.fields["context"].required = False form.fields["context"].required = False
assert not form.is_valid() assert not form.is_valid()
assert "port" in form.errors assert "port" in form.errors
# Test valid value passes validation # Test valid value passes validation
form = form_class(data={"name": "test-service", "port": 8080}) form = form_class(data={"display_name": "test-service", "port": 8080})
form.fields["context"].required = False form.fields["context"].required = False
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
def test_number_field_with_addon_text_roundtrip(): def test_number_field_with_addon_text_roundtrip():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
disk_size = models.IntegerField() disk_size = models.IntegerField()
class Meta: class Meta:
@ -1041,7 +1041,7 @@ def test_number_field_with_addon_text_roundtrip():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -1059,7 +1059,7 @@ def test_number_field_with_addon_text_roundtrip():
form = form_class(initial={"name": "test-instance", "disk_size": "25Gi"}) form = form_class(initial={"name": "test-instance", "disk_size": "25Gi"})
assert form.initial["disk_size"] == 25 assert form.initial["disk_size"] == 25
form = form_class(data={"name": "test-instance", "disk_size": "25"}) form = form_class(data={"display_name": "test-instance", "disk_size": "25"})
form.fields["context"].required = False form.fields["context"].required = False
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
nested_data = form.get_nested_data() nested_data = form.get_nested_data()