Compare commits
44 commits
275e5e3cc1
...
1ac799e29c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ac799e29c | |||
| 605d9941fb | |||
| a698d4b0ec | |||
| ee2efe1d47 | |||
| 5e92168d34 | |||
| 461565cdd4 | |||
| 69a0c5bd5d | |||
| ac845b5073 | |||
| cee4b2a8bc | |||
| 1baed53927 | |||
| f3986a875a | |||
| 820de7e470 | |||
| 392653aace | |||
| 457bbaadc2 | |||
| 4dab8e4f92 | |||
| 337774cc7a | |||
| f9ba2d6c2c | |||
| 4c437d6f26 | |||
| 68e430bb5c | |||
| 2322c37b32 | |||
| 3528c3b4f5 | |||
| 33ebf678be | |||
| dbf9756ccc | |||
| 03f6b5a3c0 | |||
| 97b53ec072 | |||
| 582c4ed564 | |||
| 9cff1e85ac | |||
| cc84926693 | |||
| 7326438470 | |||
| df9ad3171e | |||
| 97633c4c36 | |||
| 73da69cad5 | |||
|
|
658d08e341 | ||
| cecc2f88da | |||
| d889497e08 | |||
| 09bd5272c7 | |||
| 6c43ccb2a5 | |||
|
|
4de8fd7769 | ||
|
|
f6588850ca | ||
|
|
bab696d156 | ||
| a68ad6b8a5 | |||
| 4385e6ce24 | |||
| acb6ac1538 | |||
| 94f95a8664 |
43 changed files with 2050 additions and 495 deletions
|
|
@ -77,3 +77,7 @@ SERVALA_ODOO_HELPDESK_TEAM_ID='5'
|
|||
# OSB API authentication settings
|
||||
SERVALA_OSB_USERNAME=''
|
||||
SERVALA_OSB_PASSWORD=''
|
||||
|
||||
# Prefix for auto-generated Kubernetes resource names for service instances.
|
||||
# Format: {prefix}-{hash}. Defaults to 'si' (service instance).
|
||||
SERVALA_INSTANCE_NAME_PREFIX='si'
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -97,6 +97,32 @@ Then access it with http://localhost:8080/ and the Django admin with http://loca
|
|||
Deployment files are in the `deployment/kustomize` folder and makes use of [Kustomize](https://kustomize.io/) to account for differences between the deployment stages.
|
||||
Stages are configured with overlays in `deployment/kustomize/overlays/$environment`.
|
||||
|
||||
### Resource Requests and Limits
|
||||
|
||||
Resources are configured to comply with APPUiO Cloud's memory-to-CPU ratio of 4096 MiB/core.
|
||||
See [APPUiO Cloud documentation](https://docs.appuio.cloud/user/how-to/check-cpu-requests.html) for details.
|
||||
|
||||
**Production:**
|
||||
|
||||
| Container | CPU Request | Memory Request | CPU Limit | Memory Limit |
|
||||
|-----------|-------------|----------------|-----------|--------------|
|
||||
| servala | 500m | 2Gi | 2 | 4Gi |
|
||||
|
||||
**Staging:**
|
||||
|
||||
| Container | CPU Request | Memory Request | CPU Limit | Memory Limit |
|
||||
|------------------|-------------|----------------|-----------|--------------|
|
||||
| servala | 250m | 1Gi | 1 | 2Gi |
|
||||
| ssh-tunnel-dev | 50m | 204Mi | 100m | 256Mi |
|
||||
| ssh-tunnel-talos | 50m | 204Mi | 100m | 256Mi |
|
||||
|
||||
**Ratio Calculation:**
|
||||
|
||||
The ratio is calculated as: `Sum of Memory Requests / Sum of CPU Requests`
|
||||
|
||||
- Production: 2048 MiB / 0.5 cores = **4096 MiB/core** ✓
|
||||
- Staging: (1024 + 204 + 204) MiB / (0.25 + 0.05 + 0.05) cores = 1432 MiB / 0.35 cores = **4091 MiB/core** ✓
|
||||
|
||||
### Staging
|
||||
|
||||
The code is automatically built and deployed on a push to the main branch.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ spec:
|
|||
- name: servala
|
||||
image: servala.app.codey.ch/servala/servala-portal:latest
|
||||
imagePullPolicy: Always
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 2Gi
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 4Gi
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
|
|
|
|||
|
|
@ -32,10 +32,3 @@ spec:
|
|||
secretKeyRef:
|
||||
name: portal-storage-creds
|
||||
key: AWS_SECRET_ACCESS_KEY
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ spec:
|
|||
spec:
|
||||
containers:
|
||||
- name: servala
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 2Gi
|
||||
env:
|
||||
- name: SERVALA_ENVIRONMENT
|
||||
value: staging
|
||||
|
|
@ -32,8 +39,15 @@ spec:
|
|||
secretKeyRef:
|
||||
name: portal-storage-creds
|
||||
key: AWS_SECRET_ACCESS_KEY
|
||||
- name: ssh-tunnel
|
||||
- name: ssh-tunnel-dev
|
||||
image: servala.app.codey.ch/servala/servala-portal:latest
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 204Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
command:
|
||||
- "/bin/bash"
|
||||
- "-c"
|
||||
|
|
@ -41,13 +55,40 @@ spec:
|
|||
mkdir -p /app/.ssh && chmod 700 /app/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > /app/.ssh/id
|
||||
chmod 600 /app/.ssh/id
|
||||
ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 8443:127.0.0.1:8443 -N -i /app/.ssh/id -v
|
||||
ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 6443:127.0.0.1:6443 -N -i /app/.ssh/id -v
|
||||
env:
|
||||
- name: SSH_HOST
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: servala-sshclient
|
||||
key: ssh-host
|
||||
value: "78.47.176.209"
|
||||
- name: SSH_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: servala-sshclient
|
||||
key: ssh-user
|
||||
- name: SSH_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: servala-sshclient
|
||||
key: ssh-private-key
|
||||
- name: ssh-tunnel-talos
|
||||
image: servala.app.codey.ch/servala/servala-portal:latest
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 204Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
command:
|
||||
- "/bin/bash"
|
||||
- "-c"
|
||||
- |
|
||||
mkdir -p /app/.ssh && chmod 700 /app/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > /app/.ssh/id
|
||||
chmod 600 /app/.ssh/id
|
||||
ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 6444:172.18.200.10:6443 -N -i /app/.ssh/id -v
|
||||
env:
|
||||
- name: SSH_HOST
|
||||
value: mgmt.cls-rma1-9c02.servala.com
|
||||
- name: SSH_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
|
|
|||
|
|
@ -28,15 +28,15 @@ dependencies = [
|
|||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=25.11.0",
|
||||
"black>=25.12.0",
|
||||
"bumpver>=2025.1131",
|
||||
"coverage>=7.12.0",
|
||||
"coverage>=7.13.0",
|
||||
"djlint>=1.36.4",
|
||||
"flake8>=7.3.0",
|
||||
"flake8-bugbear>=25.11.29",
|
||||
"flake8-pyproject>=1.2.4",
|
||||
"isort>=7.0.0",
|
||||
"pytest>=9.0.1",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-cov>=7.0.0",
|
||||
"pytest-django>=4.11.1",
|
||||
"pytest-mock>=3.15.1",
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ The Servala Team"""
|
|||
"core_serviceinstance_change", service_instance.pk
|
||||
)
|
||||
description_parts.append(
|
||||
f"Instance: {service_instance.name} - {instance_url}"
|
||||
f"Instance: {service_instance.display_name} ({service_instance.name}) - {instance_url}"
|
||||
)
|
||||
else:
|
||||
description_parts.append(f"Instance: {instance_id}")
|
||||
|
|
|
|||
|
|
@ -9,17 +9,17 @@ from servala.core.models import ControlPlaneCRD
|
|||
from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon
|
||||
|
||||
# Fields that must be present in every form
|
||||
MANDATORY_FIELDS = ["name"]
|
||||
MANDATORY_FIELDS = ["display_name"]
|
||||
|
||||
# Default field configurations - fields that can be included with just a mapping
|
||||
# to avoid administrators having to duplicate common information
|
||||
DEFAULT_FIELD_CONFIGS = {
|
||||
"name": {
|
||||
"display_name": {
|
||||
"type": "text",
|
||||
"label": "Instance Name",
|
||||
"help_text": "Unique name for the new instance",
|
||||
"help_text": "",
|
||||
"required": True,
|
||||
"max_length": 63,
|
||||
"max_length": 100,
|
||||
},
|
||||
"spec.parameters.service.fqdn": {
|
||||
"type": "array",
|
||||
|
|
@ -51,11 +51,6 @@ class FormGeneratorMixin:
|
|||
crd = getattr(crd, "pk", crd) # can be int or object
|
||||
self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd)
|
||||
|
||||
if self.instance and hasattr(self.instance, "name") and self.instance.name:
|
||||
if "name" in self.fields:
|
||||
self.fields["name"].disabled = True
|
||||
self.fields["name"].widget = forms.HiddenInput()
|
||||
|
||||
def has_mandatory_fields(self, field_list):
|
||||
for field_name in field_list:
|
||||
if field_name in self.fields and self.fields[field_name].required:
|
||||
|
|
@ -307,15 +302,6 @@ class CustomFormMixin(FormGeneratorMixin):
|
|||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._apply_field_config()
|
||||
if (
|
||||
self.instance
|
||||
and hasattr(self.instance, "name")
|
||||
and self.instance.name
|
||||
and "name" in self.fields
|
||||
):
|
||||
self.fields["name"].widget = forms.HiddenInput()
|
||||
self.fields["name"].disabled = True
|
||||
self.fields.pop("context", None)
|
||||
|
||||
def _apply_field_config(self):
|
||||
for fieldset in self.form_config.get("fieldsets", []):
|
||||
|
|
@ -472,7 +458,7 @@ def generate_custom_form_class(form_config, model):
|
|||
"""
|
||||
Generate a custom (user-friendly) form class from form_config JSON.
|
||||
"""
|
||||
field_list = ["context", "name"]
|
||||
field_list = ["context", "display_name"]
|
||||
|
||||
for fieldset in form_config.get("fieldsets", []):
|
||||
for field_config in fieldset.get("fields", []):
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ def generate_django_model(schema, group, version, kind):
|
|||
"""
|
||||
Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema.
|
||||
"""
|
||||
# We always need these three fields to know our own name and our full namespace
|
||||
# We always need these fields to know our display name and our full namespace
|
||||
model_fields = {"__module__": "crd_models"}
|
||||
for field_name in ("name", "context"):
|
||||
for field_name in ("display_name", "context"):
|
||||
model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
|
||||
|
||||
# All other fields are generated from the schema, except for the
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
|||
if not schema or not (spec_schema := schema.get("properties", {}).get("spec")):
|
||||
return
|
||||
|
||||
valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"}
|
||||
valid_paths = self._extract_field_paths(spec_schema, "spec") | {"display_name"}
|
||||
included_mappings = set()
|
||||
errors = []
|
||||
for fieldset in form_config.get("fieldsets", []):
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ class Command(BaseCommand):
|
|||
compute_plan_assignment=instance.compute_plan_assignment,
|
||||
control_plane=instance.context.control_plane,
|
||||
instance_name=instance.name,
|
||||
display_name=instance.display_name,
|
||||
organization=instance.organization,
|
||||
service=instance.context.service_offering.service,
|
||||
)
|
||||
|
|
|
|||
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 hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
|
|
@ -615,8 +616,16 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
on the fly.
|
||||
"""
|
||||
|
||||
# The Kubernetes resource name (metadata.name). This field is immutable after
|
||||
# creation and is auto-generated for new instances. Do not modify directly!
|
||||
name = models.CharField(
|
||||
max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator]
|
||||
max_length=63,
|
||||
verbose_name=_("Instance ID"),
|
||||
validators=[kubernetes_name_validator],
|
||||
)
|
||||
display_name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name=_("Name"),
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
to="core.Organization",
|
||||
|
|
@ -686,6 +695,24 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
spec_data = prune_empty_data(spec_data)
|
||||
return spec_data
|
||||
|
||||
@staticmethod
|
||||
def generate_resource_name(organization, display_name, service, attempt=0):
|
||||
"""
|
||||
Generate a unique Kubernetes-compatible resource name.
|
||||
|
||||
Format: {prefix}-{sha256[:8]}
|
||||
|
||||
The hash input is: org_slug:display_name:service_slug[:attempt if > 0]
|
||||
On collision, we retry with an incremented attempt number included in hash.
|
||||
"""
|
||||
hash_input = (
|
||||
f"{organization.slug}:{display_name.lower().strip()}:{service.slug}"
|
||||
)
|
||||
if attempt > 0:
|
||||
hash_input += f":{attempt}"
|
||||
hash_value = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{settings.SERVALA_INSTANCE_NAME_PREFIX}-{hash_value}"
|
||||
|
||||
@staticmethod
|
||||
def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment):
|
||||
"""
|
||||
|
|
@ -719,16 +746,20 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
compute_plan_assignment,
|
||||
control_plane,
|
||||
instance_name=None,
|
||||
display_name=None,
|
||||
organization=None,
|
||||
service=None,
|
||||
):
|
||||
"""
|
||||
Build Kubernetes annotations for billing integration.
|
||||
Build Kubernetes annotations for billing integration and display name.
|
||||
"""
|
||||
from servala.core.models.organization import InvoiceGroupingChoice
|
||||
|
||||
annotations = {}
|
||||
|
||||
if display_name:
|
||||
annotations["servala.com/displayName"] = display_name
|
||||
|
||||
if compute_plan_assignment:
|
||||
annotations["servala.com/erp_product_id_resource"] = str(
|
||||
compute_plan_assignment.odoo_product_id
|
||||
|
|
@ -830,18 +861,35 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
@transaction.atomic
|
||||
def create_instance(
|
||||
cls,
|
||||
name,
|
||||
display_name,
|
||||
organization,
|
||||
context,
|
||||
created_by,
|
||||
spec_data,
|
||||
compute_plan_assignment=None,
|
||||
):
|
||||
service = context.service_offering.service
|
||||
name = None
|
||||
for attempt in range(10):
|
||||
name = cls.generate_resource_name(
|
||||
organization, display_name, service, attempt
|
||||
)
|
||||
if not cls.objects.filter(
|
||||
name=name, organization=organization, context=context
|
||||
).exists():
|
||||
break
|
||||
else:
|
||||
message = _(
|
||||
"Could not generate a unique resource name. Please try a different display name."
|
||||
)
|
||||
raise ValidationError(organization.add_support_message(message))
|
||||
|
||||
# Ensure the namespace exists
|
||||
context.control_plane.get_or_create_namespace(organization)
|
||||
try:
|
||||
instance = cls.objects.create(
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
organization=organization,
|
||||
created_by=created_by,
|
||||
context=context,
|
||||
|
|
@ -883,8 +931,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
compute_plan_assignment=compute_plan_assignment,
|
||||
control_plane=context.control_plane,
|
||||
instance_name=name,
|
||||
display_name=display_name,
|
||||
organization=organization,
|
||||
service=context.service_offering.service,
|
||||
service=service,
|
||||
)
|
||||
if annotations:
|
||||
create_data["metadata"]["annotations"] = annotations
|
||||
|
|
@ -941,6 +990,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
compute_plan_assignment=plan_to_use,
|
||||
control_plane=self.context.control_plane,
|
||||
instance_name=self.name,
|
||||
display_name=self.display_name,
|
||||
organization=self.organization,
|
||||
service=self.context.service_offering.service,
|
||||
)
|
||||
|
|
@ -1065,7 +1115,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
if not self.context.django_model:
|
||||
return
|
||||
return self.context.django_model(
|
||||
name=self.name,
|
||||
display_name=self.display_name,
|
||||
context=self.context,
|
||||
spec=self.spec,
|
||||
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
||||
|
|
@ -1152,5 +1202,50 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
except (AttributeError, KeyError, IndexError):
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def kubernetes_events(self) -> dict:
|
||||
"""
|
||||
Returns a list of event dictionaries sorted by last timestamp (newest first).
|
||||
"""
|
||||
if not self.kubernetes_object:
|
||||
return []
|
||||
|
||||
try:
|
||||
v1 = kubernetes.client.CoreV1Api(
|
||||
self.context.control_plane.get_kubernetes_client()
|
||||
)
|
||||
events = v1.list_namespaced_event(
|
||||
namespace=self.organization.namespace,
|
||||
field_selector=f"involvedObject.name={self.name},involvedObject.kind={self.context.kind}",
|
||||
)
|
||||
event_list = []
|
||||
for event in events.items:
|
||||
event_dict = {
|
||||
"type": event.type, # Normal or Warning
|
||||
"reason": event.reason,
|
||||
"message": event.message,
|
||||
"count": event.count or 1,
|
||||
"first_timestamp": (
|
||||
event.first_timestamp.isoformat()
|
||||
if event.first_timestamp
|
||||
else None
|
||||
),
|
||||
"last_timestamp": (
|
||||
event.last_timestamp.isoformat()
|
||||
if event.last_timestamp
|
||||
else None
|
||||
),
|
||||
"source": event.source.component if event.source else None,
|
||||
}
|
||||
event_list.append(event_dict)
|
||||
|
||||
event_list.sort(key=lambda x: x.get("last_timestamp") or "", reverse=True)
|
||||
|
||||
return event_list
|
||||
except ApiException:
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True)
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ class ServiceInstanceFilterForm(forms.Form):
|
|||
|
||||
class ServiceInstanceDeleteForm(forms.ModelForm):
|
||||
name = forms.CharField(
|
||||
label=_("Instance Name"),
|
||||
label=_("Instance ID"),
|
||||
max_length=63,
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
|
@ -132,7 +132,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
|
|||
kwargs["initial"] = {"name": ""}
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["name"].help_text = _(
|
||||
"To confirm deletion, please type the instance name: <strong>{instance_name}</strong>"
|
||||
"To confirm deletion, please type the instance ID: <strong>{instance_name}</strong>"
|
||||
).format(instance_name=self.instance.name)
|
||||
|
||||
def clean_name(self):
|
||||
|
|
@ -140,7 +140,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
|
|||
if entered_name != self.instance.name:
|
||||
raise forms.ValidationError(
|
||||
_(
|
||||
"The entered name does not match the instance name. Deletion not confirmed."
|
||||
"The entered name does not match the instance ID. Deletion not confirmed."
|
||||
)
|
||||
)
|
||||
return entered_name
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@
|
|||
{% block page_title_extra %}
|
||||
{% endblock page_title_extra %}
|
||||
</h3>
|
||||
{% block page_subtitle %}
|
||||
{% endblock page_subtitle %}
|
||||
</div>
|
||||
<div class="page-content">
|
||||
{% for message in messages %}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
{{ form.non_field_errors.0 }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% translate "We could not save your changes." %}
|
||||
{% translate "Please review and correct the errors highlighted in the form below." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@
|
|||
<tr>
|
||||
<td>
|
||||
<a href="{{ instance.urls.base }}"
|
||||
class="fw-semibold text-decoration-none">{{ instance.name }}</a>
|
||||
class="fw-semibold text-decoration-none">{{ instance.display_name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@
|
|||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %}
|
||||
{% translate "Create" as create_label %}
|
||||
{% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form form_submit_label=create_label hide_form_errors=True %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="deleteInstanceModalLabel">
|
||||
{% blocktranslate with instance_name=instance.name %}Confirm Deletion of {{ instance_name }}{% endblocktranslate %}
|
||||
{% blocktranslate with instance_name=instance.display_name %}Confirm Deletion of {{ instance_name }}{% endblocktranslate %}
|
||||
</h5>
|
||||
<button type="button"
|
||||
class="btn-close"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
{% load i18n static pprint_filters %}
|
||||
{% block html_title %}
|
||||
{% block page_title %}
|
||||
{{ instance.name }}
|
||||
{{ instance.display_name }}
|
||||
{% endblock page_title %}
|
||||
{% endblock html_title %}
|
||||
{% block page_title_extra %}
|
||||
|
|
@ -29,82 +29,314 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
{% endblock page_title_extra %}
|
||||
{% block page_subtitle %}
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 mt-2">
|
||||
{% if instance.context.service_definition.service.logo %}
|
||||
<img src="{{ instance.context.service_definition.service.logo.url }}"
|
||||
alt="{{ instance.context.service_definition.service.name }}"
|
||||
style="height: 24px; width: auto;">
|
||||
{% endif %}
|
||||
<span class="text-muted">|</span>
|
||||
{% if instance.context.service_offering.provider.logo %}
|
||||
<img src="{{ instance.context.service_offering.provider.logo.url }}"
|
||||
alt="{{ instance.context.service_offering.provider.name }}"
|
||||
style="height: 24px; width: auto;">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock page_subtitle %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="row match-height mb-5">
|
||||
<!-- Row 1: Product Card and Connection Credentials -->
|
||||
<div class="row match-height mb-4">
|
||||
<div class="col-12 col-md-5">
|
||||
<div class="card">
|
||||
{% if compute_plan_assignment or storage_plan %}
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Details" %}</h4>
|
||||
<h4 class="mb-1">{% translate "Product" %}</h4>
|
||||
<p class="text-muted mb-0 small">
|
||||
{{ instance.context.service_definition.service.name }}
|
||||
{% translate "at" %}
|
||||
{{ instance.context.service_offering.provider.name }}
|
||||
({{ instance.context.control_plane.name }})
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">{% translate "Service" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ instance.context.service_definition.service.name }}
|
||||
</dd>
|
||||
<dt class="col-sm-4">{% translate "Service Provider" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ instance.context.service_offering.provider.name }}
|
||||
</dd>
|
||||
<dt class="col-sm-4">{% translate "Control Plane" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ instance.context.control_plane.name }}
|
||||
</dd>
|
||||
{% if compute_plan_assignment %}
|
||||
<dt class="col-sm-4">{% translate "Compute Plan" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ compute_plan_assignment.compute_plan.name }}
|
||||
<span class="badge bg-{% if compute_plan_assignment.sla == 'guaranteed' %}success{% else %}secondary{% endif %} ms-1">
|
||||
{{ compute_plan_assignment.get_sla_display }}
|
||||
</span>
|
||||
<div class="text-muted small mt-1">
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted mb-2">{% translate "Compute" %}</h6>
|
||||
<div class="mb-1">
|
||||
{% translate "Plan:" %} <strong>{{ compute_plan_assignment.compute_plan.name }}</strong>
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-cpu"></i> {{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU
|
||||
<span class="mx-2">•</span>
|
||||
<i class="bi bi-memory"></i> {{ compute_plan_assignment.compute_plan.memory_limits }} RAM
|
||||
<span class="mx-2">•</span>
|
||||
<strong>CHF {{ compute_plan_assignment.price }}</strong>/{{ compute_plan_assignment.get_unit_display }}
|
||||
SLA: {{ compute_plan_assignment.get_sla_display }}
|
||||
</div>
|
||||
<div class="mt-1 small">
|
||||
CHF {{ compute_plan_assignment.price }} / {{ compute_plan_assignment.get_unit_display }}
|
||||
{% if pricing.compute_monthly %}
|
||||
<span class="text-muted">(~CHF {{ pricing.compute_monthly }} / {% translate "month" %})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if storage_plan %}
|
||||
<dt class="col-sm-4">{% translate "Storage Plan" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
<strong>CHF {{ storage_plan.price_per_gib }}</strong> per GiB
|
||||
<div class="text-muted small">{% translate "Billed separately based on disk usage" %}</div>
|
||||
</dd>
|
||||
<div class="mb-3">
|
||||
<h6 class="text-muted mb-2">{% translate "Storage" %}</h6>
|
||||
<div class="text-muted small">
|
||||
CHF {{ storage_plan.price_per_gib }} / GiB / {% translate "hour" %}
|
||||
{% if pricing.disk_size_gib %}
|
||||
<span class="mx-2">•</span>
|
||||
{{ pricing.disk_size_gib }} GiB {% translate "configured" %}
|
||||
{% endif %}
|
||||
<dt class="col-sm-4">{% translate "Created By" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ instance.created_by|default:"-" }}
|
||||
</dd>
|
||||
<dt class="col-sm-4">{% translate "Created At" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ instance.created_at|date:"SHORT_DATETIME_FORMAT" }}
|
||||
</dd>
|
||||
<dt class="col-sm-4">{% translate "Updated At" %}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ instance.updated_at|date:"SHORT_DATETIME_FORMAT" }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% if pricing.storage_monthly %}
|
||||
<div class="mt-1 small">
|
||||
CHF {{ pricing.storage_hourly }} / {% translate "hour" %}
|
||||
<span class="text-muted">(~CHF {{ pricing.storage_monthly }} / {% translate "month" %})</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if pricing.total_monthly %}
|
||||
<hr class="my-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted">{% translate "Estimated Total" %}</span>
|
||||
<div class="text-end">
|
||||
<div><strong>~CHF {{ pricing.total_monthly }}</strong> <span class="text-muted small">/ {% translate "month" %}</span></div>
|
||||
<div class="text-muted small">CHF {{ pricing.total_hourly }} / {% translate "hour" %}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12 col-md-7">
|
||||
{% if instance.status_conditions %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Status" %}</h4>
|
||||
{% if instance.connection_credentials %}
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0">{% translate "Connection Credentials" %}</h4>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
id="toggle-credentials-btn"
|
||||
data-show-text="{% translate "Show All" %}"
|
||||
data-hide-text="{% translate "Hide All" %}"
|
||||
onclick="toggleAllCredentials()">
|
||||
<i class="bi bi-eye me-1"></i>
|
||||
<span id="toggle-credentials-text">{% translate "Show All" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<table class="table table-bordered table-fixed mb-0">
|
||||
<colgroup>
|
||||
<col style="width: 35%;">
|
||||
<col style="width: 55%;">
|
||||
<col style="width: 10%;">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Name" %}</th>
|
||||
<th>{% translate "Value" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, value in instance.connection_credentials.items %}
|
||||
{% if "_HOST" not in key and "_URL" not in key %}
|
||||
<tr>
|
||||
<td class="text-truncate">{{ key }}</td>
|
||||
<td class="text-truncate">
|
||||
{% if key == "error" %}
|
||||
<span class="text-danger">{{ value }}</span>
|
||||
{% else %}
|
||||
<code class="credential-value" data-value="{{ value }}">••••••••••••</code>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if key != "error" %}
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-link p-0 credential-toggle"
|
||||
onclick="toggleCredential(this)"
|
||||
title="{% translate 'Show/Hide' %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-muted small mt-2">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
{% translate "Click the eye icon to reveal individual credentials, or use 'Show All' to reveal all at once." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: Zone Information (left) and Service Configuration (right) -->
|
||||
{% if instance.spec and spec_fieldsets or instance.context.control_plane.user_info %}
|
||||
<div class="row match-height mb-4">
|
||||
<!-- Zone Information (collapsible) -->
|
||||
{% if instance.context.control_plane.user_info %}
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">
|
||||
<a class="text-decoration-none text-dark d-flex align-items-center justify-content-between"
|
||||
data-bs-toggle="collapse"
|
||||
href="#zoneInfoCollapse"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
aria-controls="zoneInfoCollapse">
|
||||
{% translate "Zone Documentation" %}
|
||||
<i class="bi bi-chevron-down ms-2 small"></i>
|
||||
</a>
|
||||
</h4>
|
||||
<p class="text-muted small mb-0 mt-1">{% translate "Technical details for connecting to this zone" %}</p>
|
||||
</div>
|
||||
<div class="collapse" id="zoneInfoCollapse">
|
||||
<div class="card-body pt-0">
|
||||
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Service Configuration -->
|
||||
{% if instance.spec and spec_fieldsets %}
|
||||
<div class="col-12 {% if instance.context.control_plane.user_info %}col-md-8{% endif %}">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Service Configuration" %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if spec_fieldsets|length > 1 %}
|
||||
<!-- Multiple fieldsets: use tabs -->
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
{% for fieldset in spec_fieldsets %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="#spec-tab-{{ forloop.counter }}"
|
||||
class="nav-link {% if forloop.first %}active{% endif %}"
|
||||
data-bs-toggle="tab"
|
||||
role="tab">{{ fieldset.title }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="tab-content pt-3">
|
||||
{% for fieldset in spec_fieldsets %}
|
||||
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}"
|
||||
id="spec-tab-{{ forloop.counter }}"
|
||||
role="tabpanel">
|
||||
<dl class="row mb-0">
|
||||
{% for field in fieldset.fields %}
|
||||
<dt class="col-sm-4">
|
||||
{{ field.label }}
|
||||
{% if field.help_text %}
|
||||
<i class="bi bi-question-circle text-muted ms-1"
|
||||
data-bs-toggle="popover"
|
||||
data-bs-trigger="hover"
|
||||
data-bs-content="{{ field.help_text }}"></i>
|
||||
{% endif %}
|
||||
</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ field.value|render_tree }}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
|
||||
<h5 class="mt-3">{{ sub_fieldset.title }}</h5>
|
||||
<dl class="row mb-0">
|
||||
{% for field in sub_fieldset.fields %}
|
||||
<dt class="col-sm-4">{{ field.label }}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ field.value|render_tree }}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Single fieldset: no tabs needed -->
|
||||
{% for fieldset in spec_fieldsets %}
|
||||
<dl class="row mb-0">
|
||||
{% for field in fieldset.fields %}
|
||||
<dt class="col-sm-4">
|
||||
{{ field.label }}
|
||||
{% if field.help_text %}
|
||||
<i class="bi bi-question-circle text-muted ms-1"
|
||||
data-bs-toggle="popover"
|
||||
data-bs-trigger="hover"
|
||||
data-bs-content="{{ field.help_text }}"></i>
|
||||
{% endif %}
|
||||
</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ field.value|render_tree }}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
|
||||
<h5 class="mt-3">{{ sub_fieldset.title }}</h5>
|
||||
<dl class="row mb-0">
|
||||
{% for field in sub_fieldset.fields %}
|
||||
<dt class="col-sm-4">{{ field.label }}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{{ field.value|render_tree }}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Row 3: Expert & Metadata (collapsed) -->
|
||||
{% if instance.status_conditions or instance.created_at %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="accordion" id="expertAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="expertHeading">
|
||||
<button class="accordion-button collapsed"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#expertCollapse"
|
||||
aria-expanded="false"
|
||||
aria-controls="expertCollapse">
|
||||
<i class="bi bi-gear me-2"></i>
|
||||
{% translate "Technical Details" %}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="expertCollapse"
|
||||
class="accordion-collapse collapse"
|
||||
aria-labelledby="expertHeading"
|
||||
data-bs-parent="#expertAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="row">
|
||||
<!-- Left column: Status Conditions -->
|
||||
<div class="col-12 col-md-8">
|
||||
{% if instance.status_conditions %}
|
||||
<h5 class="mb-3">{% translate "Status Conditions" %}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Type" %}</th>
|
||||
<th>{% translate "Status" %}</th>
|
||||
<th>{% translate "Last Transition Time" %}</th>
|
||||
<th>{% translate "Last Transition" %}</th>
|
||||
<th>{% translate "Reason" %}</th>
|
||||
<th>{% translate "Message" %}</th>
|
||||
</tr>
|
||||
|
|
@ -122,7 +354,7 @@
|
|||
<span class="badge text-bg-secondary">{{ condition.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ condition.lastTransitionTime|date:"SHORT_DATETIME_FORMAT" }}</td>
|
||||
<td>{{ condition.lastTransitionTime|localtime_tag }}</td>
|
||||
<td>{{ condition.reason|default:"-" }}</td>
|
||||
<td>{{ condition.message|truncatewords:20|default:"-" }}</td>
|
||||
</tr>
|
||||
|
|
@ -130,82 +362,21 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if control_plane.user_info %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Service Provider Zone Information" %}</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if instance.spec and spec_fieldsets %}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Specification" %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
{% for fieldset in spec_fieldsets %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<a href="#spec-tab-{{ forloop.counter }}"
|
||||
class="nav-link {% if forloop.first %}active{% endif %}"
|
||||
data-bs-toggle="tab"
|
||||
role="tab">{{ fieldset.title }}</a>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="nav-item ms-2">{% translate "No specification details available." %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content pt-3">
|
||||
{% for fieldset in spec_fieldsets %}
|
||||
<div class="tab-pane fade {% if forloop.first %}show active{% endif %}"
|
||||
id="spec-tab-{{ forloop.counter }}"
|
||||
role="tabpanel">
|
||||
<!-- Main Fields -->
|
||||
<!-- Right column: Metadata -->
|
||||
<div class="col-12 col-md-4">
|
||||
<h5 class="mb-3">{% translate "Metadata" %}</h5>
|
||||
<dl class="row">
|
||||
{% for field in fieldset.fields %}
|
||||
<dt class="col-sm-3">{{ field.label }}</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
|
||||
<pre>{{ field.value|pprint }}</pre>
|
||||
{% else %}
|
||||
{{ field.value|default:"-" }}
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
<dt class="col-sm-5">{% translate "Created By" %}</dt>
|
||||
<dd class="col-sm-7">{{ instance.created_by|default:"-" }}</dd>
|
||||
<dt class="col-sm-5">{% translate "Created At" %}</dt>
|
||||
<dd class="col-sm-7">{{ instance.created_at|localtime_tag }}</dd>
|
||||
<dt class="col-sm-5">{% translate "Updated At" %}</dt>
|
||||
<dd class="col-sm-7">{{ instance.updated_at|localtime_tag }}</dd>
|
||||
</dl>
|
||||
<!-- Nested Fieldsets -->
|
||||
{% for sub_key, sub_fieldset in fieldset.fieldsets.items %}
|
||||
<h5>{{ sub_fieldset.title }}</h5>
|
||||
<dl class="row">
|
||||
{% for field in sub_fieldset.fields %}
|
||||
<dt class="col-sm-3">{{ field.label }}</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% if field.value|default:""|stringformat:"s"|slice:":1" == "{" or field.value|default:""|stringformat:"s"|slice:":1" == "[" %}
|
||||
<pre>{{ field.value|pprint }}</pre>
|
||||
{% else %}
|
||||
{{ field.value|default:"-" }}
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<p>{% translate "No specification details to display." %}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -213,42 +384,6 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if instance.connection_credentials %}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Connection Credentials" %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Name" %}</th>
|
||||
<th>{% translate "Value" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, value in instance.connection_credentials.items %}
|
||||
<tr>
|
||||
<td>{{ key }}</td>
|
||||
<td>
|
||||
{% if key == "error" %}
|
||||
<span class="text-danger">{{ value }}</span>
|
||||
{% else %}
|
||||
<code>{{ value }}</code>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade"
|
||||
|
|
@ -281,6 +416,8 @@
|
|||
</div>
|
||||
{% endblock content %}
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/local-time.js' %}"></script>
|
||||
<script src="{% static 'js/credentials.js' %}"></script>
|
||||
<script>
|
||||
// Initialize Bootstrap popovers for help text
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{% block html_title %}
|
||||
{% block page_title %}
|
||||
{% block title %}
|
||||
{% blocktranslate with instance_name=instance.name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %}
|
||||
{% blocktranslate with instance_name=instance.display_name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %}
|
||||
{% endblock %}
|
||||
{% endblock page_title %}
|
||||
{% endblock html_title %}
|
||||
|
|
@ -22,7 +22,8 @@
|
|||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %}
|
||||
{% translate "Update" as update_label %}
|
||||
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form form_submit_label=update_label hide_form_errors=True %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Name" %}</th>
|
||||
<th>{% translate "Instance ID" %}</th>
|
||||
<th>{% translate "Service" %}</th>
|
||||
<th>{% translate "Service Provider" %}</th>
|
||||
<th>{% translate "Service Provider Zone" %}</th>
|
||||
|
|
@ -33,7 +34,10 @@
|
|||
{% for instance in instances %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ instance.urls.base }}">{{ instance.name }}</a>
|
||||
<a href="{{ instance.urls.base }}">{{ instance.display_name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ instance.name }}</code>
|
||||
</td>
|
||||
<td>{{ instance.context.service_definition.service.name }}</td>
|
||||
<td>{{ instance.context.service_offering.provider.name }}</td>
|
||||
|
|
@ -42,7 +46,7 @@
|
|||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5">{% translate "No service instances found." %}</td>
|
||||
<td colspan="6">{% translate "No service instances found." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@
|
|||
</div>
|
||||
<div class="plan-header">
|
||||
<h6>{% trans "Storage" %}</h6>
|
||||
<span class="text-muted small">{% trans "Billed separately based on disk usage" %}</span>
|
||||
<span class="text-muted small">{% trans "Billed based on disk usage" %}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if storage_plan %}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
{% load i18n %}
|
||||
{% load get_field %}
|
||||
{% load static %}
|
||||
{% if not hide_form_errors %}
|
||||
{% include "frontend/forms/errors.html" %}
|
||||
{% endif %}
|
||||
{% if form and expert_form and not hide_expert_mode %}
|
||||
<div class="mb-3 text-end">
|
||||
<a href="#"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from django import template
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from servala.core.crd.utils import deslugify
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
|
@ -10,3 +15,91 @@ def pprint(value):
|
|||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, indent=2)
|
||||
return value
|
||||
|
||||
|
||||
@register.filter
|
||||
def localtime_tag(value, format_str="datetime"):
|
||||
"""
|
||||
Render a datetime value as a <time> element with datetime attribute.
|
||||
JavaScript will convert this to local time on page load.
|
||||
|
||||
Usage: {{ instance.created_at|localtime_tag }}
|
||||
{{ instance.created_at|localtime_tag:"date" }}
|
||||
{{ instance.created_at|localtime_tag:"time" }}
|
||||
"""
|
||||
if value is None:
|
||||
return "-"
|
||||
|
||||
if isinstance(value, str):
|
||||
iso_value = value
|
||||
elif isinstance(value, datetime):
|
||||
iso_value = value.isoformat()
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
if (
|
||||
not iso_value.endswith("Z")
|
||||
and "+" not in iso_value
|
||||
and "-" not in iso_value[10:]
|
||||
):
|
||||
iso_value += "Z"
|
||||
|
||||
return format_html(
|
||||
'<time datetime="{}" data-format="{}" class="local-time">{}</time>',
|
||||
iso_value,
|
||||
format_str,
|
||||
iso_value,
|
||||
)
|
||||
|
||||
|
||||
@register.filter
|
||||
def render_tree(value, key=""):
|
||||
"""
|
||||
Render a nested dict/list as a collapsible tree structure.
|
||||
Used for displaying JSON parameters in a user-friendly way.
|
||||
"""
|
||||
if value is None:
|
||||
return mark_safe('<span class="text-muted">-</span>')
|
||||
|
||||
if isinstance(value, bool):
|
||||
badge_class = "bg-success" if value else "bg-secondary"
|
||||
return format_html(
|
||||
'<span class="badge {}">{}</span>',
|
||||
badge_class,
|
||||
"Yes" if value else "No",
|
||||
)
|
||||
|
||||
if isinstance(value, (str, int, float)):
|
||||
if isinstance(value, str) and len(value) > 100:
|
||||
return format_html("<code>{}</code>", value)
|
||||
return format_html("<span>{}</span>", value)
|
||||
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
return mark_safe('<span class="text-muted">[]</span>')
|
||||
# For simple string/number arrays, render as comma-separated or line-separated
|
||||
if all(isinstance(item, (str, int, float)) for item in value):
|
||||
if len(value) == 1:
|
||||
return format_html("<span>{}</span>", value[0])
|
||||
# Multiple items: render each on its own line
|
||||
items = [format_html("<div>{}</div>", item) for item in value]
|
||||
return mark_safe("".join(items))
|
||||
# For complex arrays (dicts, nested lists), use list structure
|
||||
items = []
|
||||
for item in value:
|
||||
items.append(f"<li>{render_tree(item)}</li>")
|
||||
return mark_safe(f'<ul class="list-unstyled mb-0">{"".join(items)}</ul>')
|
||||
|
||||
if isinstance(value, dict):
|
||||
if not value:
|
||||
return mark_safe('<span class="text-muted">{}</span>')
|
||||
items = []
|
||||
for k, v in value.items():
|
||||
rendered_value = render_tree(v, k)
|
||||
items.append(
|
||||
f'<dt class="col-sm-4 text-truncate" title="{k}">{deslugify(k)}</dt>'
|
||||
f'<dd class="col-sm-8">{rendered_value}</dd>'
|
||||
)
|
||||
return mark_safe(f'<dl class="row mb-0">{"".join(items)}</dl>')
|
||||
|
||||
return format_html("<span>{}</span>", str(value))
|
||||
|
|
|
|||
|
|
@ -13,4 +13,4 @@ def get_version_or_env():
|
|||
env = os.environ.get("SERVALA_ENVIRONMENT", "development")
|
||||
if env == "production":
|
||||
return __version__
|
||||
return env
|
||||
return env # pragma: no cover
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ urlpatterns = [
|
|||
),
|
||||
path(
|
||||
"services/<slug:slug>/offering/<int:pk>/",
|
||||
views.ServiceOfferingDetailView.as_view(),
|
||||
name="organization.offering",
|
||||
views.ServiceInstanceCreateView.as_view(),
|
||||
name="organization.instance.create",
|
||||
),
|
||||
path(
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ from .organization import (
|
|||
)
|
||||
from .service import (
|
||||
ServiceDetailView,
|
||||
ServiceInstanceCreateView,
|
||||
ServiceInstanceDeleteView,
|
||||
ServiceInstanceDetailView,
|
||||
ServiceInstanceListView,
|
||||
ServiceInstanceUpdateView,
|
||||
ServiceListView,
|
||||
ServiceOfferingDetailView,
|
||||
)
|
||||
from .support import SupportView
|
||||
|
||||
|
|
@ -35,12 +35,12 @@ __all__ = [
|
|||
"OrganizationSelectionView",
|
||||
"OrganizationUpdateView",
|
||||
"ServiceDetailView",
|
||||
"ServiceInstanceCreateView",
|
||||
"ServiceInstanceDeleteView",
|
||||
"ServiceInstanceDetailView",
|
||||
"ServiceInstanceListView",
|
||||
"ServiceInstanceUpdateView",
|
||||
"ServiceListView",
|
||||
"ServiceOfferingDetailView",
|
||||
"ProfileView",
|
||||
"SupportView",
|
||||
"custom_404",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
|
|||
if self.visible_offerings.count() == 1:
|
||||
offering = self.visible_offerings.first()
|
||||
return redirect(
|
||||
"frontend:organization.offering",
|
||||
"frontend:organization.instance.create",
|
||||
organization=self.request.organization.slug,
|
||||
slug=self.object.slug,
|
||||
pk=offering.pk,
|
||||
|
|
@ -97,8 +97,8 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
|
|||
return context
|
||||
|
||||
|
||||
class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView):
|
||||
template_name = "frontend/organizations/service_offering_detail.html"
|
||||
class ServiceInstanceCreateView(OrganizationViewMixin, HtmxViewMixin, DetailView):
|
||||
template_name = "frontend/organizations/service_instance_create.html"
|
||||
context_object_name = "offering"
|
||||
model = ServiceOffering
|
||||
permission_type = "view"
|
||||
|
|
@ -276,7 +276,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
try:
|
||||
service_instance = ServiceInstance.create_instance(
|
||||
organization=self.request.organization,
|
||||
name=form.cleaned_data["name"],
|
||||
display_name=form.cleaned_data["display_name"],
|
||||
context=self.context_object,
|
||||
created_by=request.user,
|
||||
spec_data=form.get_nested_data().get("spec"),
|
||||
|
|
@ -377,17 +377,182 @@ class ServiceInstanceDetailView(
|
|||
"price_per_gib": self.object.context.control_plane.storage_plan_price_per_gib,
|
||||
}
|
||||
|
||||
# Calculate pricing summary
|
||||
context["pricing"] = self._calculate_pricing()
|
||||
|
||||
return context
|
||||
|
||||
def get_nested_spec(self):
|
||||
def _parse_disk_size_gib(self, disk_value):
|
||||
"""Parse disk size string (e.g., '10Gi', '100G') to GiB as integer."""
|
||||
if not disk_value:
|
||||
return None
|
||||
disk_str = str(disk_value)
|
||||
# Handle Gi suffix (GiB)
|
||||
if disk_str.endswith("Gi"):
|
||||
try:
|
||||
return int(disk_str[:-2])
|
||||
except ValueError:
|
||||
return None
|
||||
# Handle G suffix (assume GB, convert to GiB approximately)
|
||||
if disk_str.endswith("G"):
|
||||
try:
|
||||
return int(float(disk_str[:-1]) * 0.931) # GB to GiB
|
||||
except ValueError:
|
||||
return None
|
||||
# Handle plain number (assume GiB)
|
||||
try:
|
||||
return int(disk_str)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _calculate_pricing(self):
|
||||
"""Calculate hourly and monthly pricing for the instance."""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
pricing = {
|
||||
"compute_hourly": None,
|
||||
"compute_monthly": None,
|
||||
"storage_hourly": None,
|
||||
"storage_monthly": None,
|
||||
"total_hourly": None,
|
||||
"total_monthly": None,
|
||||
"disk_size_gib": None,
|
||||
}
|
||||
|
||||
hours_per_month = 720
|
||||
|
||||
# Compute plan pricing
|
||||
compute_assignment = self.object.compute_plan_assignment
|
||||
if compute_assignment:
|
||||
price = compute_assignment.price
|
||||
unit = compute_assignment.unit
|
||||
|
||||
# Convert to hourly
|
||||
if unit == "hour":
|
||||
hourly = price
|
||||
elif unit == "day":
|
||||
hourly = price / Decimal("24")
|
||||
elif unit == "month":
|
||||
hourly = price / Decimal(hours_per_month)
|
||||
elif unit == "year":
|
||||
hourly = price / Decimal(hours_per_month * 12)
|
||||
else:
|
||||
hourly = price # fallback
|
||||
|
||||
pricing["compute_hourly"] = hourly.quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
pricing["compute_monthly"] = (hourly * hours_per_month).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
# Storage pricing
|
||||
storage_price_per_gib = None
|
||||
if (
|
||||
self.object.context
|
||||
and self.object.context.control_plane.storage_plan_price_per_gib
|
||||
):
|
||||
storage_price_per_gib = (
|
||||
self.object.context.control_plane.storage_plan_price_per_gib
|
||||
)
|
||||
|
||||
# Get disk size from spec
|
||||
disk_size_gib = None
|
||||
if self.object.spec:
|
||||
disk_value = self._get_value_from_path(
|
||||
{"spec": self.object.spec}, "spec.parameters.size.disk"
|
||||
)
|
||||
disk_size_gib = self._parse_disk_size_gib(disk_value)
|
||||
pricing["disk_size_gib"] = disk_size_gib
|
||||
|
||||
if storage_price_per_gib and disk_size_gib:
|
||||
storage_hourly = storage_price_per_gib * disk_size_gib
|
||||
pricing["storage_hourly"] = storage_hourly.quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
pricing["storage_monthly"] = (storage_hourly * hours_per_month).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
# Total pricing
|
||||
if pricing["compute_hourly"] is not None or pricing["storage_hourly"] is not None:
|
||||
compute_h = pricing["compute_hourly"] or Decimal("0")
|
||||
storage_h = pricing["storage_hourly"] or Decimal("0")
|
||||
pricing["total_hourly"] = (compute_h + storage_h).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
pricing["total_monthly"] = (pricing["total_hourly"] * hours_per_month).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
return pricing
|
||||
|
||||
def _get_value_from_path(self, data, path):
|
||||
"""
|
||||
Organize spec data into fieldsets similar to how the form does it.
|
||||
Get a value from nested dict using dot notation path.
|
||||
e.g., 'spec.parameters.size.disk' from {'spec': {'parameters': {'size': {'disk': '10Gi'}}}}
|
||||
"""
|
||||
parts = path.split(".")
|
||||
current = data
|
||||
for part in parts:
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
def _get_form_config_fieldsets(self):
|
||||
"""
|
||||
Generate fieldsets from form_config, extracting values from spec.
|
||||
"""
|
||||
form_config = self.object.context.service_definition.form_config
|
||||
spec = self.object.spec or {}
|
||||
full_data = {"spec": spec, "name": self.object.name}
|
||||
|
||||
fieldsets = []
|
||||
for fieldset_config in form_config.get("fieldsets", []):
|
||||
fields = []
|
||||
for field_config in fieldset_config.get("fields", []):
|
||||
mapping = field_config.get("controlplane_field_mapping", "")
|
||||
# Skip the name field as it's shown in the header
|
||||
if mapping == "name":
|
||||
continue
|
||||
|
||||
value = self._get_value_from_path(full_data, mapping)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
label = field_config.get("label") or deslugify(mapping.split(".")[-1])
|
||||
|
||||
fields.append(
|
||||
{
|
||||
"key": mapping,
|
||||
"label": label,
|
||||
"value": value,
|
||||
"help_text": field_config.get("help_text", ""),
|
||||
}
|
||||
)
|
||||
|
||||
if fields:
|
||||
fieldsets.append(
|
||||
{
|
||||
"title": fieldset_config.get("title", "Configuration"),
|
||||
"fields": fields,
|
||||
"fieldsets": {},
|
||||
}
|
||||
)
|
||||
|
||||
return fieldsets
|
||||
|
||||
def _get_auto_generated_fieldsets(self):
|
||||
"""
|
||||
Auto-generate fieldsets from spec structure (fallback when no form_config).
|
||||
Excludes "General" tab - only returns nested structures.
|
||||
"""
|
||||
spec = self.object.spec or {}
|
||||
if not spec:
|
||||
return []
|
||||
|
||||
others = []
|
||||
nested_fieldsets = {}
|
||||
|
||||
# First pass: organize fields into nested structures
|
||||
|
|
@ -431,15 +596,6 @@ class ServiceInstanceDetailView(
|
|||
"value": sub_value,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# This is a top-level field
|
||||
others.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": deslugify(key),
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
|
||||
# Second pass: Promote fields based on count
|
||||
for group_key, group in list(nested_fieldsets.items()):
|
||||
|
|
@ -451,38 +607,40 @@ class ServiceInstanceDetailView(
|
|||
group["fields"].append(field)
|
||||
del group["fieldsets"][sub_key]
|
||||
|
||||
# Move single-field groups to others
|
||||
# Remove empty groups
|
||||
total_fields = len(group["fields"])
|
||||
for sub_fieldset in group["fieldsets"].values():
|
||||
total_fields += len(sub_fieldset["fields"])
|
||||
|
||||
if (
|
||||
total_fields == 1
|
||||
and len(group["fields"]) == 1
|
||||
and not group["fieldsets"]
|
||||
):
|
||||
field = group["fields"][0]
|
||||
field["label"] = f"{group['title']}: {field['label']}"
|
||||
others.append(field)
|
||||
del nested_fieldsets[group_key]
|
||||
elif total_fields == 0:
|
||||
if total_fields == 0:
|
||||
del nested_fieldsets[group_key]
|
||||
|
||||
# Create fieldsets from the organized data (no "General" tab)
|
||||
fieldsets = []
|
||||
if others:
|
||||
fieldsets.append(
|
||||
{
|
||||
"title": "General",
|
||||
"fields": others,
|
||||
"fieldsets": {},
|
||||
}
|
||||
)
|
||||
# Create fieldsets from the organized data
|
||||
for group in nested_fieldsets.values():
|
||||
fieldsets.append(group)
|
||||
|
||||
return fieldsets
|
||||
|
||||
def get_nested_spec(self):
|
||||
"""
|
||||
Organize spec data into fieldsets.
|
||||
Uses form_config when available, otherwise auto-generates from spec structure.
|
||||
"""
|
||||
spec = self.object.spec or {}
|
||||
if not spec:
|
||||
return []
|
||||
|
||||
# Check if form_config exists
|
||||
if (
|
||||
self.object.context
|
||||
and self.object.context.service_definition
|
||||
and self.object.context.service_definition.form_config
|
||||
):
|
||||
return self._get_form_config_fieldsets()
|
||||
|
||||
return self._get_auto_generated_fieldsets()
|
||||
|
||||
|
||||
class ServiceInstanceUpdateView(
|
||||
ServiceInstanceMixin, OrganizationViewMixin, HtmxUpdateView
|
||||
|
|
@ -604,6 +762,9 @@ class ServiceInstanceUpdateView(
|
|||
current_spec = dict(self.object.spec) if self.object.spec else {}
|
||||
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
|
||||
if self.plan_form.is_valid():
|
||||
compute_plan_assignment = self.plan_form.cleaned_data.get(
|
||||
|
|
@ -618,7 +779,7 @@ class ServiceInstanceUpdateView(
|
|||
messages.success(
|
||||
self.request,
|
||||
_("Service instance '{name}' updated successfully.").format(
|
||||
name=self.object.name
|
||||
name=self.object.display_name
|
||||
),
|
||||
)
|
||||
return redirect(self.object.urls.base)
|
||||
|
|
@ -679,7 +840,7 @@ class ServiceInstanceDeleteView(
|
|||
messages.success(
|
||||
self.request,
|
||||
_("Service instance '{name}' has been scheduled for deletion.").format(
|
||||
name=self.object.name
|
||||
name=self.object.display_name
|
||||
),
|
||||
)
|
||||
response = HttpResponse()
|
||||
|
|
@ -690,7 +851,7 @@ class ServiceInstanceDeleteView(
|
|||
self.request,
|
||||
self.organization.add_support_message(
|
||||
_(
|
||||
f"An error occurred while trying to delete instance '{self.object.name}': {str(e)}."
|
||||
f"An error occurred while trying to delete instance '{self.object.display_name}': {str(e)}."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -270,6 +270,9 @@ SESSION_COOKIE_SECURE = not DEBUG
|
|||
DEFAULT_LABEL_KEY = "appcat.vshn.io/provider-config"
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# Prefix for auto-generated Kubernetes resource names for service instances
|
||||
SERVALA_INSTANCE_NAME_PREFIX = os.environ.get("SERVALA_INSTANCE_NAME_PREFIX", "si")
|
||||
|
||||
# TODO
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
|
|
|
|||
|
|
@ -361,3 +361,9 @@ html[data-bs-theme="dark"] .beta-banner-button:hover {
|
|||
background-color: white;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
/* Fixed table layout for credentials */
|
||||
.table-fixed {
|
||||
table-layout: fixed;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
|
|
|||
68
src/servala/static/js/credentials.js
Normal file
68
src/servala/static/js/credentials.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
const CREDENTIAL_MASK = '••••••••••••';
|
||||
|
||||
function toggleCredential(button) {
|
||||
const row = button.closest('tr');
|
||||
const codeEl = row.querySelector('.credential-value');
|
||||
const icon = button.querySelector('i');
|
||||
|
||||
if (!codeEl) return;
|
||||
|
||||
const isHidden = codeEl.textContent === CREDENTIAL_MASK;
|
||||
|
||||
if (isHidden) {
|
||||
codeEl.textContent = codeEl.getAttribute('data-value');
|
||||
icon.classList.remove('bi-eye');
|
||||
icon.classList.add('bi-eye-slash');
|
||||
} else {
|
||||
codeEl.textContent = CREDENTIAL_MASK;
|
||||
icon.classList.remove('bi-eye-slash');
|
||||
icon.classList.add('bi-eye');
|
||||
}
|
||||
|
||||
updateShowAllButton();
|
||||
}
|
||||
|
||||
function toggleAllCredentials() {
|
||||
const credentials = document.querySelectorAll('.credential-value');
|
||||
const anyHidden = Array.from(credentials).some(el => el.textContent === CREDENTIAL_MASK);
|
||||
|
||||
credentials.forEach(function (codeEl) {
|
||||
const row = codeEl.closest('tr');
|
||||
const button = row.querySelector('.credential-toggle');
|
||||
const icon = button ? button.querySelector('i') : null;
|
||||
|
||||
if (anyHidden) {
|
||||
codeEl.textContent = codeEl.getAttribute('data-value');
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-eye');
|
||||
icon.classList.add('bi-eye-slash');
|
||||
}
|
||||
} else {
|
||||
codeEl.textContent = CREDENTIAL_MASK;
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-eye-slash');
|
||||
icon.classList.add('bi-eye');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
updateShowAllButton();
|
||||
}
|
||||
|
||||
function updateShowAllButton() {
|
||||
const credentials = document.querySelectorAll('.credential-value');
|
||||
const anyHidden = Array.from(credentials).some(el => el.textContent === CREDENTIAL_MASK);
|
||||
const toggleBtn = document.getElementById('toggle-credentials-btn');
|
||||
const toggleText = document.getElementById('toggle-credentials-text');
|
||||
const toggleIcon = toggleBtn ? toggleBtn.querySelector('i') : null;
|
||||
|
||||
if (toggleText && toggleBtn) {
|
||||
const showText = toggleBtn.getAttribute('data-show-text') || 'Show All';
|
||||
const hideText = toggleBtn.getAttribute('data-hide-text') || 'Hide All';
|
||||
toggleText.textContent = anyHidden ? showText : hideText;
|
||||
}
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.toggle('bi-eye', anyHidden);
|
||||
toggleIcon.classList.toggle('bi-eye-slash', !anyHidden);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
const initializeFqdnGeneration = (prefix) => {
|
||||
const nameField = document.querySelector(`input#id_${prefix}-name`);
|
||||
const nameField = document.querySelector(`input#id_${prefix}-display_name`);
|
||||
if (!nameField) return
|
||||
|
||||
// Try to find array input first (DynamicArrayWidget), then fallback to regular text input
|
||||
|
|
@ -23,9 +23,19 @@ const initializeFqdnGeneration = (prefix) => {
|
|||
if (!fqdnField) return
|
||||
|
||||
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) => {
|
||||
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() {
|
||||
|
|
@ -63,9 +73,7 @@ const runFqdnInit = () => {
|
|||
initializeFqdnGeneration("expert");
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
runFqdnInit()
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', runFqdnInit)
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'service-form') runFqdnInit()
|
||||
});
|
||||
|
|
|
|||
43
src/servala/static/js/local-time.js
Normal file
43
src/servala/static/js/local-time.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
const convertToLocalTime = () => {
|
||||
document.querySelectorAll('time.local-time').forEach((el) => {
|
||||
const isoDate = el.getAttribute('datetime')
|
||||
const format = el.getAttribute('data-format') || 'datetime'
|
||||
|
||||
if (!isoDate) return
|
||||
|
||||
try {
|
||||
const date = new Date(isoDate)
|
||||
if (isNaN(date.getTime())) return
|
||||
|
||||
let options = {}
|
||||
if (format === 'date') {
|
||||
options = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}
|
||||
} else if (format === 'time') {
|
||||
options = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}
|
||||
} else {
|
||||
options = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}
|
||||
}
|
||||
|
||||
el.textContent = date.toLocaleString(undefined, options)
|
||||
el.title = date.toISOString()
|
||||
} catch (e) {
|
||||
console.error('Error parsing date:', isoDate, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', convertToLocalTime)
|
||||
|
|
@ -18,6 +18,7 @@ from servala.core.models.service import (
|
|||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDefinition,
|
||||
ServiceInstance,
|
||||
ServiceOffering,
|
||||
)
|
||||
|
||||
|
|
@ -44,15 +45,38 @@ def other_organization(origin):
|
|||
|
||||
@pytest.fixture
|
||||
def org_owner(organization):
|
||||
user = User.objects.create(email="user@example.org", password="example")
|
||||
owner = User.objects.create(email="owner@example.org", password="example")
|
||||
OrganizationMembership.objects.create(
|
||||
organization=organization, user=user, role="owner"
|
||||
organization=organization, user=owner, role="owner"
|
||||
)
|
||||
return user
|
||||
return owner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_service_category():
|
||||
def org_admin(organization):
|
||||
admin = User.objects.create(email="admin@example.org", password="example")
|
||||
OrganizationMembership.objects.create(
|
||||
organization=organization, user=admin, role="admin"
|
||||
)
|
||||
return admin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_member(organization):
|
||||
member = User.objects.create(email="member@example.org", password="example")
|
||||
OrganizationMembership.objects.create(
|
||||
organization=organization, user=member, role="member"
|
||||
)
|
||||
return member
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user():
|
||||
return User.objects.create(email="generic-user@example.org", password="example")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_category():
|
||||
return ServiceCategory.objects.create(
|
||||
name="Databases",
|
||||
description="Database services",
|
||||
|
|
@ -60,18 +84,18 @@ def test_service_category():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def test_service(test_service_category):
|
||||
def service(service_category):
|
||||
return Service.objects.create(
|
||||
name="Redis",
|
||||
slug="redis",
|
||||
category=test_service_category,
|
||||
category=service_category,
|
||||
description="Redis database service",
|
||||
osb_service_id="test-service-123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_cloud_provider():
|
||||
def cloud_provider():
|
||||
return CloudProvider.objects.create(
|
||||
name="Exoscale",
|
||||
description="Exoscale cloud provider",
|
||||
|
|
@ -79,10 +103,10 @@ def test_cloud_provider():
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def test_service_offering(test_service, test_cloud_provider):
|
||||
def service_offering(service, cloud_provider):
|
||||
return ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
provider=test_cloud_provider,
|
||||
service=service,
|
||||
provider=cloud_provider,
|
||||
description="Redis on Exoscale",
|
||||
osb_plan_id="test-plan-123",
|
||||
)
|
||||
|
|
@ -125,11 +149,11 @@ def mock_odoo_failure(mocker):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def test_control_plane(test_cloud_provider):
|
||||
def control_plane(cloud_provider):
|
||||
return ControlPlane.objects.create(
|
||||
name="Geneva (CH-GVA-2)",
|
||||
description="Geneva control plane",
|
||||
cloud_provider=test_cloud_provider,
|
||||
cloud_provider=cloud_provider,
|
||||
api_credentials={
|
||||
"server": "https://k8s.example.com",
|
||||
"token": "test-token",
|
||||
|
|
@ -139,10 +163,10 @@ def test_control_plane(test_cloud_provider):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def test_service_definition(test_service):
|
||||
def service_definition(service):
|
||||
return ServiceDefinition.objects.create(
|
||||
name="Redis Standard",
|
||||
service=test_service,
|
||||
service=service,
|
||||
api_definition={
|
||||
"group": "vshn.appcat.vshn.io",
|
||||
"version": "v1",
|
||||
|
|
@ -152,13 +176,11 @@ def test_service_definition(test_service):
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
def test_control_plane_crd(
|
||||
test_service_offering, test_control_plane, test_service_definition
|
||||
):
|
||||
def control_plane_crd(service_offering, control_plane, service_definition):
|
||||
return ControlPlaneCRD.objects.create(
|
||||
service_offering=test_service_offering,
|
||||
control_plane=test_control_plane,
|
||||
service_definition=test_service_definition,
|
||||
service_offering=service_offering,
|
||||
control_plane=control_plane,
|
||||
service_definition=service_definition,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -176,10 +198,10 @@ def compute_plan():
|
|||
|
||||
|
||||
@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(
|
||||
compute_plan=compute_plan,
|
||||
control_plane_crd=test_control_plane_crd,
|
||||
control_plane_crd=control_plane_crd,
|
||||
sla="besteffort",
|
||||
odoo_product_id="test-product-id",
|
||||
odoo_unit_id="test-unit-id",
|
||||
|
|
@ -187,3 +209,30 @@ def compute_plan_assignment(compute_plan, test_control_plane_crd):
|
|||
unit="hour",
|
||||
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(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
|
||||
response = osb_client.put(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
|
|
@ -107,15 +107,15 @@ def test_successful_onboarding_new_organization(
|
|||
@pytest.mark.django_db
|
||||
def test_new_organization_inherits_origin(
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
billing_entity,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
exoscale_origin.billing_entity = billing_entity
|
||||
exoscale_origin.save()
|
||||
|
||||
|
|
@ -137,8 +137,8 @@ def test_new_organization_inherits_origin(
|
|||
@pytest.mark.django_db
|
||||
def test_duplicate_organization_returns_existing(
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
|
|
@ -148,10 +148,10 @@ def test_duplicate_organization_returns_existing(
|
|||
osb_guid="test-org-guid-123",
|
||||
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["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
|
||||
response = osb_client.put(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
|
|
@ -169,13 +169,13 @@ def test_duplicate_organization_returns_existing(
|
|||
@pytest.mark.django_db
|
||||
def test_unauthenticated_osb_api_request_fails(
|
||||
client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
valid_osb_payload,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
|
||||
response = client.put(
|
||||
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(
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
valid_osb_payload,
|
||||
field_to_remove,
|
||||
expected_error,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
|
||||
if isinstance(field_to_remove, tuple):
|
||||
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
|
||||
def test_invalid_plan_id_error(
|
||||
osb_client, test_service, valid_osb_payload, instance_id
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
def test_invalid_plan_id_error(osb_client, service, valid_osb_payload, instance_id):
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = 99999
|
||||
|
||||
response = osb_client.put(
|
||||
|
|
@ -266,17 +264,17 @@ def test_invalid_plan_id_error(
|
|||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content)
|
||||
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"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
valid_osb_payload["parameters"]["users"] = []
|
||||
|
||||
response = osb_client.put(
|
||||
|
|
@ -292,10 +290,10 @@ def test_empty_users_array_error(
|
|||
|
||||
@pytest.mark.django_db
|
||||
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["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
valid_osb_payload["parameters"]["users"] = [
|
||||
{"email": "user1@example.com", "full_name": "User One"},
|
||||
{"email": "user2@example.com", "full_name": "User Two"},
|
||||
|
|
@ -314,10 +312,10 @@ def test_multiple_users_error(
|
|||
|
||||
@pytest.mark.django_db
|
||||
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["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
valid_osb_payload["parameters"]["users"] = [
|
||||
{"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(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
valid_osb_payload["parameters"]["users"][0]["full_name"] = "John Doe Smith"
|
||||
|
||||
response = osb_client.put(
|
||||
|
|
@ -376,14 +374,14 @@ def test_user_creation_with_name_parsing(
|
|||
def test_email_normalization(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
valid_osb_payload["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM "
|
||||
|
||||
response = osb_client.put(
|
||||
|
|
@ -401,14 +399,14 @@ def test_email_normalization(
|
|||
def test_odoo_integration_failure_handling(
|
||||
mock_odoo_failure,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
valid_osb_payload["service_id"] = service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = service_offering.osb_plan_id
|
||||
|
||||
response = osb_client.put(
|
||||
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(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
):
|
||||
payload = {
|
||||
"service_id": test_service.osb_service_id,
|
||||
"plan_id": test_service_offering.osb_plan_id,
|
||||
"service_id": service.osb_service_id,
|
||||
"plan_id": service_offering.osb_plan_id,
|
||||
"context": {
|
||||
"organization_guid": "fallback-org-guid",
|
||||
"organization_name": "Fallback Organization",
|
||||
|
|
@ -462,13 +460,13 @@ def test_organization_creation_with_context_only(
|
|||
def test_delete_offboarding_success(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
instance_id,
|
||||
):
|
||||
response = osb_client.delete(
|
||||
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
|
||||
|
|
@ -476,9 +474,9 @@ def test_delete_offboarding_success(
|
|||
|
||||
|
||||
@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(
|
||||
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
|
||||
|
|
@ -487,9 +485,9 @@ def test_delete_missing_service_id(osb_client, test_service_offering, instance_i
|
|||
|
||||
|
||||
@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(
|
||||
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
|
||||
|
|
@ -509,16 +507,16 @@ def test_delete_invalid_service_id(osb_client, instance_id):
|
|||
|
||||
|
||||
@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(
|
||||
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
|
||||
response_data = json.loads(response.content)
|
||||
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"]
|
||||
)
|
||||
|
||||
|
|
@ -527,13 +525,13 @@ def test_delete_invalid_plan_id(osb_client, test_service, instance_id):
|
|||
def test_patch_suspension_success(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
instance_id,
|
||||
):
|
||||
payload = {
|
||||
"service_id": test_service.osb_service_id,
|
||||
"plan_id": test_service_offering.osb_plan_id,
|
||||
"service_id": service.osb_service_id,
|
||||
"plan_id": service_offering.osb_plan_id,
|
||||
"parameters": {
|
||||
"users": [
|
||||
{
|
||||
|
|
@ -556,9 +554,9 @@ def test_patch_suspension_success(
|
|||
|
||||
|
||||
@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 = {
|
||||
"plan_id": test_service_offering.osb_plan_id,
|
||||
"plan_id": service_offering.osb_plan_id,
|
||||
"parameters": {"users": []},
|
||||
}
|
||||
|
||||
|
|
@ -574,9 +572,9 @@ def test_patch_missing_service_id(osb_client, test_service_offering, instance_id
|
|||
|
||||
|
||||
@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 = {
|
||||
"service_id": test_service.osb_service_id,
|
||||
"service_id": service.osb_service_id,
|
||||
"parameters": {"users": []},
|
||||
}
|
||||
|
||||
|
|
@ -609,8 +607,8 @@ def test_delete_creates_ticket_with_admin_links(
|
|||
mocker,
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
instance_id,
|
||||
):
|
||||
# Mock the create_helpdesk_ticket function
|
||||
|
|
@ -618,7 +616,7 @@ def test_delete_creates_ticket_with_admin_links(
|
|||
|
||||
response = osb_client.delete(
|
||||
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
|
||||
|
|
@ -629,10 +627,10 @@ def test_delete_creates_ticket_with_admin_links(
|
|||
|
||||
# Check that the description contains an admin URL
|
||||
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 (
|
||||
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,
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
instance_id,
|
||||
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")
|
||||
|
||||
payload = {
|
||||
"service_id": test_service.osb_service_id,
|
||||
"plan_id": test_service_offering.osb_plan_id,
|
||||
"service_id": service.osb_service_id,
|
||||
"plan_id": service_offering.osb_plan_id,
|
||||
"parameters": {
|
||||
"users": [
|
||||
{
|
||||
|
|
@ -680,8 +678,7 @@ def test_patch_creates_ticket_with_user_admin_links(
|
|||
assert "admin/core/user" in call_kwargs["description"]
|
||||
assert f"/{org_owner.pk}/" in call_kwargs["description"]
|
||||
assert (
|
||||
call_kwargs["title"]
|
||||
== f"Exoscale OSB Suspend - {test_service.name} - {instance_id}"
|
||||
call_kwargs["title"] == f"Exoscale OSB Suspend - {service.name} - {instance_id}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -690,8 +687,8 @@ def test_ticket_includes_organization_and_instance_when_found(
|
|||
mocker,
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
service,
|
||||
service_offering,
|
||||
organization,
|
||||
):
|
||||
# Mock the create_helpdesk_ticket function
|
||||
|
|
@ -699,12 +696,12 @@ def test_ticket_includes_organization_and_instance_when_found(
|
|||
|
||||
service_definition = ServiceDefinition.objects.create(
|
||||
name="Test Definition",
|
||||
service=test_service,
|
||||
service=service,
|
||||
api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"},
|
||||
)
|
||||
control_plane = ControlPlane.objects.create(
|
||||
name="Test Control Plane",
|
||||
cloud_provider=test_service_offering.provider,
|
||||
cloud_provider=service_offering.provider,
|
||||
api_credentials={
|
||||
"certificate-authority-data": "test",
|
||||
"server": "https://test",
|
||||
|
|
@ -712,20 +709,22 @@ def test_ticket_includes_organization_and_instance_when_found(
|
|||
},
|
||||
)
|
||||
crd = ControlPlaneCRD.objects.create(
|
||||
service_offering=test_service_offering,
|
||||
service_offering=service_offering,
|
||||
control_plane=control_plane,
|
||||
service_definition=service_definition,
|
||||
)
|
||||
instance_name = "test-instance-123"
|
||||
instance_display_name = "Test Instance 123"
|
||||
service_instance = ServiceInstance.objects.create(
|
||||
name=instance_name,
|
||||
display_name=instance_display_name,
|
||||
organization=organization,
|
||||
context=crd,
|
||||
)
|
||||
|
||||
response = osb_client.delete(
|
||||
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
|
||||
|
|
@ -739,7 +738,10 @@ def test_ticket_includes_organization_and_instance_when_found(
|
|||
assert "admin/core/organization" in call_kwargs["description"]
|
||||
assert f"/{organization.pk}/" in call_kwargs["description"]
|
||||
|
||||
# Check instance is included
|
||||
assert f"Instance: {service_instance.name}" in call_kwargs["description"]
|
||||
# Check instance is included (format: "display_name (resource_name)")
|
||||
assert (
|
||||
f"Instance: {service_instance.display_name} ({service_instance.name})"
|
||||
in call_kwargs["description"]
|
||||
)
|
||||
assert "admin/core/serviceinstance" in call_kwargs["description"]
|
||||
assert f"/{service_instance.pk}/" in call_kwargs["description"]
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
|
|
@ -32,7 +32,7 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
|
|||
crd.service_definition = service_def
|
||||
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
|
||||
class Meta:
|
||||
app_label = "test"
|
||||
|
|
@ -193,7 +193,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
|
|||
"""Test that choice fields use custom choices when provided in form_config"""
|
||||
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
environment = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
|
|
@ -215,7 +215,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
|
|
@ -246,7 +246,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
|
|||
def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
|
||||
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
environment = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
|
|
@ -267,7 +267,7 @@ def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
|
|
@ -292,7 +292,7 @@ def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
|
|||
|
||||
def test_choice_field_validates_against_control_plane_choices():
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
environment = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
|
|
@ -313,7 +313,7 @@ def test_choice_field_validates_against_control_plane_choices():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
|
|
@ -330,15 +330,15 @@ def test_choice_field_validates_against_control_plane_choices():
|
|||
|
||||
form_class = generate_custom_form_class(form_config, TestModel)
|
||||
|
||||
form = form_class(data={"name": "test-service", "environment": "dev"})
|
||||
form = form_class(data={"display_name": "test-service", "environment": "dev"})
|
||||
form.fields["context"].required = False # Skip context validation
|
||||
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
|
||||
|
||||
form = form_class(data={"name": "test-service", "environment": "prod"})
|
||||
form = form_class(data={"display_name": "test-service", "environment": "prod"})
|
||||
form.fields["context"].required = False # Skip context validation
|
||||
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
|
||||
|
||||
form = form_class(data={"name": "test-service", "environment": "invalid"})
|
||||
form = form_class(data={"display_name": "test-service", "environment": "invalid"})
|
||||
form.fields["context"].required = False # Skip context validation
|
||||
assert not form.is_valid()
|
||||
assert "environment" in form.errors
|
||||
|
|
@ -368,7 +368,7 @@ def test_admin_form_validates_choice_values_against_schema():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
},
|
||||
{
|
||||
"type": "choice",
|
||||
|
|
@ -399,7 +399,7 @@ def test_admin_form_validates_choice_values_against_schema():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
},
|
||||
{
|
||||
"type": "choice",
|
||||
|
|
@ -431,7 +431,7 @@ def test_admin_form_validates_choice_values_against_schema():
|
|||
|
||||
def test_number_field_min_max_sets_widget_attributes():
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
port = models.IntegerField()
|
||||
replica_count = models.IntegerField()
|
||||
|
||||
|
|
@ -446,7 +446,7 @@ def test_number_field_min_max_sets_widget_attributes():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
|
|
@ -494,7 +494,7 @@ def test_number_field_min_max_sets_widget_attributes():
|
|||
def test_default_value_for_all_field_types():
|
||||
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
description = models.TextField()
|
||||
port = models.IntegerField()
|
||||
environment = models.CharField(
|
||||
|
|
@ -518,7 +518,7 @@ def test_default_value_for_all_field_types():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"default_value": "default-name",
|
||||
},
|
||||
{
|
||||
|
|
@ -559,7 +559,7 @@ def test_default_value_for_all_field_types():
|
|||
form_class = generate_custom_form_class(form_config, TestModel)
|
||||
form = form_class()
|
||||
|
||||
assert form.fields["name"].initial == "default-name"
|
||||
assert form.fields["display_name"].initial == "default-name"
|
||||
assert form.fields["description"].initial == "Default description text"
|
||||
assert form.fields["port"].initial == "8080"
|
||||
assert form.fields["environment"].initial == "dev"
|
||||
|
|
@ -570,7 +570,7 @@ def test_default_value_for_all_field_types():
|
|||
def test_default_value_not_override_existing_instance():
|
||||
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
port = models.IntegerField()
|
||||
|
||||
class Meta:
|
||||
|
|
@ -583,7 +583,7 @@ def test_default_value_not_override_existing_instance():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"default_value": "default-name",
|
||||
},
|
||||
{
|
||||
|
|
@ -597,11 +597,11 @@ def test_default_value_not_override_existing_instance():
|
|||
]
|
||||
}
|
||||
|
||||
instance = TestModel(name="existing-name", port=3000)
|
||||
instance = TestModel(display_name="existing-name", port=3000)
|
||||
form_class = generate_custom_form_class(form_config, TestModel)
|
||||
form = form_class(instance=instance)
|
||||
|
||||
assert form.initial["name"] == "existing-name"
|
||||
assert form.initial["display_name"] == "existing-name"
|
||||
assert form.initial["port"] == 3000
|
||||
|
||||
|
||||
|
|
@ -708,7 +708,7 @@ def test_form_config_handles_empty_string_as_none():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"max_length": "", # Empty string
|
||||
},
|
||||
]
|
||||
|
|
@ -744,7 +744,7 @@ def test_single_element_choices_are_normalized():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
},
|
||||
{
|
||||
"type": "choice",
|
||||
|
|
@ -879,7 +879,7 @@ def test_three_plus_element_choices_fail_validation():
|
|||
def test_field_with_default_config_only_needs_mapping():
|
||||
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
|
||||
class Meta:
|
||||
app_label = "test"
|
||||
|
|
@ -889,7 +889,7 @@ def test_field_with_default_config_only_needs_mapping():
|
|||
{
|
||||
"fields": [
|
||||
{
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -899,16 +899,16 @@ def test_field_with_default_config_only_needs_mapping():
|
|||
form_class = generate_custom_form_class(minimal_config, TestModel)
|
||||
form = form_class()
|
||||
|
||||
name_field = form.fields["name"]
|
||||
assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"]
|
||||
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"]
|
||||
assert name_field.required == DEFAULT_FIELD_CONFIGS["name"]["required"]
|
||||
name_field = form.fields["display_name"]
|
||||
assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"]
|
||||
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
|
||||
assert name_field.required == DEFAULT_FIELD_CONFIGS["display_name"]["required"]
|
||||
|
||||
|
||||
def test_field_with_default_config_can_override_defaults():
|
||||
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
|
||||
class Meta:
|
||||
app_label = "test"
|
||||
|
|
@ -918,7 +918,7 @@ def test_field_with_default_config_can_override_defaults():
|
|||
{
|
||||
"fields": [
|
||||
{
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"label": "Custom Name Label",
|
||||
"required": False,
|
||||
},
|
||||
|
|
@ -930,16 +930,16 @@ def test_field_with_default_config_can_override_defaults():
|
|||
form_class = generate_custom_form_class(override_config, TestModel)
|
||||
form = form_class()
|
||||
|
||||
name_field = form.fields["name"]
|
||||
name_field = form.fields["display_name"]
|
||||
assert name_field.label == "Custom Name Label"
|
||||
assert name_field.required is False
|
||||
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"]
|
||||
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
|
||||
|
||||
|
||||
def test_empty_values_dont_override_default_configs():
|
||||
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
|
||||
class Meta:
|
||||
app_label = "test"
|
||||
|
|
@ -949,7 +949,7 @@ def test_empty_values_dont_override_default_configs():
|
|||
{
|
||||
"fields": [
|
||||
{
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"type": "",
|
||||
"label": "",
|
||||
"help_text": None,
|
||||
|
|
@ -964,11 +964,11 @@ def test_empty_values_dont_override_default_configs():
|
|||
form_class = generate_custom_form_class(admin_form_config, TestModel)
|
||||
form = form_class()
|
||||
|
||||
name_field = form.fields["name"]
|
||||
name_field = form.fields["display_name"]
|
||||
|
||||
assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"]
|
||||
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"]
|
||||
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"]
|
||||
assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"]
|
||||
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
|
||||
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["display_name"]["max_length"]
|
||||
|
||||
assert name_field.required is False # Was overridden by explicit False
|
||||
|
||||
|
|
@ -976,7 +976,7 @@ def test_empty_values_dont_override_default_configs():
|
|||
def test_number_field_validates_min_max_values():
|
||||
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
port = models.IntegerField()
|
||||
|
||||
class Meta:
|
||||
|
|
@ -990,7 +990,7 @@ def test_number_field_validates_min_max_values():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
|
|
@ -1009,26 +1009,26 @@ def test_number_field_validates_min_max_values():
|
|||
form_class = generate_custom_form_class(form_config, TestModel)
|
||||
|
||||
# Test value below minimum fails validation
|
||||
form = form_class(data={"name": "test-service", "port": 0})
|
||||
form = form_class(data={"display_name": "test-service", "port": 0})
|
||||
form.fields["context"].required = False
|
||||
assert not form.is_valid()
|
||||
assert "port" in form.errors
|
||||
|
||||
# Test value above maximum fails validation
|
||||
form = form_class(data={"name": "test-service", "port": 65536})
|
||||
form = form_class(data={"display_name": "test-service", "port": 65536})
|
||||
form.fields["context"].required = False
|
||||
assert not form.is_valid()
|
||||
assert "port" in form.errors
|
||||
|
||||
# Test valid value passes validation
|
||||
form = form_class(data={"name": "test-service", "port": 8080})
|
||||
form = form_class(data={"display_name": "test-service", "port": 8080})
|
||||
form.fields["context"].required = False
|
||||
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
|
||||
|
||||
|
||||
def test_number_field_with_addon_text_roundtrip():
|
||||
class TestModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
display_name = models.CharField(max_length=100)
|
||||
disk_size = models.IntegerField()
|
||||
|
||||
class Meta:
|
||||
|
|
@ -1041,7 +1041,7 @@ def test_number_field_with_addon_text_roundtrip():
|
|||
{
|
||||
"type": "text",
|
||||
"label": "Name",
|
||||
"controlplane_field_mapping": "name",
|
||||
"controlplane_field_mapping": "display_name",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
|
|
@ -1059,7 +1059,7 @@ def test_number_field_with_addon_text_roundtrip():
|
|||
form = form_class(initial={"name": "test-instance", "disk_size": "25Gi"})
|
||||
|
||||
assert form.initial["disk_size"] == 25
|
||||
form = form_class(data={"name": "test-instance", "disk_size": "25"})
|
||||
form = form_class(data={"display_name": "test-instance", "disk_size": "25"})
|
||||
form.fields["context"].required = False
|
||||
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
|
||||
nested_data = form.get_nested_data()
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ class TestReencryptFieldsCommand:
|
|||
assert "Starting re-encryption" 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()
|
||||
call_command("reencrypt_fields", stdout=out)
|
||||
|
||||
|
|
@ -147,11 +147,11 @@ class TestSyncBillingMetadataCommand:
|
|||
|
||||
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()
|
||||
call_command("sync_billing_metadata", "--dry-run", stdout=out)
|
||||
|
||||
output = out.getvalue()
|
||||
assert "DRY RUN" 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
|
||||
|
|
|
|||
151
src/tests/test_rules.py
Normal file
151
src/tests/test_rules.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import pytest
|
||||
from django_scopes import scope
|
||||
|
||||
from servala.core.models.organization import OrganizationRole
|
||||
from servala.core.rules import (
|
||||
has_organization_role,
|
||||
is_organization_admin,
|
||||
is_organization_member,
|
||||
is_organization_owner,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_has_organization_role_returns_false_for_non_organization_object(user):
|
||||
assert has_organization_role(user, "not an organization", None) is False
|
||||
|
||||
|
||||
def test_has_organization_role_returns_false_for_non_member(user, organization):
|
||||
with scope(organization=organization):
|
||||
assert has_organization_role(user, organization, None) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_fixture", ["org_owner", "org_admin", "org_member"])
|
||||
def test_has_organization_role_returns_true_for_any_member(
|
||||
user_fixture, organization, request
|
||||
):
|
||||
user = request.getfixturevalue(user_fixture)
|
||||
with scope(organization=organization):
|
||||
assert has_organization_role(user, organization, None) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_fixture,roles,expected",
|
||||
[
|
||||
("org_owner", [OrganizationRole.OWNER], True),
|
||||
("org_owner", [OrganizationRole.ADMIN], False),
|
||||
("org_owner", [OrganizationRole.MEMBER], False),
|
||||
("org_owner", [OrganizationRole.OWNER, OrganizationRole.ADMIN], True),
|
||||
("org_admin", [OrganizationRole.ADMIN], True),
|
||||
("org_admin", [OrganizationRole.OWNER], False),
|
||||
("org_admin", [OrganizationRole.OWNER, OrganizationRole.ADMIN], True),
|
||||
("org_member", [OrganizationRole.MEMBER], True),
|
||||
("org_member", [OrganizationRole.OWNER], False),
|
||||
("org_member", [OrganizationRole.ADMIN], False),
|
||||
],
|
||||
)
|
||||
def test_has_organization_role_filters_by_roles(
|
||||
user_fixture, roles, expected, organization, request
|
||||
):
|
||||
user = request.getfixturevalue(user_fixture)
|
||||
with scope(organization=organization):
|
||||
assert has_organization_role(user, organization, roles) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_fixture,expected",
|
||||
[
|
||||
("org_owner", True),
|
||||
("org_admin", False),
|
||||
("org_member", False),
|
||||
],
|
||||
)
|
||||
def test_is_organization_owner(user_fixture, expected, organization, request):
|
||||
user = request.getfixturevalue(user_fixture)
|
||||
with scope(organization=organization):
|
||||
assert is_organization_owner(user, organization) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_fixture,expected",
|
||||
[
|
||||
("org_owner", True),
|
||||
("org_admin", False),
|
||||
("org_member", False),
|
||||
],
|
||||
)
|
||||
def test_is_organization_owner_with_related_object(
|
||||
user_fixture, expected, organization, mocker, request
|
||||
):
|
||||
user = request.getfixturevalue(user_fixture)
|
||||
obj = mocker.MagicMock()
|
||||
obj.organization = organization
|
||||
with scope(organization=organization):
|
||||
assert is_organization_owner(user, obj) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_fixture,expected",
|
||||
[
|
||||
("org_owner", True),
|
||||
("org_admin", True),
|
||||
("org_member", False),
|
||||
],
|
||||
)
|
||||
def test_is_organization_admin(user_fixture, expected, organization, request):
|
||||
user = request.getfixturevalue(user_fixture)
|
||||
with scope(organization=organization):
|
||||
assert is_organization_admin(user, organization) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_fixture,expected",
|
||||
[
|
||||
("org_owner", True),
|
||||
("org_admin", True),
|
||||
("org_member", False),
|
||||
],
|
||||
)
|
||||
def test_is_organization_admin_with_related_object(
|
||||
user_fixture, expected, organization, mocker, request
|
||||
):
|
||||
user = request.getfixturevalue(user_fixture)
|
||||
obj = mocker.MagicMock()
|
||||
obj.organization = organization
|
||||
with scope(organization=organization):
|
||||
assert is_organization_admin(user, obj) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_fixture", ["org_owner", "org_admin", "org_member"])
|
||||
def test_is_organization_member_returns_true_for_members(
|
||||
user_fixture, organization, request
|
||||
):
|
||||
user = request.getfixturevalue(user_fixture)
|
||||
with scope(organization=organization):
|
||||
assert is_organization_member(user, organization) is True
|
||||
|
||||
|
||||
def test_is_organization_member_returns_false_for_non_member(user, organization):
|
||||
with scope(organization=organization):
|
||||
assert is_organization_member(user, organization) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user_fixture", ["org_owner", "org_admin", "org_member"])
|
||||
def test_is_organization_member_with_related_object(
|
||||
user_fixture, organization, mocker, request
|
||||
):
|
||||
user = request.getfixturevalue(user_fixture)
|
||||
obj = mocker.MagicMock()
|
||||
obj.organization = organization
|
||||
with scope(organization=organization):
|
||||
assert is_organization_member(user, obj) is True
|
||||
|
||||
|
||||
def test_is_organization_member_with_related_object_returns_false_for_non_member(
|
||||
user, organization, mocker
|
||||
):
|
||||
obj = mocker.MagicMock()
|
||||
obj.organization = organization
|
||||
with scope(organization=organization):
|
||||
assert is_organization_member(user, obj) is False
|
||||
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
|
||||
|
||||
from servala.core.models.service import CloudProvider, ServiceOffering
|
||||
from servala.core.models.service import (
|
||||
CloudProvider,
|
||||
ControlPlane,
|
||||
ControlPlaneCRD,
|
||||
ServiceInstance,
|
||||
ServiceOffering,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -51,76 +59,76 @@ def test_organization_linked_in_sidebar(
|
|||
|
||||
@pytest.mark.django_db
|
||||
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)
|
||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
||||
url = f"/org/{organization.slug}/services/{service.slug}/"
|
||||
response = client.get(url)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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(
|
||||
name="AWS", description="Amazon Web Services"
|
||||
)
|
||||
second_offering = ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
service=service,
|
||||
provider=second_provider,
|
||||
description="Redis on AWS",
|
||||
osb_plan_id="test-plan-456",
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
assert response.status_code == 200
|
||||
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 "Create Instance" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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(
|
||||
name="AWS", description="Amazon Web Services"
|
||||
)
|
||||
ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
service=service,
|
||||
provider=second_provider,
|
||||
description="Redis on AWS",
|
||||
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)
|
||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
||||
url = f"/org/{organization.slug}/services/{service.slug}/"
|
||||
response = client.get(url)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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(
|
||||
name="AWS", description="Amazon Web Services"
|
||||
)
|
||||
second_offering = ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
service=service,
|
||||
provider=second_provider,
|
||||
description="Redis on AWS",
|
||||
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"
|
||||
)
|
||||
third_offering = ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
service=service,
|
||||
provider=third_provider,
|
||||
description="Redis on Azure",
|
||||
osb_plan_id="test-plan-789",
|
||||
)
|
||||
organization.origin.limit_cloudproviders.add(
|
||||
test_service_offering.provider, second_provider
|
||||
service_offering.provider, second_provider
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
assert response.status_code == 200
|
||||
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 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
|
||||
|
|
|
|||
103
uv.lock
generated
103
uv.lock
generated
|
|
@ -65,7 +65,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "black"
|
||||
version = "25.11.0"
|
||||
version = "25.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
|
@ -75,41 +75,42 @@ dependencies = [
|
|||
{ name = "platformdirs" },
|
||||
{ name = "pytokens" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.42.0"
|
||||
version = "1.42.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/9b/eef5346ce3148bf4856318fe629e0fd7f6dd73ffd55ea08e316c967f8af0/boto3-1.42.0.tar.gz", hash = "sha256:9c67729a6112b7dced521ea70b0369fba138e89852b029a7876041cd1460c084", size = 112854, upload-time = "2025-12-01T02:31:09.157Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/31/246916eec4fc5ff7bebf7e75caf47ee4d72b37d4120b6943e3460956e618/boto3-1.42.4.tar.gz", hash = "sha256:65f0d98a3786ec729ba9b5f70448895b2d1d1f27949aa7af5cb4f39da341bbc4", size = 112826, upload-time = "2025-12-05T20:27:14.931Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/2c/6c6ee5667426aee6629106b9e51668449fb34ec077655da82bf4b15d8890/boto3-1.42.0-py3-none-any.whl", hash = "sha256:af32b7f61dd6293cad728ec205bcb3611ab1bf7b7dbccfd0f2bd7b9c9af96039", size = 140617, upload-time = "2025-12-01T02:31:07.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/25/9ae819385aad79f524859f7179cecf8ac019b63ac8f150c51b250967f6db/boto3-1.42.4-py3-none-any.whl", hash = "sha256:0f4089e230d55f981d67376e48cefd41c3d58c7f694480f13288e6ff7b1fefbc", size = 140621, upload-time = "2025-12-05T20:27:12.803Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.41.6"
|
||||
version = "1.42.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/04/8e8ca38631eeb499a1099dcc2a081faaea399f9d46080720540ff54ec609/botocore-1.41.6.tar.gz", hash = "sha256:08fe47e9b306f4436f5eaf6a02cb6d55c7745d13d2d093ce5d917d3ef3d3df75", size = 14770281, upload-time = "2025-12-01T02:30:54.286Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/b7/dec048c124619b2702b5236c5fc9d8e5b0a87013529e9245dc49aaaf31ff/botocore-1.42.4.tar.gz", hash = "sha256:d4816023492b987a804f693c2d76fb751fdc8755d49933106d69e2489c4c0f98", size = 14848605, upload-time = "2025-12-05T20:27:02.919Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/d4/587a71c599997b0f7aa842ea71604348f5a7d239cfff338292904f236983/botocore-1.41.6-py3-none-any.whl", hash = "sha256:963cc946e885acb941c96e7d343cb6507b479812ca22566ceb3e9410d0588de0", size = 14442076, upload-time = "2025-12-01T02:30:50.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/a2/7b50f12a9c5a33cd85a5f23fdf78a0cbc445c0245c16051bb627f328be06/botocore-1.42.4-py3-none-any.whl", hash = "sha256:c3b091fd33809f187824b6434e518b889514ded5164cb379358367c18e8b0d7d", size = 14519938, upload-time = "2025-12-05T20:26:58.881Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -226,37 +227,37 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.12.0"
|
||||
version = "7.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -720,11 +721,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.5.0"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -823,7 +824,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.1"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
|
|
@ -832,9 +833,9 @@ dependencies = [
|
|||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1154,15 +1155,15 @@ requires-dist = [
|
|||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "black", specifier = ">=25.11.0" },
|
||||
{ name = "black", specifier = ">=25.12.0" },
|
||||
{ name = "bumpver", specifier = ">=2025.1131" },
|
||||
{ name = "coverage", specifier = ">=7.12.0" },
|
||||
{ name = "coverage", specifier = ">=7.13.0" },
|
||||
{ name = "djlint", specifier = ">=1.36.4" },
|
||||
{ name = "flake8", specifier = ">=7.3.0" },
|
||||
{ name = "flake8-bugbear", specifier = ">=25.11.29" },
|
||||
{ name = "flake8-pyproject", specifier = ">=1.2.4" },
|
||||
{ name = "isort", specifier = ">=7.0.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.1" },
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
||||
{ name = "pytest-django", specifier = ">=4.11.1" },
|
||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue