Compare commits

...

9 commits

Author SHA1 Message Date
7a88b2b83a Fix FQDN generation
All checks were successful
Tests / test (push) Successful in 29s
2025-12-09 16:17:03 +01:00
ce92f6f29e Fix custom form config
All checks were successful
Tests / test (push) Successful in 29s
2025-12-09 16:16:25 +01:00
86feb0ae4f Fix default form config
All checks were successful
Tests / test (push) Successful in 29s
2025-12-09 09:59:45 +01:00
e102d40c09 Use instance name where appropriate 2025-12-09 09:58:42 +01:00
6627916ed7 Make display name editable 2025-12-09 09:55:31 +01:00
d1f3d188f6 Use display name where appropriate 2025-12-09 09:33:06 +01:00
46a2826ff5 Add model field, migrations, and backend methods 2025-12-09 09:26:48 +01:00
ffc5b0285a Add instance name prefix setting 2025-12-09 09:17:50 +01:00
517d905e41 Add missing test fixture 2025-12-09 09:17:12 +01:00
20 changed files with 200 additions and 91 deletions

View file

@ -77,3 +77,7 @@ SERVALA_ODOO_HELPDESK_TEAM_ID='5'
# OSB API authentication settings
SERVALA_OSB_USERNAME=''
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
)
description_parts.append(
f"Instance: {service_instance.name} - {instance_url}"
f"Instance: {service_instance.display_name} ({service_instance.name}) - {instance_url}"
)
else:
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
# 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
# to avoid administrators having to duplicate common information
DEFAULT_FIELD_CONFIGS = {
"name": {
"display_name": {
"type": "text",
"label": "Instance Name",
"help_text": "Unique name for the new instance",
"help_text": "",
"required": True,
"max_length": 63,
"max_length": 100,
},
"spec.parameters.service.fqdn": {
"type": "array",
@ -51,11 +51,6 @@ class FormGeneratorMixin:
crd = getattr(crd, "pk", crd) # can be int or object
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):
for field_name in field_list:
if field_name in self.fields and self.fields[field_name].required:
@ -307,15 +302,6 @@ class CustomFormMixin(FormGeneratorMixin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
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):
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.
"""
field_list = ["context", "name"]
field_list = ["context", "display_name"]
for fieldset in form_config.get("fieldsets", []):
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.
"""
# 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"}
for field_name in ("name", "context"):
for field_name in ("display_name", "context"):
model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
# 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")):
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()
errors = []
for fieldset in form_config.get("fieldsets", []):

View file

@ -195,6 +195,7 @@ class Command(BaseCommand):
compute_plan_assignment=instance.compute_plan_assignment,
control_plane=instance.context.control_plane,
instance_name=instance.name,
display_name=instance.display_name,
organization=instance.organization,
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.client.rest import ApiException
import hashlib
from django.conf import settings
from servala.core import rules as perms
from servala.core.models.mixins import ServalaModelMixin
from servala.core.validators import kubernetes_name_validator
@ -615,8 +619,16 @@ class ServiceInstance(ServalaModelMixin, models.Model):
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(
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(
to="core.Organization",
@ -686,6 +698,24 @@ class ServiceInstance(ServalaModelMixin, models.Model):
spec_data = prune_empty_data(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
def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment):
"""
@ -719,16 +749,20 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment,
control_plane,
instance_name=None,
display_name=None,
organization=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
annotations = {}
if display_name:
annotations["servala.com/displayName"] = display_name
if compute_plan_assignment:
annotations["servala.com/erp_product_id_resource"] = str(
compute_plan_assignment.odoo_product_id
@ -830,18 +864,35 @@ class ServiceInstance(ServalaModelMixin, models.Model):
@transaction.atomic
def create_instance(
cls,
name,
display_name,
organization,
context,
created_by,
spec_data,
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
context.control_plane.get_or_create_namespace(organization)
try:
instance = cls.objects.create(
name=name,
display_name=display_name,
organization=organization,
created_by=created_by,
context=context,
@ -883,8 +934,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment=compute_plan_assignment,
control_plane=context.control_plane,
instance_name=name,
display_name=display_name,
organization=organization,
service=context.service_offering.service,
service=service,
)
if annotations:
create_data["metadata"]["annotations"] = annotations
@ -941,6 +993,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment=plan_to_use,
control_plane=self.context.control_plane,
instance_name=self.name,
display_name=self.display_name,
organization=self.organization,
service=self.context.service_offering.service,
)

View file

@ -123,7 +123,7 @@ class ServiceInstanceFilterForm(forms.Form):
class ServiceInstanceDeleteForm(forms.ModelForm):
name = forms.CharField(
label=_("Instance Name"),
label=_("Resource Name"),
max_length=63,
widget=forms.TextInput(attrs={"class": "form-control"}),
)
@ -132,7 +132,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
kwargs["initial"] = {"name": ""}
super().__init__(*args, **kwargs)
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)
def clean_name(self):
@ -140,7 +140,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
if entered_name != self.instance.name:
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

View file

@ -96,7 +96,7 @@
<tr>
<td>
<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>
<div class="d-flex align-items-center">

View file

@ -7,7 +7,7 @@
{% csrf_token %}
<div class="modal-header">
<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>
<button type="button"
class="btn-close"

View file

@ -2,7 +2,7 @@
{% load i18n static pprint_filters %}
{% block html_title %}
{% block page_title %}
{{ instance.name }}
{{ instance.display_name }}
{% endblock page_title %}
{% endblock html_title %}
{% block page_title_extra %}
@ -39,6 +39,10 @@
</div>
<div class="card-body">
<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>
<dd class="col-sm-8">
{{ instance.context.service_definition.service.name }}

View file

@ -5,7 +5,7 @@
{% block html_title %}
{% block page_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 page_title %}
{% endblock html_title %}

View file

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

View file

@ -276,7 +276,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
try:
service_instance = ServiceInstance.create_instance(
organization=self.request.organization,
name=form.cleaned_data["name"],
display_name=form.cleaned_data["display_name"],
context=self.context_object,
created_by=request.user,
spec_data=form.get_nested_data().get("spec"),
@ -618,7 +618,7 @@ class ServiceInstanceUpdateView(
messages.success(
self.request,
_("Service instance '{name}' updated successfully.").format(
name=self.object.name
name=self.object.display_name
),
)
return redirect(self.object.urls.base)
@ -679,7 +679,7 @@ class ServiceInstanceDeleteView(
messages.success(
self.request,
_("Service instance '{name}' has been scheduled for deletion.").format(
name=self.object.name
name=self.object.display_name
),
)
response = HttpResponse()
@ -690,7 +690,7 @@ class ServiceInstanceDeleteView(
self.request,
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_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
TIME_ZONE = "UTC"

View file

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

View file

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

View file

@ -717,8 +717,10 @@ def test_ticket_includes_organization_and_instance_when_found(
service_definition=service_definition,
)
instance_name = "test-instance-123"
instance_display_name = "Test Instance 123"
service_instance = ServiceInstance.objects.create(
name=instance_name,
display_name=instance_display_name,
organization=organization,
context=crd,
)
@ -739,7 +741,10 @@ def test_ticket_includes_organization_and_instance_when_found(
assert "admin/core/organization" in call_kwargs["description"]
assert f"/{organization.pk}/" in call_kwargs["description"]
# Check instance is included
assert f"Instance: {service_instance.name}" in call_kwargs["description"]
# Check instance is included (format: "display_name (resource_name)")
assert (
f"Instance: {service_instance.display_name} ({service_instance.name})"
in call_kwargs["description"]
)
assert "admin/core/serviceinstance" 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",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"required": True,
}
],
@ -32,7 +32,7 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
crd.service_definition = service_def
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
class Meta:
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"""
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
environment = models.CharField(
max_length=20,
choices=[
@ -215,7 +215,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"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():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
environment = models.CharField(
max_length=20,
choices=[
@ -267,7 +267,7 @@ def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"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():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
environment = models.CharField(
max_length=20,
choices=[
@ -313,7 +313,7 @@ def test_choice_field_validates_against_control_plane_choices():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"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 = 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
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
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
assert not form.is_valid()
assert "environment" in form.errors
@ -368,7 +368,7 @@ def test_admin_form_validates_choice_values_against_schema():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
},
{
"type": "choice",
@ -399,7 +399,7 @@ def test_admin_form_validates_choice_values_against_schema():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
},
{
"type": "choice",
@ -431,7 +431,7 @@ def test_admin_form_validates_choice_values_against_schema():
def test_number_field_min_max_sets_widget_attributes():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
port = models.IntegerField()
replica_count = models.IntegerField()
@ -446,7 +446,7 @@ def test_number_field_min_max_sets_widget_attributes():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"required": True,
},
{
@ -494,7 +494,7 @@ def test_number_field_min_max_sets_widget_attributes():
def test_default_value_for_all_field_types():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
description = models.TextField()
port = models.IntegerField()
environment = models.CharField(
@ -518,7 +518,7 @@ def test_default_value_for_all_field_types():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_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 = 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["port"].initial == "8080"
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():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
port = models.IntegerField()
class Meta:
@ -583,7 +583,7 @@ def test_default_value_not_override_existing_instance():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_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 = form_class(instance=instance)
assert form.initial["name"] == "existing-name"
assert form.initial["display_name"] == "existing-name"
assert form.initial["port"] == 3000
@ -708,7 +708,7 @@ def test_form_config_handles_empty_string_as_none():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"max_length": "", # Empty string
},
]
@ -744,7 +744,7 @@ def test_single_element_choices_are_normalized():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
},
{
"type": "choice",
@ -879,7 +879,7 @@ def test_three_plus_element_choices_fail_validation():
def test_field_with_default_config_only_needs_mapping():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
class Meta:
app_label = "test"
@ -889,7 +889,7 @@ def test_field_with_default_config_only_needs_mapping():
{
"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 = form_class()
name_field = form.fields["name"]
assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"]
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"]
assert name_field.required == DEFAULT_FIELD_CONFIGS["name"]["required"]
name_field = form.fields["display_name"]
assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"]
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
assert name_field.required == DEFAULT_FIELD_CONFIGS["display_name"]["required"]
def test_field_with_default_config_can_override_defaults():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
class Meta:
app_label = "test"
@ -918,7 +918,7 @@ def test_field_with_default_config_can_override_defaults():
{
"fields": [
{
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"label": "Custom Name Label",
"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 = form_class()
name_field = form.fields["name"]
name_field = form.fields["display_name"]
assert name_field.label == "Custom Name Label"
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():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
class Meta:
app_label = "test"
@ -949,7 +949,7 @@ def test_empty_values_dont_override_default_configs():
{
"fields": [
{
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"type": "",
"label": "",
"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 = 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.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"]
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"]
assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"]
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["display_name"]["max_length"]
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():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
port = models.IntegerField()
class Meta:
@ -990,7 +990,7 @@ def test_number_field_validates_min_max_values():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"required": True,
},
{
@ -1009,26 +1009,26 @@ def test_number_field_validates_min_max_values():
form_class = generate_custom_form_class(form_config, TestModel)
# 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
assert not form.is_valid()
assert "port" in form.errors
# 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
assert not form.is_valid()
assert "port" in form.errors
# 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
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
def test_number_field_with_addon_text_roundtrip():
class TestModel(models.Model):
name = models.CharField(max_length=100)
display_name = models.CharField(max_length=100)
disk_size = models.IntegerField()
class Meta:
@ -1041,7 +1041,7 @@ def test_number_field_with_addon_text_roundtrip():
{
"type": "text",
"label": "Name",
"controlplane_field_mapping": "name",
"controlplane_field_mapping": "display_name",
"required": True,
},
{
@ -1059,7 +1059,7 @@ def test_number_field_with_addon_text_roundtrip():
form = form_class(initial={"name": "test-instance", "disk_size": "25Gi"})
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
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
nested_data = form.get_nested_data()