Merge pull request 'Add display name field' (#331) from 290-display-name into main
Reviewed-on: #331 Reviewed-by: Tobias Brunner <tobias.brunner@vshn.ch>
This commit is contained in:
commit
cee4b2a8bc
24 changed files with 988 additions and 225 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="Instance ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# 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),
|
||||||
|
]
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import copy
|
import copy
|
||||||
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
@ -615,8 +616,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=_("Instance ID"),
|
||||||
|
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 +695,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 +746,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 +861,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 +931,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 +990,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
compute_plan_assignment=plan_to_use,
|
compute_plan_assignment=plan_to_use,
|
||||||
control_plane=self.context.control_plane,
|
control_plane=self.context.control_plane,
|
||||||
instance_name=self.name,
|
instance_name=self.name,
|
||||||
|
display_name=self.display_name,
|
||||||
organization=self.organization,
|
organization=self.organization,
|
||||||
service=self.context.service_offering.service,
|
service=self.context.service_offering.service,
|
||||||
)
|
)
|
||||||
|
|
@ -1065,7 +1115,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
if not self.context.django_model:
|
if not self.context.django_model:
|
||||||
return
|
return
|
||||||
return self.context.django_model(
|
return self.context.django_model(
|
||||||
name=self.name,
|
display_name=self.display_name,
|
||||||
context=self.context,
|
context=self.context,
|
||||||
spec=self.spec,
|
spec=self.spec,
|
||||||
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
||||||
|
|
|
||||||
|
|
@ -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=_("Instance ID"),
|
||||||
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 instance ID: <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 instance ID. 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 "Instance ID" %}</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 "Instance ID" %}</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,7 +34,10 @@
|
||||||
{% 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>
|
||||||
|
<code>{{ instance.name }}</code>
|
||||||
</td>
|
</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>
|
||||||
|
|
@ -42,7 +46,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 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"),
|
||||||
|
|
@ -604,6 +604,9 @@ class ServiceInstanceUpdateView(
|
||||||
current_spec = dict(self.object.spec) if self.object.spec else {}
|
current_spec = dict(self.object.spec) if self.object.spec else {}
|
||||||
spec_data = self._deep_merge(current_spec, spec_data)
|
spec_data = self._deep_merge(current_spec, spec_data)
|
||||||
|
|
||||||
|
if display_name := form_data.get("display_name"):
|
||||||
|
self.object.display_name = display_name
|
||||||
|
|
||||||
compute_plan_assignment = None
|
compute_plan_assignment = None
|
||||||
if self.plan_form.is_valid():
|
if self.plan_form.is_valid():
|
||||||
compute_plan_assignment = self.plan_form.cleaned_data.get(
|
compute_plan_assignment = self.plan_form.cleaned_data.get(
|
||||||
|
|
@ -618,7 +621,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 +682,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 +693,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
|
||||||
|
|
@ -23,9 +23,19 @@ const initializeFqdnGeneration = (prefix) => {
|
||||||
if (!fqdnField) return
|
if (!fqdnField) return
|
||||||
|
|
||||||
if (nameField && fqdnField) {
|
if (nameField && fqdnField) {
|
||||||
|
const sanitizeForFqdn = (name) => {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphens
|
||||||
|
.replace(/-+/g, '-') // Collapse multiple hyphens
|
||||||
|
.replace(/^-|-$/g, ''); // Trim hyphens from start/end
|
||||||
|
}
|
||||||
|
|
||||||
const generateFqdn = (instanceName) => {
|
const generateFqdn = (instanceName) => {
|
||||||
if (!instanceName) return '';
|
if (!instanceName) return '';
|
||||||
return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
|
const sanitized = sanitizeForFqdn(instanceName);
|
||||||
|
if (!sanitized) return ''
|
||||||
|
return `${sanitized}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
nameField.addEventListener('input', function() {
|
nameField.addEventListener('input', function() {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from servala.core.models.service import (
|
||||||
Service,
|
Service,
|
||||||
ServiceCategory,
|
ServiceCategory,
|
||||||
ServiceDefinition,
|
ServiceDefinition,
|
||||||
|
ServiceInstance,
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -42,11 +43,6 @@ def other_organization(origin):
|
||||||
return Organization.objects.create(name="Test Org Alternate", origin=origin)
|
return Organization.objects.create(name="Test Org Alternate", origin=origin)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def user():
|
|
||||||
return User.objects.create(email="testuser@example.org", password="test")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def org_owner(organization):
|
def org_owner(organization):
|
||||||
owner = User.objects.create(email="owner@example.org", password="example")
|
owner = User.objects.create(email="owner@example.org", password="example")
|
||||||
|
|
@ -75,7 +71,12 @@ def org_member(organization):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_service_category():
|
def user():
|
||||||
|
return User.objects.create(email="generic-user@example.org", password="example")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service_category():
|
||||||
return ServiceCategory.objects.create(
|
return ServiceCategory.objects.create(
|
||||||
name="Databases",
|
name="Databases",
|
||||||
description="Database services",
|
description="Database services",
|
||||||
|
|
@ -83,18 +84,18 @@ def test_service_category():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_service(test_service_category):
|
def service(service_category):
|
||||||
return Service.objects.create(
|
return Service.objects.create(
|
||||||
name="Redis",
|
name="Redis",
|
||||||
slug="redis",
|
slug="redis",
|
||||||
category=test_service_category,
|
category=service_category,
|
||||||
description="Redis database service",
|
description="Redis database service",
|
||||||
osb_service_id="test-service-123",
|
osb_service_id="test-service-123",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_cloud_provider():
|
def cloud_provider():
|
||||||
return CloudProvider.objects.create(
|
return CloudProvider.objects.create(
|
||||||
name="Exoscale",
|
name="Exoscale",
|
||||||
description="Exoscale cloud provider",
|
description="Exoscale cloud provider",
|
||||||
|
|
@ -102,10 +103,10 @@ def test_cloud_provider():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_service_offering(test_service, test_cloud_provider):
|
def service_offering(service, cloud_provider):
|
||||||
return ServiceOffering.objects.create(
|
return ServiceOffering.objects.create(
|
||||||
service=test_service,
|
service=service,
|
||||||
provider=test_cloud_provider,
|
provider=cloud_provider,
|
||||||
description="Redis on Exoscale",
|
description="Redis on Exoscale",
|
||||||
osb_plan_id="test-plan-123",
|
osb_plan_id="test-plan-123",
|
||||||
)
|
)
|
||||||
|
|
@ -148,11 +149,11 @@ def mock_odoo_failure(mocker):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_control_plane(test_cloud_provider):
|
def control_plane(cloud_provider):
|
||||||
return ControlPlane.objects.create(
|
return ControlPlane.objects.create(
|
||||||
name="Geneva (CH-GVA-2)",
|
name="Geneva (CH-GVA-2)",
|
||||||
description="Geneva control plane",
|
description="Geneva control plane",
|
||||||
cloud_provider=test_cloud_provider,
|
cloud_provider=cloud_provider,
|
||||||
api_credentials={
|
api_credentials={
|
||||||
"server": "https://k8s.example.com",
|
"server": "https://k8s.example.com",
|
||||||
"token": "test-token",
|
"token": "test-token",
|
||||||
|
|
@ -162,10 +163,10 @@ def test_control_plane(test_cloud_provider):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_service_definition(test_service):
|
def service_definition(service):
|
||||||
return ServiceDefinition.objects.create(
|
return ServiceDefinition.objects.create(
|
||||||
name="Redis Standard",
|
name="Redis Standard",
|
||||||
service=test_service,
|
service=service,
|
||||||
api_definition={
|
api_definition={
|
||||||
"group": "vshn.appcat.vshn.io",
|
"group": "vshn.appcat.vshn.io",
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
|
|
@ -175,13 +176,11 @@ def test_service_definition(test_service):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_control_plane_crd(
|
def control_plane_crd(service_offering, control_plane, service_definition):
|
||||||
test_service_offering, test_control_plane, test_service_definition
|
|
||||||
):
|
|
||||||
return ControlPlaneCRD.objects.create(
|
return ControlPlaneCRD.objects.create(
|
||||||
service_offering=test_service_offering,
|
service_offering=service_offering,
|
||||||
control_plane=test_control_plane,
|
control_plane=control_plane,
|
||||||
service_definition=test_service_definition,
|
service_definition=service_definition,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -199,10 +198,10 @@ def compute_plan():
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def compute_plan_assignment(compute_plan, test_control_plane_crd):
|
def compute_plan_assignment(compute_plan, control_plane_crd):
|
||||||
return ComputePlanAssignment.objects.create(
|
return ComputePlanAssignment.objects.create(
|
||||||
compute_plan=compute_plan,
|
compute_plan=compute_plan,
|
||||||
control_plane_crd=test_control_plane_crd,
|
control_plane_crd=control_plane_crd,
|
||||||
sla="besteffort",
|
sla="besteffort",
|
||||||
odoo_product_id="test-product-id",
|
odoo_product_id="test-product-id",
|
||||||
odoo_unit_id="test-unit-id",
|
odoo_unit_id="test-unit-id",
|
||||||
|
|
@ -210,3 +209,30 @@ def compute_plan_assignment(compute_plan, test_control_plane_crd):
|
||||||
unit="hour",
|
unit="hour",
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service_instance(organization, control_plane_crd):
|
||||||
|
return ServiceInstance.objects.create(
|
||||||
|
name="test-abc12345",
|
||||||
|
display_name="My Test Instance",
|
||||||
|
organization=organization,
|
||||||
|
context=control_plane_crd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def control_plane_with_storage(cloud_provider):
|
||||||
|
return ControlPlane.objects.create(
|
||||||
|
name="Storage Zone",
|
||||||
|
description="Zone with storage billing",
|
||||||
|
cloud_provider=cloud_provider,
|
||||||
|
api_credentials={
|
||||||
|
"server": "https://k8s.example.com",
|
||||||
|
"token": "test-token",
|
||||||
|
"certificate-authority-data": "test-ca-data",
|
||||||
|
},
|
||||||
|
storage_plan_odoo_product_id="storage-product-123",
|
||||||
|
storage_plan_odoo_unit_id="storage-unit-456",
|
||||||
|
storage_plan_price_per_gib="0.10",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -55,14 +55,14 @@ def valid_osb_payload():
|
||||||
def test_successful_onboarding_new_organization(
|
def test_successful_onboarding_new_organization(
|
||||||
mock_odoo_success,
|
mock_odoo_success,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
valid_osb_payload,
|
valid_osb_payload,
|
||||||
exoscale_origin,
|
exoscale_origin,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
|
|
||||||
response = osb_client.put(
|
response = osb_client.put(
|
||||||
f"/api/osb/v2/service_instances/{instance_id}",
|
f"/api/osb/v2/service_instances/{instance_id}",
|
||||||
|
|
@ -107,15 +107,15 @@ def test_successful_onboarding_new_organization(
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_new_organization_inherits_origin(
|
def test_new_organization_inherits_origin(
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
valid_osb_payload,
|
valid_osb_payload,
|
||||||
exoscale_origin,
|
exoscale_origin,
|
||||||
instance_id,
|
instance_id,
|
||||||
billing_entity,
|
billing_entity,
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
exoscale_origin.billing_entity = billing_entity
|
exoscale_origin.billing_entity = billing_entity
|
||||||
exoscale_origin.save()
|
exoscale_origin.save()
|
||||||
|
|
||||||
|
|
@ -137,8 +137,8 @@ def test_new_organization_inherits_origin(
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_duplicate_organization_returns_existing(
|
def test_duplicate_organization_returns_existing(
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
valid_osb_payload,
|
valid_osb_payload,
|
||||||
exoscale_origin,
|
exoscale_origin,
|
||||||
instance_id,
|
instance_id,
|
||||||
|
|
@ -148,10 +148,10 @@ def test_duplicate_organization_returns_existing(
|
||||||
osb_guid="test-org-guid-123",
|
osb_guid="test-org-guid-123",
|
||||||
origin=exoscale_origin,
|
origin=exoscale_origin,
|
||||||
)
|
)
|
||||||
org.limit_osb_services.add(test_service)
|
org.limit_osb_services.add(service)
|
||||||
|
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
|
|
||||||
response = osb_client.put(
|
response = osb_client.put(
|
||||||
f"/api/osb/v2/service_instances/{instance_id}",
|
f"/api/osb/v2/service_instances/{instance_id}",
|
||||||
|
|
@ -169,13 +169,13 @@ def test_duplicate_organization_returns_existing(
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_unauthenticated_osb_api_request_fails(
|
def test_unauthenticated_osb_api_request_fails(
|
||||||
client,
|
client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
valid_osb_payload,
|
valid_osb_payload,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/osb/v2/service_instances/{instance_id}",
|
f"/api/osb/v2/service_instances/{instance_id}",
|
||||||
|
|
@ -205,15 +205,15 @@ def test_unauthenticated_osb_api_request_fails(
|
||||||
)
|
)
|
||||||
def test_missing_required_fields_error(
|
def test_missing_required_fields_error(
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
valid_osb_payload,
|
valid_osb_payload,
|
||||||
field_to_remove,
|
field_to_remove,
|
||||||
expected_error,
|
expected_error,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
|
|
||||||
if isinstance(field_to_remove, tuple):
|
if isinstance(field_to_remove, tuple):
|
||||||
if field_to_remove[0] == "context":
|
if field_to_remove[0] == "context":
|
||||||
|
|
@ -251,10 +251,8 @@ def test_invalid_service_id_error(osb_client, valid_osb_payload, instance_id):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_invalid_plan_id_error(
|
def test_invalid_plan_id_error(osb_client, service, valid_osb_payload, instance_id):
|
||||||
osb_client, test_service, valid_osb_payload, instance_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
):
|
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
|
||||||
valid_osb_payload["plan_id"] = 99999
|
valid_osb_payload["plan_id"] = 99999
|
||||||
|
|
||||||
response = osb_client.put(
|
response = osb_client.put(
|
||||||
|
|
@ -266,17 +264,17 @@ def test_invalid_plan_id_error(
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
response_data = json.loads(response.content)
|
response_data = json.loads(response.content)
|
||||||
assert (
|
assert (
|
||||||
f"Unknown plan_id: 99999 for service_id: {test_service.osb_service_id}"
|
f"Unknown plan_id: 99999 for service_id: {service.osb_service_id}"
|
||||||
in response_data["error"]
|
in response_data["error"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_empty_users_array_error(
|
def test_empty_users_array_error(
|
||||||
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
|
osb_client, service, service_offering, valid_osb_payload, instance_id
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
valid_osb_payload["parameters"]["users"] = []
|
valid_osb_payload["parameters"]["users"] = []
|
||||||
|
|
||||||
response = osb_client.put(
|
response = osb_client.put(
|
||||||
|
|
@ -292,10 +290,10 @@ def test_empty_users_array_error(
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_multiple_users_error(
|
def test_multiple_users_error(
|
||||||
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
|
osb_client, service, service_offering, valid_osb_payload, instance_id
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
valid_osb_payload["parameters"]["users"] = [
|
valid_osb_payload["parameters"]["users"] = [
|
||||||
{"email": "user1@example.com", "full_name": "User One"},
|
{"email": "user1@example.com", "full_name": "User One"},
|
||||||
{"email": "user2@example.com", "full_name": "User Two"},
|
{"email": "user2@example.com", "full_name": "User Two"},
|
||||||
|
|
@ -314,10 +312,10 @@ def test_multiple_users_error(
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_empty_email_address_error(
|
def test_empty_email_address_error(
|
||||||
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id
|
osb_client, service, service_offering, valid_osb_payload, instance_id
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
valid_osb_payload["parameters"]["users"] = [
|
valid_osb_payload["parameters"]["users"] = [
|
||||||
{"email": "", "full_name": "User With No Email"},
|
{"email": "", "full_name": "User With No Email"},
|
||||||
]
|
]
|
||||||
|
|
@ -350,14 +348,14 @@ def test_invalid_json_error(osb_client, instance_id):
|
||||||
def test_user_creation_with_name_parsing(
|
def test_user_creation_with_name_parsing(
|
||||||
mock_odoo_success,
|
mock_odoo_success,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
valid_osb_payload,
|
valid_osb_payload,
|
||||||
exoscale_origin,
|
exoscale_origin,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
valid_osb_payload["parameters"]["users"][0]["full_name"] = "John Doe Smith"
|
valid_osb_payload["parameters"]["users"][0]["full_name"] = "John Doe Smith"
|
||||||
|
|
||||||
response = osb_client.put(
|
response = osb_client.put(
|
||||||
|
|
@ -376,14 +374,14 @@ def test_user_creation_with_name_parsing(
|
||||||
def test_email_normalization(
|
def test_email_normalization(
|
||||||
mock_odoo_success,
|
mock_odoo_success,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
valid_osb_payload,
|
valid_osb_payload,
|
||||||
exoscale_origin,
|
exoscale_origin,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
valid_osb_payload["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM "
|
valid_osb_payload["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM "
|
||||||
|
|
||||||
response = osb_client.put(
|
response = osb_client.put(
|
||||||
|
|
@ -401,14 +399,14 @@ def test_email_normalization(
|
||||||
def test_odoo_integration_failure_handling(
|
def test_odoo_integration_failure_handling(
|
||||||
mock_odoo_failure,
|
mock_odoo_failure,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
valid_osb_payload,
|
valid_osb_payload,
|
||||||
exoscale_origin,
|
exoscale_origin,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||||
|
|
||||||
response = osb_client.put(
|
response = osb_client.put(
|
||||||
f"/api/osb/v2/service_instances/{instance_id}",
|
f"/api/osb/v2/service_instances/{instance_id}",
|
||||||
|
|
@ -425,14 +423,14 @@ def test_odoo_integration_failure_handling(
|
||||||
def test_organization_creation_with_context_only(
|
def test_organization_creation_with_context_only(
|
||||||
mock_odoo_success,
|
mock_odoo_success,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
exoscale_origin,
|
exoscale_origin,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
payload = {
|
payload = {
|
||||||
"service_id": test_service.osb_service_id,
|
"service_id": service.osb_service_id,
|
||||||
"plan_id": test_service_offering.osb_plan_id,
|
"plan_id": service_offering.osb_plan_id,
|
||||||
"context": {
|
"context": {
|
||||||
"organization_guid": "fallback-org-guid",
|
"organization_guid": "fallback-org-guid",
|
||||||
"organization_name": "Fallback Organization",
|
"organization_name": "Fallback Organization",
|
||||||
|
|
@ -462,13 +460,13 @@ def test_organization_creation_with_context_only(
|
||||||
def test_delete_offboarding_success(
|
def test_delete_offboarding_success(
|
||||||
mock_odoo_success,
|
mock_odoo_success,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
response = osb_client.delete(
|
response = osb_client.delete(
|
||||||
f"/api/osb/v2/service_instances/{instance_id}"
|
f"/api/osb/v2/service_instances/{instance_id}"
|
||||||
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
|
f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
@ -476,9 +474,9 @@ def test_delete_offboarding_success(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_delete_missing_service_id(osb_client, test_service_offering, instance_id):
|
def test_delete_missing_service_id(osb_client, service_offering, instance_id):
|
||||||
response = osb_client.delete(
|
response = osb_client.delete(
|
||||||
f"/api/osb/v2/service_instances/{instance_id}?plan_id={test_service_offering.osb_plan_id}"
|
f"/api/osb/v2/service_instances/{instance_id}?plan_id={service_offering.osb_plan_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
@ -487,9 +485,9 @@ def test_delete_missing_service_id(osb_client, test_service_offering, instance_i
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_delete_missing_plan_id(osb_client, test_service, instance_id):
|
def test_delete_missing_plan_id(osb_client, service, instance_id):
|
||||||
response = osb_client.delete(
|
response = osb_client.delete(
|
||||||
f"/api/osb/v2/service_instances/{instance_id}?service_id={test_service.osb_service_id}"
|
f"/api/osb/v2/service_instances/{instance_id}?service_id={service.osb_service_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
@ -509,16 +507,16 @@ def test_delete_invalid_service_id(osb_client, instance_id):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_delete_invalid_plan_id(osb_client, test_service, instance_id):
|
def test_delete_invalid_plan_id(osb_client, service, instance_id):
|
||||||
response = osb_client.delete(
|
response = osb_client.delete(
|
||||||
f"/api/osb/v2/service_instances/{instance_id}"
|
f"/api/osb/v2/service_instances/{instance_id}"
|
||||||
f"?service_id={test_service.osb_service_id}&plan_id=invalid"
|
f"?service_id={service.osb_service_id}&plan_id=invalid"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
response_data = json.loads(response.content)
|
response_data = json.loads(response.content)
|
||||||
assert (
|
assert (
|
||||||
f"Unknown plan_id: invalid for service_id: {test_service.osb_service_id}"
|
f"Unknown plan_id: invalid for service_id: {service.osb_service_id}"
|
||||||
in response_data["error"]
|
in response_data["error"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -527,13 +525,13 @@ def test_delete_invalid_plan_id(osb_client, test_service, instance_id):
|
||||||
def test_patch_suspension_success(
|
def test_patch_suspension_success(
|
||||||
mock_odoo_success,
|
mock_odoo_success,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
payload = {
|
payload = {
|
||||||
"service_id": test_service.osb_service_id,
|
"service_id": service.osb_service_id,
|
||||||
"plan_id": test_service_offering.osb_plan_id,
|
"plan_id": service_offering.osb_plan_id,
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
|
|
@ -556,9 +554,9 @@ def test_patch_suspension_success(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_patch_missing_service_id(osb_client, test_service_offering, instance_id):
|
def test_patch_missing_service_id(osb_client, service_offering, instance_id):
|
||||||
payload = {
|
payload = {
|
||||||
"plan_id": test_service_offering.osb_plan_id,
|
"plan_id": service_offering.osb_plan_id,
|
||||||
"parameters": {"users": []},
|
"parameters": {"users": []},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -574,9 +572,9 @@ def test_patch_missing_service_id(osb_client, test_service_offering, instance_id
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_patch_missing_plan_id(osb_client, test_service, instance_id):
|
def test_patch_missing_plan_id(osb_client, service, instance_id):
|
||||||
payload = {
|
payload = {
|
||||||
"service_id": test_service.osb_service_id,
|
"service_id": service.osb_service_id,
|
||||||
"parameters": {"users": []},
|
"parameters": {"users": []},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -609,8 +607,8 @@ def test_delete_creates_ticket_with_admin_links(
|
||||||
mocker,
|
mocker,
|
||||||
mock_odoo_success,
|
mock_odoo_success,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
# Mock the create_helpdesk_ticket function
|
# Mock the create_helpdesk_ticket function
|
||||||
|
|
@ -618,7 +616,7 @@ def test_delete_creates_ticket_with_admin_links(
|
||||||
|
|
||||||
response = osb_client.delete(
|
response = osb_client.delete(
|
||||||
f"/api/osb/v2/service_instances/{instance_id}"
|
f"/api/osb/v2/service_instances/{instance_id}"
|
||||||
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
|
f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
@ -629,10 +627,10 @@ def test_delete_creates_ticket_with_admin_links(
|
||||||
|
|
||||||
# Check that the description contains an admin URL
|
# Check that the description contains an admin URL
|
||||||
assert "admin/core/serviceoffering" in call_kwargs["description"]
|
assert "admin/core/serviceoffering" in call_kwargs["description"]
|
||||||
assert f"/{test_service_offering.pk}/" in call_kwargs["description"]
|
assert f"/{service_offering.pk}/" in call_kwargs["description"]
|
||||||
assert (
|
assert (
|
||||||
call_kwargs["title"]
|
call_kwargs["title"]
|
||||||
== f"Exoscale OSB Offboard - {test_service.name} - {instance_id}"
|
== f"Exoscale OSB Offboard - {service.name} - {instance_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -641,8 +639,8 @@ def test_patch_creates_ticket_with_user_admin_links(
|
||||||
mocker,
|
mocker,
|
||||||
mock_odoo_success,
|
mock_odoo_success,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
instance_id,
|
instance_id,
|
||||||
org_owner,
|
org_owner,
|
||||||
):
|
):
|
||||||
|
|
@ -650,8 +648,8 @@ def test_patch_creates_ticket_with_user_admin_links(
|
||||||
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
|
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"service_id": test_service.osb_service_id,
|
"service_id": service.osb_service_id,
|
||||||
"plan_id": test_service_offering.osb_plan_id,
|
"plan_id": service_offering.osb_plan_id,
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
|
|
@ -680,8 +678,7 @@ def test_patch_creates_ticket_with_user_admin_links(
|
||||||
assert "admin/core/user" in call_kwargs["description"]
|
assert "admin/core/user" in call_kwargs["description"]
|
||||||
assert f"/{org_owner.pk}/" in call_kwargs["description"]
|
assert f"/{org_owner.pk}/" in call_kwargs["description"]
|
||||||
assert (
|
assert (
|
||||||
call_kwargs["title"]
|
call_kwargs["title"] == f"Exoscale OSB Suspend - {service.name} - {instance_id}"
|
||||||
== f"Exoscale OSB Suspend - {test_service.name} - {instance_id}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -690,8 +687,8 @@ def test_ticket_includes_organization_and_instance_when_found(
|
||||||
mocker,
|
mocker,
|
||||||
mock_odoo_success,
|
mock_odoo_success,
|
||||||
osb_client,
|
osb_client,
|
||||||
test_service,
|
service,
|
||||||
test_service_offering,
|
service_offering,
|
||||||
organization,
|
organization,
|
||||||
):
|
):
|
||||||
# Mock the create_helpdesk_ticket function
|
# Mock the create_helpdesk_ticket function
|
||||||
|
|
@ -699,12 +696,12 @@ def test_ticket_includes_organization_and_instance_when_found(
|
||||||
|
|
||||||
service_definition = ServiceDefinition.objects.create(
|
service_definition = ServiceDefinition.objects.create(
|
||||||
name="Test Definition",
|
name="Test Definition",
|
||||||
service=test_service,
|
service=service,
|
||||||
api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"},
|
api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"},
|
||||||
)
|
)
|
||||||
control_plane = ControlPlane.objects.create(
|
control_plane = ControlPlane.objects.create(
|
||||||
name="Test Control Plane",
|
name="Test Control Plane",
|
||||||
cloud_provider=test_service_offering.provider,
|
cloud_provider=service_offering.provider,
|
||||||
api_credentials={
|
api_credentials={
|
||||||
"certificate-authority-data": "test",
|
"certificate-authority-data": "test",
|
||||||
"server": "https://test",
|
"server": "https://test",
|
||||||
|
|
@ -712,20 +709,22 @@ def test_ticket_includes_organization_and_instance_when_found(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
crd = ControlPlaneCRD.objects.create(
|
crd = ControlPlaneCRD.objects.create(
|
||||||
service_offering=test_service_offering,
|
service_offering=service_offering,
|
||||||
control_plane=control_plane,
|
control_plane=control_plane,
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = osb_client.delete(
|
response = osb_client.delete(
|
||||||
f"/api/osb/v2/service_instances/{instance_name}"
|
f"/api/osb/v2/service_instances/{instance_name}"
|
||||||
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
|
f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
@ -739,7 +738,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()
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ class TestReencryptFieldsCommand:
|
||||||
assert "Starting re-encryption" in output
|
assert "Starting re-encryption" in output
|
||||||
assert "Re-encrypted 0 ControlPlane objects" in output
|
assert "Re-encrypted 0 ControlPlane objects" in output
|
||||||
|
|
||||||
def test_reencrypt_fields_with_control_plane(self, test_control_plane):
|
def test_reencrypt_fields_with_control_plane(self, control_plane):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command("reencrypt_fields", stdout=out)
|
call_command("reencrypt_fields", stdout=out)
|
||||||
|
|
||||||
|
|
@ -147,11 +147,11 @@ class TestSyncBillingMetadataCommand:
|
||||||
|
|
||||||
assert "No control planes found with the specified IDs" in out.getvalue()
|
assert "No control planes found with the specified IDs" in out.getvalue()
|
||||||
|
|
||||||
def test_sync_billing_metadata_dry_run_with_control_plane(self, test_control_plane):
|
def test_sync_billing_metadata_dry_run_with_control_plane(self, control_plane):
|
||||||
out = StringIO()
|
out = StringIO()
|
||||||
call_command("sync_billing_metadata", "--dry-run", stdout=out)
|
call_command("sync_billing_metadata", "--dry-run", stdout=out)
|
||||||
|
|
||||||
output = out.getvalue()
|
output = out.getvalue()
|
||||||
assert "DRY RUN" in output
|
assert "DRY RUN" in output
|
||||||
assert "Syncing billing metadata on 1 control plane(s)" in output
|
assert "Syncing billing metadata on 1 control plane(s)" in output
|
||||||
assert test_control_plane.name in output
|
assert control_plane.name in output
|
||||||
|
|
|
||||||
349
src/tests/test_service_models.py
Normal file
349
src/tests/test_service_models.py
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""Tests for servala.core.models.service module."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from servala.core.models.service import (
|
||||||
|
ControlPlane,
|
||||||
|
Service,
|
||||||
|
ServiceInstance,
|
||||||
|
prune_empty_data,
|
||||||
|
validate_api_credentials,
|
||||||
|
validate_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service_with_external_links(service_category):
|
||||||
|
return Service.objects.create(
|
||||||
|
name="PostgreSQL",
|
||||||
|
slug="postgresql",
|
||||||
|
category=service_category,
|
||||||
|
description="PostgreSQL database",
|
||||||
|
external_links=[
|
||||||
|
{"url": "https://docs.example.com", "title": "Docs", "featured": True},
|
||||||
|
{"url": "https://github.com/example", "title": "GitHub", "featured": False},
|
||||||
|
{"url": "https://api.example.com", "title": "API", "featured": True},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("data", [None, {}])
|
||||||
|
def test_validate_dict_allows_empty_by_default(data):
|
||||||
|
validate_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("data", [None, {}])
|
||||||
|
def test_validate_dict_raises_when_empty_not_allowed(data):
|
||||||
|
with pytest.raises(ValidationError, match="Data may not be empty"):
|
||||||
|
validate_dict(data, allow_empty=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_dict_missing_required_fields_raises():
|
||||||
|
with pytest.raises(ValidationError, match="Missing required fields"):
|
||||||
|
validate_dict({"field1": "v"}, required_fields={"field1", "field2", "field3"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_dict_all_required_fields_present_passes():
|
||||||
|
validate_dict({"a": 1, "b": 2, "extra": 3}, required_fields={"a", "b"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("data", [None, {}])
|
||||||
|
def test_validate_api_credentials_allows_empty(data):
|
||||||
|
validate_api_credentials(data)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"input_data,expected",
|
||||||
|
[
|
||||||
|
({"a": 1, "b": None, "c": 3}, {"a": 1, "c": 3}),
|
||||||
|
({"a": "hello", "b": "", "c": "world"}, {"a": "hello", "c": "world"}),
|
||||||
|
({"a": [1, 2], "b": [], "c": [3]}, {"a": [1, 2], "c": [3]}),
|
||||||
|
(
|
||||||
|
{"a": {"nested": 1}, "b": {}, "c": {"x": 2}},
|
||||||
|
{"a": {"nested": 1}, "c": {"x": 2}},
|
||||||
|
),
|
||||||
|
({"outer": {"inner": {"empty": None}}}, {}),
|
||||||
|
(
|
||||||
|
{"false_val": False, "zero": 0, "none": None},
|
||||||
|
{"false_val": False, "zero": 0},
|
||||||
|
),
|
||||||
|
("string", "string"),
|
||||||
|
(42, 42),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_prune_empty_data(input_data, expected):
|
||||||
|
assert prune_empty_data(input_data) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_empty_data_nested_dicts():
|
||||||
|
data = {"level1": {"level2": {"keep": "value", "remove": None, "empty": ""}}}
|
||||||
|
assert prune_empty_data(data) == {"level1": {"level2": {"keep": "value"}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_empty_data_in_lists():
|
||||||
|
data = {"items": [{"keep": 1}, {"remove": None}, {"also_keep": 2}]}
|
||||||
|
assert prune_empty_data(data) == {"items": [{"keep": 1}, {"also_keep": 2}]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_featured_links_filters_correctly(service_with_external_links):
|
||||||
|
featured = service_with_external_links.featured_links
|
||||||
|
assert len(featured) == 2
|
||||||
|
assert all(link["featured"] for link in featured)
|
||||||
|
assert {link["title"] for link in featured} == {"Docs", "API"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"external_links,expected_count",
|
||||||
|
[
|
||||||
|
(None, 0),
|
||||||
|
([], 0),
|
||||||
|
([{"url": "https://x.com", "title": "X", "featured": False}], 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_service_featured_links_empty_cases(
|
||||||
|
service_category, external_links, expected_count
|
||||||
|
):
|
||||||
|
svc = Service.objects.create(
|
||||||
|
name="Test",
|
||||||
|
slug="test-svc",
|
||||||
|
category=service_category,
|
||||||
|
external_links=external_links,
|
||||||
|
)
|
||||||
|
assert len(svc.featured_links) == expected_count
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_str(service):
|
||||||
|
assert str(service) == "Redis"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_category_str(service_category):
|
||||||
|
assert str(service_category) == "Databases"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_provider_str(cloud_provider):
|
||||||
|
assert str(cloud_provider) == "Exoscale"
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_plane_str(control_plane):
|
||||||
|
assert str(control_plane) == "Geneva (CH-GVA-2)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_plane_test_connection_no_credentials(cloud_provider):
|
||||||
|
plane = ControlPlane.objects.create(
|
||||||
|
name="No Creds", cloud_provider=cloud_provider, api_credentials={}
|
||||||
|
)
|
||||||
|
success, message = plane.test_connection()
|
||||||
|
assert success is False
|
||||||
|
assert "No API credentials" in str(message)
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_definition_str(service_definition):
|
||||||
|
assert str(service_definition) == "Redis Standard"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_definition_control_planes(service_definition, control_plane_crd):
|
||||||
|
assert control_plane_crd.control_plane in service_definition.control_planes
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_plane_crd_str(control_plane_crd):
|
||||||
|
result = str(control_plane_crd)
|
||||||
|
assert "Redis" in result and "Exoscale" in result and "Geneva" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"prop,expected",
|
||||||
|
[("group", "vshn.appcat.vshn.io"), ("version", "v1"), ("kind", "VSHNRedis")],
|
||||||
|
)
|
||||||
|
def test_control_plane_crd_api_properties(control_plane_crd, prop, expected):
|
||||||
|
assert getattr(control_plane_crd, prop) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_offering_str(service_offering):
|
||||||
|
result = str(service_offering)
|
||||||
|
assert "Redis" in result and "Exoscale" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_offering_control_planes(service_offering, control_plane_crd):
|
||||||
|
assert control_plane_crd.control_plane in service_offering.control_planes
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_resource_name_consistent(organization, service):
|
||||||
|
name1 = ServiceInstance.generate_resource_name(organization, "My Instance", service)
|
||||||
|
name2 = ServiceInstance.generate_resource_name(organization, "My Instance", service)
|
||||||
|
assert name1 == name2
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_resource_name_different_inputs(organization, service):
|
||||||
|
name_a = ServiceInstance.generate_resource_name(organization, "Instance A", service)
|
||||||
|
name_b = ServiceInstance.generate_resource_name(organization, "Instance B", service)
|
||||||
|
assert name_a != name_b
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_resource_name_attempt_changes_hash(organization, service):
|
||||||
|
name0 = ServiceInstance.generate_resource_name(
|
||||||
|
organization, "X", service, attempt=0
|
||||||
|
)
|
||||||
|
name1 = ServiceInstance.generate_resource_name(
|
||||||
|
organization, "X", service, attempt=1
|
||||||
|
)
|
||||||
|
assert name0 != name1
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_resource_name_format(organization, service, settings):
|
||||||
|
name = ServiceInstance.generate_resource_name(organization, "Test", service)
|
||||||
|
assert name.startswith(f"{settings.SERVALA_INSTANCE_NAME_PREFIX}-")
|
||||||
|
assert len(name.split("-")[-1]) == 8
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"display_name", ["My Instance", "MY INSTANCE", " My Instance "]
|
||||||
|
)
|
||||||
|
def test_generate_resource_name_normalizes_display_name(
|
||||||
|
organization, service, display_name
|
||||||
|
):
|
||||||
|
canonical = ServiceInstance.generate_resource_name(
|
||||||
|
organization, "my instance", service
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
ServiceInstance.generate_resource_name(organization, display_name, service)
|
||||||
|
== canonical
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spec_data", [None, {}])
|
||||||
|
def test_prepare_spec_data_handles_empty(spec_data):
|
||||||
|
assert ServiceInstance._prepare_spec_data(spec_data) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_spec_data_prunes_empty_values():
|
||||||
|
assert ServiceInstance._prepare_spec_data({"keep": "v", "rm": None}) == {
|
||||||
|
"keep": "v"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_compute_plan_to_spec(compute_plan_assignment):
|
||||||
|
result = ServiceInstance._apply_compute_plan_to_spec({}, compute_plan_assignment)
|
||||||
|
assert result["parameters"]["size"]["memory"] == "2Gi"
|
||||||
|
assert result["parameters"]["size"]["cpu"] == "1000m"
|
||||||
|
assert result["parameters"]["size"]["requests"]["memory"] == "1Gi"
|
||||||
|
assert result["parameters"]["size"]["requests"]["cpu"] == "500m"
|
||||||
|
assert result["parameters"]["service"]["serviceLevel"] == "besteffort"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_compute_plan_to_spec_none_assignment():
|
||||||
|
spec = {"existing": "value"}
|
||||||
|
assert ServiceInstance._apply_compute_plan_to_spec(spec, None) == {
|
||||||
|
"existing": "value"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_compute_plan_preserves_existing(compute_plan_assignment):
|
||||||
|
spec = {"parameters": {"custom": "setting", "service": {"other": "config"}}}
|
||||||
|
result = ServiceInstance._apply_compute_plan_to_spec(spec, compute_plan_assignment)
|
||||||
|
assert result["parameters"]["custom"] == "setting"
|
||||||
|
assert result["parameters"]["service"]["other"] == "config"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_annotations_display_name():
|
||||||
|
cp = MagicMock(storage_plan_odoo_product_id=None, storage_plan_odoo_unit_id=None)
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(
|
||||||
|
compute_plan_assignment=None, control_plane=cp, display_name="My Service"
|
||||||
|
)
|
||||||
|
assert annotations["servala.com/displayName"] == "My Service"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_annotations_no_display_name():
|
||||||
|
cp = MagicMock(storage_plan_odoo_product_id=None, storage_plan_odoo_unit_id=None)
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(
|
||||||
|
compute_plan_assignment=None, control_plane=cp, display_name=None
|
||||||
|
)
|
||||||
|
assert "servala.com/displayName" not in annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_annotations_compute_plan(compute_plan_assignment):
|
||||||
|
cp = MagicMock(storage_plan_odoo_product_id=None, storage_plan_odoo_unit_id=None)
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(
|
||||||
|
compute_plan_assignment=compute_plan_assignment, control_plane=cp
|
||||||
|
)
|
||||||
|
assert annotations["servala.com/erp_product_id_resource"] == "test-product-id"
|
||||||
|
assert annotations["servala.com/erp_unit_id_resource"] == "test-unit-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_annotations_storage_plan(control_plane_with_storage):
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(
|
||||||
|
compute_plan_assignment=None, control_plane=control_plane_with_storage
|
||||||
|
)
|
||||||
|
assert annotations["servala.com/erp_product_id_storage"] == "storage-product-123"
|
||||||
|
assert annotations["servala.com/erp_unit_id_storage"] == "storage-unit-456"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error_msg,expected_has_list,expected_errors",
|
||||||
|
[
|
||||||
|
("", False, None),
|
||||||
|
(None, False, None),
|
||||||
|
("Something went wrong", False, None),
|
||||||
|
("Error: [single error]", False, None),
|
||||||
|
("Validation failed: [e1, e2, e3]", True, ["e1", "e2", "e3"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_format_kubernetes_error(error_msg, expected_has_list, expected_errors):
|
||||||
|
result = ServiceInstance._format_kubernetes_error(error_msg)
|
||||||
|
assert result["has_list"] == expected_has_list
|
||||||
|
assert result["errors"] == expected_errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_kubernetes_error_strips_quotes():
|
||||||
|
result = ServiceInstance._format_kubernetes_error("Errors: [\"quoted\", 'single']")
|
||||||
|
assert "quoted" in result["errors"]
|
||||||
|
assert "single" in result["errors"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_format_error_non_dict():
|
||||||
|
assert ServiceInstance._safe_format_error("plain string") == "plain string"
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_format_error_escapes_html():
|
||||||
|
error_data = {"message": "<script>alert('xss')</script>", "has_list": False}
|
||||||
|
result = ServiceInstance._safe_format_error(error_data)
|
||||||
|
assert "<script>" not in result
|
||||||
|
assert "<script>" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_format_error_formats_list():
|
||||||
|
error_data = {"message": "Failed", "errors": ["e1", "e2"], "has_list": True}
|
||||||
|
result = str(ServiceInstance._safe_format_error(error_data))
|
||||||
|
assert "Failed" in result and "<ul>" in result and "<li>e1</li>" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"spec,expected",
|
||||||
|
[
|
||||||
|
({}, None),
|
||||||
|
({"parameters": {"service": {}}}, None),
|
||||||
|
({"parameters": {"service": {"fqdn": "app.example.com"}}}, "app.example.com"),
|
||||||
|
(
|
||||||
|
{"parameters": {"service": {"fqdn": ["first.com", "second.com"]}}},
|
||||||
|
"first.com",
|
||||||
|
),
|
||||||
|
({"parameters": {"service": {"fqdn": 12345}}}, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_fqdn_url(service_instance, mocker, spec, expected):
|
||||||
|
mocker.patch.object(
|
||||||
|
ServiceInstance, "spec", new_callable=mocker.PropertyMock, return_value=spec
|
||||||
|
)
|
||||||
|
assert service_instance.fqdn_url == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_kubernetes_caches(service_instance):
|
||||||
|
service_instance.__dict__["kubernetes_object"] = {"cached": True}
|
||||||
|
service_instance.__dict__["spec"] = {"cached": True}
|
||||||
|
service_instance._clear_kubernetes_caches()
|
||||||
|
assert "kubernetes_object" not in service_instance.__dict__
|
||||||
|
assert "spec" not in service_instance.__dict__
|
||||||
208
src/tests/test_user.py
Normal file
208
src/tests/test_user.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from servala.core.models import User
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user():
|
||||||
|
return User.objects.create(email="testuser@example.org", password="test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user():
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email="test@example.org",
|
||||||
|
password="testpassword123",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
)
|
||||||
|
assert user.email == "test@example.org"
|
||||||
|
assert user.first_name == "Test"
|
||||||
|
assert user.last_name == "User"
|
||||||
|
assert user.check_password("testpassword123")
|
||||||
|
assert not user.is_staff
|
||||||
|
assert not user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_normalizes_email():
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email=" TEST@EXAMPLE.ORG ",
|
||||||
|
password="testpassword123",
|
||||||
|
)
|
||||||
|
assert user.email == "TEST@example.org"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_without_email_raises_error():
|
||||||
|
with pytest.raises(ValueError, match="Please provide an email address"):
|
||||||
|
User.objects.create_user(email="", password="testpassword123")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_superuser():
|
||||||
|
user = User.objects.create_superuser(
|
||||||
|
email="admin@example.org",
|
||||||
|
password="adminpassword123",
|
||||||
|
)
|
||||||
|
assert user.email == "admin@example.org"
|
||||||
|
assert user.check_password("adminpassword123")
|
||||||
|
assert user.is_staff
|
||||||
|
assert user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"first_name,last_name,email,expected",
|
||||||
|
[
|
||||||
|
("John", "Doe", "john@example.org", "John Doe"),
|
||||||
|
("John", "", "john@example.org", "John"),
|
||||||
|
("", "Doe", "john@example.org", "Doe"),
|
||||||
|
("", "", "john@example.org", "john@example.org"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_user_str(first_name, last_name, email, expected):
|
||||||
|
user = User(email=email, first_name=first_name, last_name=last_name)
|
||||||
|
assert str(user) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_username():
|
||||||
|
user = User()
|
||||||
|
assert user.normalize_username(" TEST@EXAMPLE.ORG ") == "test@example.org"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_odoo_contact_returns_none_without_billing_entity(user, organization):
|
||||||
|
assert organization.billing_entity is None
|
||||||
|
result = user.get_odoo_contact(organization)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_odoo_contact_returns_none_without_odoo_company_id(
|
||||||
|
user, organization, billing_entity
|
||||||
|
):
|
||||||
|
organization.billing_entity = billing_entity
|
||||||
|
organization.save()
|
||||||
|
assert billing_entity.odoo_company_id is None
|
||||||
|
result = user.get_odoo_contact(organization)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_odoo_contact_returns_contact_from_odoo(
|
||||||
|
user, organization, billing_entity, mocker
|
||||||
|
):
|
||||||
|
billing_entity.odoo_company_id = 123
|
||||||
|
billing_entity.save()
|
||||||
|
organization.billing_entity = billing_entity
|
||||||
|
organization.save()
|
||||||
|
|
||||||
|
mock_client = mocker.patch("servala.core.models.user.odoo.CLIENT")
|
||||||
|
mock_client.search_read.return_value = [
|
||||||
|
{"id": 456, "name": "Test User", "email": user.email}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = user.get_odoo_contact(organization)
|
||||||
|
|
||||||
|
assert result == {"id": 456, "name": "Test User", "email": user.email}
|
||||||
|
mock_client.search_read.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_odoo_contact_returns_none_when_not_found(
|
||||||
|
user, organization, billing_entity, mocker
|
||||||
|
):
|
||||||
|
billing_entity.odoo_company_id = 123
|
||||||
|
billing_entity.save()
|
||||||
|
organization.billing_entity = billing_entity
|
||||||
|
organization.save()
|
||||||
|
|
||||||
|
mock_client = mocker.patch("servala.core.models.user.odoo.CLIENT")
|
||||||
|
mock_client.search_read.return_value = []
|
||||||
|
|
||||||
|
result = user.get_odoo_contact(organization)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_odoo_contact_returns_none_without_billing_entity(
|
||||||
|
user, organization
|
||||||
|
):
|
||||||
|
assert organization.billing_entity is None
|
||||||
|
result = user.get_or_create_odoo_contact(organization)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_odoo_contact_returns_none_without_odoo_company_id(
|
||||||
|
user, organization, billing_entity
|
||||||
|
):
|
||||||
|
organization.billing_entity = billing_entity
|
||||||
|
organization.save()
|
||||||
|
assert billing_entity.odoo_company_id is None
|
||||||
|
result = user.get_or_create_odoo_contact(organization)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_odoo_contact_returns_existing_contact(
|
||||||
|
user, organization, billing_entity, mocker
|
||||||
|
):
|
||||||
|
billing_entity.odoo_company_id = 123
|
||||||
|
billing_entity.save()
|
||||||
|
organization.billing_entity = billing_entity
|
||||||
|
organization.save()
|
||||||
|
|
||||||
|
mock_client = mocker.patch("servala.core.models.user.odoo.CLIENT")
|
||||||
|
mock_client.search_read.return_value = [{"id": 456, "name": "Test User"}]
|
||||||
|
|
||||||
|
result = user.get_or_create_odoo_contact(organization)
|
||||||
|
|
||||||
|
assert result == 456
|
||||||
|
mock_client.execute.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_odoo_contact_creates_new_contact(
|
||||||
|
organization, billing_entity, mocker
|
||||||
|
):
|
||||||
|
new_user = User.objects.create_user(
|
||||||
|
email="newuser@example.org",
|
||||||
|
password="test",
|
||||||
|
first_name="New",
|
||||||
|
last_name="User",
|
||||||
|
)
|
||||||
|
billing_entity.odoo_company_id = 123
|
||||||
|
billing_entity.save()
|
||||||
|
organization.billing_entity = billing_entity
|
||||||
|
organization.save()
|
||||||
|
|
||||||
|
mock_client = mocker.patch("servala.core.models.user.odoo.CLIENT")
|
||||||
|
mock_client.search_read.return_value = []
|
||||||
|
mock_client.execute.return_value = 789
|
||||||
|
|
||||||
|
result = new_user.get_or_create_odoo_contact(organization)
|
||||||
|
|
||||||
|
assert result == 789
|
||||||
|
mock_client.execute.assert_called_once()
|
||||||
|
call_args = mock_client.execute.call_args
|
||||||
|
assert call_args[0][0] == "res.partner"
|
||||||
|
assert call_args[0][1] == "create"
|
||||||
|
partner_data = call_args[0][2][0]
|
||||||
|
assert partner_data["name"] == "New User"
|
||||||
|
assert partner_data["email"] == "newuser@example.org"
|
||||||
|
assert partner_data["parent_id"] == 123
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_odoo_contact_uses_email_as_name_fallback(
|
||||||
|
organization, billing_entity, mocker
|
||||||
|
):
|
||||||
|
new_user = User.objects.create_user(
|
||||||
|
email="noname@example.org",
|
||||||
|
password="test",
|
||||||
|
)
|
||||||
|
billing_entity.odoo_company_id = 123
|
||||||
|
billing_entity.save()
|
||||||
|
organization.billing_entity = billing_entity
|
||||||
|
organization.save()
|
||||||
|
|
||||||
|
mock_client = mocker.patch("servala.core.models.user.odoo.CLIENT")
|
||||||
|
mock_client.search_read.return_value = []
|
||||||
|
mock_client.execute.return_value = 789
|
||||||
|
|
||||||
|
new_user.get_or_create_odoo_contact(organization)
|
||||||
|
|
||||||
|
call_args = mock_client.execute.call_args
|
||||||
|
partner_data = call_args[0][2][0]
|
||||||
|
assert partner_data["name"] == "noname@example.org"
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from servala.core.models.service import CloudProvider, ServiceOffering
|
from servala.core.models.service import (
|
||||||
|
CloudProvider,
|
||||||
|
ControlPlane,
|
||||||
|
ControlPlaneCRD,
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceOffering,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
@ -51,76 +59,76 @@ def test_organization_linked_in_sidebar(
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_service_detail_redirects_with_single_offering(
|
def test_service_detail_redirects_with_single_offering(
|
||||||
client, org_owner, organization, test_service, test_service_offering
|
client, org_owner, organization, service, service_offering
|
||||||
):
|
):
|
||||||
client.force_login(org_owner)
|
client.force_login(org_owner)
|
||||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
url = f"/org/{organization.slug}/services/{service.slug}/"
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
|
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/"
|
expected_url = f"/org/{organization.slug}/services/{service.slug}/offering/{service_offering.pk}/"
|
||||||
assert response.url == expected_url
|
assert response.url == expected_url
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_service_detail_shows_multiple_offerings(
|
def test_service_detail_shows_multiple_offerings(
|
||||||
client, org_owner, organization, test_service, test_service_offering
|
client, org_owner, organization, service, service_offering
|
||||||
):
|
):
|
||||||
second_provider = CloudProvider.objects.create(
|
second_provider = CloudProvider.objects.create(
|
||||||
name="AWS", description="Amazon Web Services"
|
name="AWS", description="Amazon Web Services"
|
||||||
)
|
)
|
||||||
second_offering = ServiceOffering.objects.create(
|
second_offering = ServiceOffering.objects.create(
|
||||||
service=test_service,
|
service=service,
|
||||||
provider=second_provider,
|
provider=second_provider,
|
||||||
description="Redis on AWS",
|
description="Redis on AWS",
|
||||||
osb_plan_id="test-plan-456",
|
osb_plan_id="test-plan-456",
|
||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(org_owner)
|
client.force_login(org_owner)
|
||||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
url = f"/org/{organization.slug}/services/{service.slug}/"
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
|
|
||||||
assert test_service_offering.provider.name in content
|
assert service_offering.provider.name in content
|
||||||
assert second_offering.provider.name in content
|
assert second_offering.provider.name in content
|
||||||
assert "Create Instance" in content
|
assert "Create Instance" in content
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_service_detail_respects_cloud_provider_restrictions(
|
def test_service_detail_respects_cloud_provider_restrictions(
|
||||||
client, org_owner, organization, test_service, test_service_offering
|
client, org_owner, organization, service, service_offering
|
||||||
):
|
):
|
||||||
second_provider = CloudProvider.objects.create(
|
second_provider = CloudProvider.objects.create(
|
||||||
name="AWS", description="Amazon Web Services"
|
name="AWS", description="Amazon Web Services"
|
||||||
)
|
)
|
||||||
ServiceOffering.objects.create(
|
ServiceOffering.objects.create(
|
||||||
service=test_service,
|
service=service,
|
||||||
provider=second_provider,
|
provider=second_provider,
|
||||||
description="Redis on AWS",
|
description="Redis on AWS",
|
||||||
osb_plan_id="test-plan-456",
|
osb_plan_id="test-plan-456",
|
||||||
)
|
)
|
||||||
organization.origin.limit_cloudproviders.add(test_service_offering.provider)
|
organization.origin.limit_cloudproviders.add(service_offering.provider)
|
||||||
|
|
||||||
client.force_login(org_owner)
|
client.force_login(org_owner)
|
||||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
url = f"/org/{organization.slug}/services/{service.slug}/"
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
|
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/"
|
expected_url = f"/org/{organization.slug}/services/{service.slug}/offering/{service_offering.pk}/"
|
||||||
assert response.url == expected_url
|
assert response.url == expected_url
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_service_detail_no_redirect_with_restricted_multiple_offerings(
|
def test_service_detail_no_redirect_with_restricted_multiple_offerings(
|
||||||
client, org_owner, organization, test_service, test_service_offering
|
client, org_owner, organization, service, service_offering
|
||||||
):
|
):
|
||||||
second_provider = CloudProvider.objects.create(
|
second_provider = CloudProvider.objects.create(
|
||||||
name="AWS", description="Amazon Web Services"
|
name="AWS", description="Amazon Web Services"
|
||||||
)
|
)
|
||||||
second_offering = ServiceOffering.objects.create(
|
second_offering = ServiceOffering.objects.create(
|
||||||
service=test_service,
|
service=service,
|
||||||
provider=second_provider,
|
provider=second_provider,
|
||||||
description="Redis on AWS",
|
description="Redis on AWS",
|
||||||
osb_plan_id="test-plan-456",
|
osb_plan_id="test-plan-456",
|
||||||
|
|
@ -129,21 +137,80 @@ def test_service_detail_no_redirect_with_restricted_multiple_offerings(
|
||||||
name="Azure", description="Microsoft Azure"
|
name="Azure", description="Microsoft Azure"
|
||||||
)
|
)
|
||||||
third_offering = ServiceOffering.objects.create(
|
third_offering = ServiceOffering.objects.create(
|
||||||
service=test_service,
|
service=service,
|
||||||
provider=third_provider,
|
provider=third_provider,
|
||||||
description="Redis on Azure",
|
description="Redis on Azure",
|
||||||
osb_plan_id="test-plan-789",
|
osb_plan_id="test-plan-789",
|
||||||
)
|
)
|
||||||
organization.origin.limit_cloudproviders.add(
|
organization.origin.limit_cloudproviders.add(
|
||||||
test_service_offering.provider, second_provider
|
service_offering.provider, second_provider
|
||||||
)
|
)
|
||||||
|
|
||||||
client.force_login(org_owner)
|
client.force_login(org_owner)
|
||||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
url = f"/org/{organization.slug}/services/{service.slug}/"
|
||||||
response = client.get(url)
|
response = client.get(url)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
assert test_service_offering.provider.name in content
|
assert service_offering.provider.name in content
|
||||||
assert second_offering.provider.name in content
|
assert second_offering.provider.name in content
|
||||||
assert third_offering.provider.name not in content
|
assert third_offering.provider.name not in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_service_instance_update_spec_pushes_display_name_annotation(
|
||||||
|
organization, control_plane_crd, org_owner
|
||||||
|
):
|
||||||
|
instance = ServiceInstance.objects.create(
|
||||||
|
name="test-instance",
|
||||||
|
display_name="Original Name",
|
||||||
|
organization=organization,
|
||||||
|
context=control_plane_crd,
|
||||||
|
)
|
||||||
|
mock_api = MagicMock()
|
||||||
|
with (
|
||||||
|
patch.object(ControlPlane, "custom_objects_api", mock_api),
|
||||||
|
patch.object(ControlPlaneCRD, "kind_plural", "testkinds"),
|
||||||
|
):
|
||||||
|
instance.display_name = "Updated Name"
|
||||||
|
instance.update_spec(spec_data={}, updated_by=org_owner)
|
||||||
|
mock_api.patch_namespaced_custom_object.assert_called_once()
|
||||||
|
call_kwargs = mock_api.patch_namespaced_custom_object.call_args[1]
|
||||||
|
assert "metadata" in call_kwargs["body"]
|
||||||
|
annotations = call_kwargs["body"]["metadata"]["annotations"]
|
||||||
|
assert annotations["servala.com/displayName"] == "Updated Name"
|
||||||
|
|
||||||
|
instance.refresh_from_db()
|
||||||
|
assert instance.display_name == "Updated Name"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_build_billing_annotations_includes_display_name():
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(
|
||||||
|
compute_plan_assignment=None,
|
||||||
|
control_plane=MagicMock(
|
||||||
|
storage_plan_odoo_product_id=None,
|
||||||
|
storage_plan_odoo_unit_id=None,
|
||||||
|
),
|
||||||
|
instance_name="test-instance",
|
||||||
|
display_name="My Display Name",
|
||||||
|
organization=None,
|
||||||
|
service=None,
|
||||||
|
)
|
||||||
|
assert annotations["servala.com/displayName"] == "My Display Name"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_build_billing_annotations_omits_display_name_when_none():
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(
|
||||||
|
compute_plan_assignment=None,
|
||||||
|
control_plane=MagicMock(
|
||||||
|
storage_plan_odoo_product_id=None,
|
||||||
|
storage_plan_odoo_unit_id=None,
|
||||||
|
),
|
||||||
|
instance_name="test-instance",
|
||||||
|
display_name=None,
|
||||||
|
organization=None,
|
||||||
|
service=None,
|
||||||
|
)
|
||||||
|
assert "servala.com/displayName" not in annotations
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue