Compare commits
9 commits
848e5162bc
...
7a88b2b83a
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a88b2b83a | |||
| ce92f6f29e | |||
| 86feb0ae4f | |||
| e102d40c09 | |||
| 6627916ed7 | |||
| d1f3d188f6 | |||
| 46a2826ff5 | |||
| ffc5b0285a | |||
| 517d905e41 |
20 changed files with 200 additions and 91 deletions
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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", []):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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", []):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
46
src/servala/core/migrations/0019_add_display_name.py
Normal file
46
src/servala/core/migrations/0019_add_display_name.py
Normal 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),
|
||||||
|
]
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,7 @@ class ServiceOfferingDetailView(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)}."
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,11 @@ def org_owner(organization):
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue