Add display name field #331

Merged
tobru merged 18 commits from 290-display-name into main 2025-12-11 15:33:00 +00:00
8 changed files with 68 additions and 63 deletions
Showing only changes of commit 97b53ec072 - Show all commits

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,16 @@ 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",
"required": True, "required": True,
"max_length": 63, "max_length": 100,
}, },
"spec.parameters.service.fqdn": { "spec.parameters.service.fqdn": {
"type": "array", "type": "array",
@ -472,7 +471,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

@ -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

@ -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

@ -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

@ -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()