Compare commits

..

44 commits

Author SHA1 Message Date
1ac799e29c redesign service detail page
All checks were successful
Tests / test (push) Successful in 28s
with the help of Claude Code
2025-12-12 08:51:43 +01:00
605d9941fb Move events to end of page and take up full width 2025-12-12 08:48:02 +01:00
a698d4b0ec Show k8s event on instance page 2025-12-12 08:48:02 +01:00
ee2efe1d47 Prettier rendering for JSON data on instance page 2025-12-12 08:48:02 +01:00
5e92168d34 fixup! Show times as local time 2025-12-12 08:48:02 +01:00
461565cdd4 Toggle credentials in instance detail view 2025-12-12 08:48:02 +01:00
69a0c5bd5d Show times as local time 2025-12-12 08:48:02 +01:00
ac845b5073
properly set requests and limits for the deployment
All checks were successful
Build and Deploy Staging / build (push) Successful in 39s
Build and Deploy Staging / deploy (push) Successful in 7s
2025-12-11 17:11:48 +01:00
cee4b2a8bc Merge pull request 'Add display name field' (#331) from 290-display-name into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 41s
Tests / test (push) Successful in 44s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #331
Reviewed-by: Tobias Brunner <tobias.brunner@vshn.ch>
2025-12-11 15:32:59 +00:00
1baed53927
ssh tunnel on staging to talos cluster
All checks were successful
Build and Deploy Staging / build (push) Successful in 40s
Build and Deploy Staging / deploy (push) Successful in 6s
2025-12-11 15:36:54 +01:00
f3986a875a Rename Resource Name to Instance ID
All checks were successful
Tests / test (push) Successful in 35s
2025-12-11 13:14:08 +01:00
820de7e470 Sanitize suggested FQDN 2025-12-11 12:58:59 +01:00
392653aace Nudge test coverage up to 60%
All checks were successful
Tests / test (push) Successful in 29s
2025-12-10 13:01:42 +01:00
457bbaadc2 Better naming of test fixtures 2025-12-10 13:01:22 +01:00
4dab8e4f92 Add tests for user model 2025-12-10 12:37:00 +01:00
337774cc7a Add test showing that annotations are pushed 2025-12-10 12:36:24 +01:00
f9ba2d6c2c Code style 2025-12-10 12:34:54 +01:00
4c437d6f26 Save display name 2025-12-10 12:30:36 +01:00
68e430bb5c Fix update view 2025-12-10 11:57:27 +01:00
2322c37b32 Fix FQDN generation 2025-12-10 11:57:27 +01:00
3528c3b4f5 Fix custom form config 2025-12-10 11:57:27 +01:00
33ebf678be Fix default form config 2025-12-10 11:57:27 +01:00
dbf9756ccc Use instance name where appropriate 2025-12-10 11:57:27 +01:00
03f6b5a3c0 Make display name editable 2025-12-10 11:57:27 +01:00
97b53ec072 Use display name where appropriate 2025-12-10 11:57:27 +01:00
582c4ed564 Add model field, migrations, and backend methods 2025-12-10 11:57:27 +01:00
9cff1e85ac Add instance name prefix setting 2025-12-10 11:57:27 +01:00
cc84926693 Add missing test fixture 2025-12-10 11:57:27 +01:00
7326438470 Merge pull request 'Use correct button labels in instance forms' (#332) from 242-save-button into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 41s
Tests / test (push) Successful in 30s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #332
Reviewed-by: Tobias Brunner <tobias.brunner@vshn.ch>
2025-12-10 10:56:32 +00:00
df9ad3171e Use correct button labels in instance forms
All checks were successful
Tests / test (push) Successful in 29s
closes #242
2025-12-10 09:03:20 +01:00
97633c4c36
change port forwarding for converged setup
All checks were successful
Build and Deploy Staging / build (push) Successful in 41s
Build and Deploy Staging / deploy (push) Successful in 6s
2025-12-09 16:03:51 +01:00
73da69cad5 Merge pull request 'Update dependency coverage to >=7.13.0' (#330) from renovate/coverage-7.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 58s
Tests / test (push) Successful in 30s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #330
2025-12-09 12:28:35 +00:00
Renovate Bot
658d08e341 Update dependency coverage to >=7.13.0
All checks were successful
Tests / test (push) Successful in 28s
2025-12-09 03:01:27 +00:00
cecc2f88da Merge pull request 'Update dependency pytest to >=9.0.2' (#326) from renovate/pytest-9.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 40s
Tests / test (push) Successful in 27s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #326
2025-12-08 16:14:11 +00:00
d889497e08 Merge pull request 'Update dependency black to >=25.12.0' (#327) from renovate/black-25.x into main
Some checks are pending
Build and Deploy Staging / build (push) Waiting to run
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Reviewed-on: #327
2025-12-08 16:14:02 +00:00
09bd5272c7 Merge pull request 'Lock file maintenance' (#328) from renovate/lock-file-maintenance into main
Some checks are pending
Build and Deploy Staging / build (push) Waiting to run
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Reviewed-on: #328
2025-12-08 16:13:51 +00:00
6c43ccb2a5 Merge pull request 'Hide duplicate form error and improve error message' (#324) from 225-simple-form-errors into main
Some checks failed
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Reviewed-on: #324
2025-12-08 16:13:31 +00:00
Renovate Bot
4de8fd7769 Lock file maintenance
All checks were successful
Tests / test (push) Successful in 27s
2025-12-08 03:01:29 +00:00
Renovate Bot
f6588850ca Update dependency black to >=25.12.0
All checks were successful
Tests / test (push) Successful in 32s
2025-12-08 03:01:20 +00:00
Renovate Bot
bab696d156 Update dependency pytest to >=9.0.2
All checks were successful
Tests / test (push) Successful in 28s
2025-12-07 03:01:17 +00:00
a68ad6b8a5 Increase test coverage
All checks were successful
Tests / test (push) Successful in 30s
2025-12-05 16:39:11 +01:00
4385e6ce24 Improve view naming for easier dev navigation 2025-12-05 15:24:41 +01:00
acb6ac1538 Stop showing duplicate error messages 2025-12-05 15:21:34 +01:00
94f95a8664 Improve error message 2025-12-05 15:21:20 +01:00
37 changed files with 1307 additions and 302 deletions

View file

@ -77,3 +77,7 @@ SERVALA_ODOO_HELPDESK_TEAM_ID='5'
# OSB API authentication settings # OSB API authentication settings
SERVALA_OSB_USERNAME='' SERVALA_OSB_USERNAME=''
SERVALA_OSB_PASSWORD='' SERVALA_OSB_PASSWORD=''
# Prefix for auto-generated Kubernetes resource names for service instances.
# Format: {prefix}-{hash}. Defaults to 'si' (service instance).
SERVALA_INSTANCE_NAME_PREFIX='si'

View file

@ -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. 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`. 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 ### Staging
The code is automatically built and deployed on a push to the main branch. The code is automatically built and deployed on a push to the main branch.

View file

@ -21,6 +21,13 @@ spec:
- name: servala - name: servala
image: servala.app.codey.ch/servala/servala-portal:latest image: servala.app.codey.ch/servala/servala-portal:latest
imagePullPolicy: Always imagePullPolicy: Always
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
cpu: 2
memory: 4Gi
ports: ports:
- name: http - name: http
containerPort: 8080 containerPort: 8080

View file

@ -32,10 +32,3 @@ spec:
secretKeyRef: secretKeyRef:
name: portal-storage-creds name: portal-storage-creds
key: AWS_SECRET_ACCESS_KEY key: AWS_SECRET_ACCESS_KEY
resources:
limits:
cpu: 2
memory: 2Gi
requests:
cpu: 500m
memory: 512Mi

View file

@ -7,6 +7,13 @@ spec:
spec: spec:
containers: containers:
- name: servala - name: servala
resources:
requests:
cpu: 250m
memory: 1Gi
limits:
cpu: 1
memory: 2Gi
env: env:
- name: SERVALA_ENVIRONMENT - name: SERVALA_ENVIRONMENT
value: staging value: staging
@ -32,8 +39,15 @@ spec:
secretKeyRef: secretKeyRef:
name: portal-storage-creds name: portal-storage-creds
key: AWS_SECRET_ACCESS_KEY key: AWS_SECRET_ACCESS_KEY
- name: ssh-tunnel - name: ssh-tunnel-dev
image: servala.app.codey.ch/servala/servala-portal:latest image: servala.app.codey.ch/servala/servala-portal:latest
resources:
requests:
cpu: 50m
memory: 204Mi
limits:
cpu: 100m
memory: 256Mi
command: command:
- "/bin/bash" - "/bin/bash"
- "-c" - "-c"
@ -41,13 +55,40 @@ spec:
mkdir -p /app/.ssh && chmod 700 /app/.ssh mkdir -p /app/.ssh && chmod 700 /app/.ssh
echo "$SSH_PRIVATE_KEY" > /app/.ssh/id echo "$SSH_PRIVATE_KEY" > /app/.ssh/id
chmod 600 /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: env:
- name: SSH_HOST - name: SSH_HOST
valueFrom: value: "78.47.176.209"
secretKeyRef: - name: SSH_USER
name: servala-sshclient valueFrom:
key: ssh-host 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 - name: SSH_USER
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:

View file

@ -28,15 +28,15 @@ dependencies = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"black>=25.11.0", "black>=25.12.0",
"bumpver>=2025.1131", "bumpver>=2025.1131",
"coverage>=7.12.0", "coverage>=7.13.0",
"djlint>=1.36.4", "djlint>=1.36.4",
"flake8>=7.3.0", "flake8>=7.3.0",
"flake8-bugbear>=25.11.29", "flake8-bugbear>=25.11.29",
"flake8-pyproject>=1.2.4", "flake8-pyproject>=1.2.4",
"isort>=7.0.0", "isort>=7.0.0",
"pytest>=9.0.1", "pytest>=9.0.2",
"pytest-cov>=7.0.0", "pytest-cov>=7.0.0",
"pytest-django>=4.11.1", "pytest-django>=4.11.1",
"pytest-mock>=3.15.1", "pytest-mock>=3.15.1",

View file

@ -301,7 +301,7 @@ The Servala Team"""
"core_serviceinstance_change", service_instance.pk "core_serviceinstance_change", service_instance.pk
) )
description_parts.append( description_parts.append(
f"Instance: {service_instance.name} - {instance_url}" f"Instance: {service_instance.display_name} ({service_instance.name}) - {instance_url}"
) )
else: else:
description_parts.append(f"Instance: {instance_id}") description_parts.append(f"Instance: {instance_id}")

View file

@ -9,17 +9,17 @@ from servala.core.models import ControlPlaneCRD
from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon
# Fields that must be present in every form # Fields that must be present in every form
MANDATORY_FIELDS = ["name"] MANDATORY_FIELDS = ["display_name"]
# Default field configurations - fields that can be included with just a mapping # Default field configurations - fields that can be included with just a mapping
# to avoid administrators having to duplicate common information # to avoid administrators having to duplicate common information
DEFAULT_FIELD_CONFIGS = { DEFAULT_FIELD_CONFIGS = {
"name": { "display_name": {
"type": "text", "type": "text",
"label": "Instance Name", "label": "Instance Name",
"help_text": "Unique name for the new instance", "help_text": "",
"required": True, "required": True,
"max_length": 63, "max_length": 100,
}, },
"spec.parameters.service.fqdn": { "spec.parameters.service.fqdn": {
"type": "array", "type": "array",
@ -51,11 +51,6 @@ class FormGeneratorMixin:
crd = getattr(crd, "pk", crd) # can be int or object crd = getattr(crd, "pk", crd) # can be int or object
self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd) self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd)
if self.instance and hasattr(self.instance, "name") and self.instance.name:
if "name" in self.fields:
self.fields["name"].disabled = True
self.fields["name"].widget = forms.HiddenInput()
def has_mandatory_fields(self, field_list): def has_mandatory_fields(self, field_list):
for field_name in field_list: for field_name in field_list:
if field_name in self.fields and self.fields[field_name].required: if field_name in self.fields and self.fields[field_name].required:
@ -307,15 +302,6 @@ class CustomFormMixin(FormGeneratorMixin):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._apply_field_config() self._apply_field_config()
if (
self.instance
and hasattr(self.instance, "name")
and self.instance.name
and "name" in self.fields
):
self.fields["name"].widget = forms.HiddenInput()
self.fields["name"].disabled = True
self.fields.pop("context", None)
def _apply_field_config(self): def _apply_field_config(self):
for fieldset in self.form_config.get("fieldsets", []): for fieldset in self.form_config.get("fieldsets", []):
@ -472,7 +458,7 @@ def generate_custom_form_class(form_config, model):
""" """
Generate a custom (user-friendly) form class from form_config JSON. Generate a custom (user-friendly) form class from form_config JSON.
""" """
field_list = ["context", "name"] field_list = ["context", "display_name"]
for fieldset in form_config.get("fieldsets", []): for fieldset in form_config.get("fieldsets", []):
for field_config in fieldset.get("fields", []): for field_config in fieldset.get("fields", []):

View file

@ -23,9 +23,9 @@ def generate_django_model(schema, group, version, kind):
""" """
Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema.
""" """
# We always need these three fields to know our own name and our full namespace # We always need these fields to know our display name and our full namespace
model_fields = {"__module__": "crd_models"} model_fields = {"__module__": "crd_models"}
for field_name in ("name", "context"): for field_name in ("display_name", "context"):
model_fields[field_name] = duplicate_field(field_name, ServiceInstance) model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
# All other fields are generated from the schema, except for the # All other fields are generated from the schema, except for the

View file

@ -254,7 +254,7 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
if not schema or not (spec_schema := schema.get("properties", {}).get("spec")): if not schema or not (spec_schema := schema.get("properties", {}).get("spec")):
return return
valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"} valid_paths = self._extract_field_paths(spec_schema, "spec") | {"display_name"}
included_mappings = set() included_mappings = set()
errors = [] errors = []
for fieldset in form_config.get("fieldsets", []): for fieldset in form_config.get("fieldsets", []):

View file

@ -195,6 +195,7 @@ class Command(BaseCommand):
compute_plan_assignment=instance.compute_plan_assignment, compute_plan_assignment=instance.compute_plan_assignment,
control_plane=instance.context.control_plane, control_plane=instance.context.control_plane,
instance_name=instance.name, instance_name=instance.name,
display_name=instance.display_name,
organization=instance.organization, organization=instance.organization,
service=instance.context.service_offering.service, service=instance.context.service_offering.service,
) )

View file

@ -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),
]

View file

@ -1,4 +1,5 @@
import copy import copy
import hashlib
import html import html
import json import json
import re import re
@ -615,8 +616,16 @@ class ServiceInstance(ServalaModelMixin, models.Model):
on the fly. on the fly.
""" """
# The Kubernetes resource name (metadata.name). This field is immutable after
# creation and is auto-generated for new instances. Do not modify directly!
name = models.CharField( name = models.CharField(
max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator] max_length=63,
verbose_name=_("Instance ID"),
validators=[kubernetes_name_validator],
)
display_name = models.CharField(
max_length=100,
verbose_name=_("Name"),
) )
organization = models.ForeignKey( organization = models.ForeignKey(
to="core.Organization", to="core.Organization",
@ -686,6 +695,24 @@ class ServiceInstance(ServalaModelMixin, models.Model):
spec_data = prune_empty_data(spec_data) spec_data = prune_empty_data(spec_data)
return spec_data return spec_data
@staticmethod
def generate_resource_name(organization, display_name, service, attempt=0):
"""
Generate a unique Kubernetes-compatible resource name.
Format: {prefix}-{sha256[:8]}
The hash input is: org_slug:display_name:service_slug[:attempt if > 0]
On collision, we retry with an incremented attempt number included in hash.
"""
hash_input = (
f"{organization.slug}:{display_name.lower().strip()}:{service.slug}"
)
if attempt > 0:
hash_input += f":{attempt}"
hash_value = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()[:8]
return f"{settings.SERVALA_INSTANCE_NAME_PREFIX}-{hash_value}"
@staticmethod @staticmethod
def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment): def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment):
""" """
@ -719,16 +746,20 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment, compute_plan_assignment,
control_plane, control_plane,
instance_name=None, instance_name=None,
display_name=None,
organization=None, organization=None,
service=None, service=None,
): ):
""" """
Build Kubernetes annotations for billing integration. Build Kubernetes annotations for billing integration and display name.
""" """
from servala.core.models.organization import InvoiceGroupingChoice from servala.core.models.organization import InvoiceGroupingChoice
annotations = {} annotations = {}
if display_name:
annotations["servala.com/displayName"] = display_name
if compute_plan_assignment: if compute_plan_assignment:
annotations["servala.com/erp_product_id_resource"] = str( annotations["servala.com/erp_product_id_resource"] = str(
compute_plan_assignment.odoo_product_id compute_plan_assignment.odoo_product_id
@ -830,18 +861,35 @@ class ServiceInstance(ServalaModelMixin, models.Model):
@transaction.atomic @transaction.atomic
def create_instance( def create_instance(
cls, cls,
name, display_name,
organization, organization,
context, context,
created_by, created_by,
spec_data, spec_data,
compute_plan_assignment=None, compute_plan_assignment=None,
): ):
service = context.service_offering.service
name = None
for attempt in range(10):
name = cls.generate_resource_name(
organization, display_name, service, attempt
)
if not cls.objects.filter(
name=name, organization=organization, context=context
).exists():
break
else:
message = _(
"Could not generate a unique resource name. Please try a different display name."
)
raise ValidationError(organization.add_support_message(message))
# Ensure the namespace exists # Ensure the namespace exists
context.control_plane.get_or_create_namespace(organization) context.control_plane.get_or_create_namespace(organization)
try: try:
instance = cls.objects.create( instance = cls.objects.create(
name=name, name=name,
display_name=display_name,
organization=organization, organization=organization,
created_by=created_by, created_by=created_by,
context=context, context=context,
@ -883,8 +931,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment=compute_plan_assignment, compute_plan_assignment=compute_plan_assignment,
control_plane=context.control_plane, control_plane=context.control_plane,
instance_name=name, instance_name=name,
display_name=display_name,
organization=organization, organization=organization,
service=context.service_offering.service, service=service,
) )
if annotations: if annotations:
create_data["metadata"]["annotations"] = annotations create_data["metadata"]["annotations"] = annotations
@ -941,6 +990,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment=plan_to_use, compute_plan_assignment=plan_to_use,
control_plane=self.context.control_plane, control_plane=self.context.control_plane,
instance_name=self.name, instance_name=self.name,
display_name=self.display_name,
organization=self.organization, organization=self.organization,
service=self.context.service_offering.service, service=self.context.service_offering.service,
) )
@ -1065,7 +1115,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
if not self.context.django_model: if not self.context.django_model:
return return
return self.context.django_model( return self.context.django_model(
name=self.name, display_name=self.display_name,
context=self.context, context=self.context,
spec=self.spec, spec=self.spec,
# We pass -1 as ID in order to make it clear that a) this object exists (remotely), # We pass -1 as ID in order to make it clear that a) this object exists (remotely),

View file

@ -123,7 +123,7 @@ class ServiceInstanceFilterForm(forms.Form):
class ServiceInstanceDeleteForm(forms.ModelForm): class ServiceInstanceDeleteForm(forms.ModelForm):
name = forms.CharField( name = forms.CharField(
label=_("Instance Name"), label=_("Instance ID"),
max_length=63, max_length=63,
widget=forms.TextInput(attrs={"class": "form-control"}), widget=forms.TextInput(attrs={"class": "form-control"}),
) )
@ -132,7 +132,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
kwargs["initial"] = {"name": ""} kwargs["initial"] = {"name": ""}
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["name"].help_text = _( self.fields["name"].help_text = _(
"To confirm deletion, please type the instance name: <strong>{instance_name}</strong>" "To confirm deletion, please type the instance ID: <strong>{instance_name}</strong>"
).format(instance_name=self.instance.name) ).format(instance_name=self.instance.name)
def clean_name(self): def clean_name(self):
@ -140,7 +140,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
if entered_name != self.instance.name: if entered_name != self.instance.name:
raise forms.ValidationError( raise forms.ValidationError(
_( _(
"The entered name does not match the instance name. Deletion not confirmed." "The entered name does not match the instance ID. Deletion not confirmed."
) )
) )
return entered_name return entered_name

View file

@ -11,7 +11,7 @@
{{ form.non_field_errors.0 }} {{ form.non_field_errors.0 }}
{% endif %} {% endif %}
{% else %} {% else %}
{% translate "We could not save your changes." %} {% translate "Please review and correct the errors highlighted in the form below." %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -96,7 +96,7 @@
<tr> <tr>
<td> <td>
<a href="{{ instance.urls.base }}" <a href="{{ instance.urls.base }}"
class="fw-semibold text-decoration-none">{{ instance.name }}</a> class="fw-semibold text-decoration-none">{{ instance.display_name }}</a>
</td> </td>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">

View file

@ -26,7 +26,8 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %} {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div> </div>
{% else %} {% 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 %} {% endif %}
</div> </div>
</div> </div>

View file

@ -7,7 +7,7 @@
{% csrf_token %} {% csrf_token %}
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="deleteInstanceModalLabel"> <h5 class="modal-title" id="deleteInstanceModalLabel">
{% blocktranslate with instance_name=instance.name %}Confirm Deletion of {{ instance_name }}{% endblocktranslate %} {% blocktranslate with instance_name=instance.display_name %}Confirm Deletion of {{ instance_name }}{% endblocktranslate %}
</h5> </h5>
<button type="button" <button type="button"
class="btn-close" class="btn-close"

View file

@ -2,7 +2,7 @@
{% load i18n static pprint_filters %} {% load i18n static pprint_filters %}
{% block html_title %} {% block html_title %}
{% block page_title %} {% block page_title %}
{{ instance.name }} {{ instance.display_name }}
{% endblock page_title %} {% endblock page_title %}
{% endblock html_title %} {% endblock html_title %}
{% block page_title_extra %} {% block page_title_extra %}

View file

@ -5,7 +5,7 @@
{% block html_title %} {% block html_title %}
{% block page_title %} {% block page_title %}
{% block title %} {% block title %}
{% blocktranslate with instance_name=instance.name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %} {% blocktranslate with instance_name=instance.display_name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %}
{% endblock %} {% endblock %}
{% endblock page_title %} {% endblock page_title %}
{% endblock html_title %} {% endblock html_title %}
@ -22,7 +22,8 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %} {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div> </div>
{% else %} {% 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 %} {% endif %}
</div> </div>
</div> </div>

View file

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

View file

@ -1,7 +1,9 @@
{% load i18n %} {% load i18n %}
{% load get_field %} {% load get_field %}
{% load static %} {% load static %}
{% if not hide_form_errors %}
{% include "frontend/forms/errors.html" %} {% include "frontend/forms/errors.html" %}
{% endif %}
{% if form and expert_form and not hide_expert_mode %} {% if form and expert_form and not hide_expert_mode %}
<div class="mb-3 text-end"> <div class="mb-3 text-end">
<a href="#" <a href="#"

View file

@ -13,4 +13,4 @@ def get_version_or_env():
env = os.environ.get("SERVALA_ENVIRONMENT", "development") env = os.environ.get("SERVALA_ENVIRONMENT", "development")
if env == "production": if env == "production":
return __version__ return __version__
return env return env # pragma: no cover

View file

@ -47,8 +47,8 @@ urlpatterns = [
), ),
path( path(
"services/<slug:slug>/offering/<int:pk>/", "services/<slug:slug>/offering/<int:pk>/",
views.ServiceOfferingDetailView.as_view(), views.ServiceInstanceCreateView.as_view(),
name="organization.offering", name="organization.instance.create",
), ),
path( path(
"", "",

View file

@ -16,12 +16,12 @@ from .organization import (
) )
from .service import ( from .service import (
ServiceDetailView, ServiceDetailView,
ServiceInstanceCreateView,
ServiceInstanceDeleteView, ServiceInstanceDeleteView,
ServiceInstanceDetailView, ServiceInstanceDetailView,
ServiceInstanceListView, ServiceInstanceListView,
ServiceInstanceUpdateView, ServiceInstanceUpdateView,
ServiceListView, ServiceListView,
ServiceOfferingDetailView,
) )
from .support import SupportView from .support import SupportView
@ -35,12 +35,12 @@ __all__ = [
"OrganizationSelectionView", "OrganizationSelectionView",
"OrganizationUpdateView", "OrganizationUpdateView",
"ServiceDetailView", "ServiceDetailView",
"ServiceInstanceCreateView",
"ServiceInstanceDeleteView", "ServiceInstanceDeleteView",
"ServiceInstanceDetailView", "ServiceInstanceDetailView",
"ServiceInstanceListView", "ServiceInstanceListView",
"ServiceInstanceUpdateView", "ServiceInstanceUpdateView",
"ServiceListView", "ServiceListView",
"ServiceOfferingDetailView",
"ProfileView", "ProfileView",
"SupportView", "SupportView",
"custom_404", "custom_404",

View file

@ -83,7 +83,7 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
if self.visible_offerings.count() == 1: if self.visible_offerings.count() == 1:
offering = self.visible_offerings.first() offering = self.visible_offerings.first()
return redirect( return redirect(
"frontend:organization.offering", "frontend:organization.instance.create",
organization=self.request.organization.slug, organization=self.request.organization.slug,
slug=self.object.slug, slug=self.object.slug,
pk=offering.pk, pk=offering.pk,
@ -97,8 +97,8 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
return context return context
class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView): class ServiceInstanceCreateView(OrganizationViewMixin, HtmxViewMixin, DetailView):
template_name = "frontend/organizations/service_offering_detail.html" template_name = "frontend/organizations/service_instance_create.html"
context_object_name = "offering" context_object_name = "offering"
model = ServiceOffering model = ServiceOffering
permission_type = "view" permission_type = "view"
@ -276,7 +276,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
try: try:
service_instance = ServiceInstance.create_instance( service_instance = ServiceInstance.create_instance(
organization=self.request.organization, organization=self.request.organization,
name=form.cleaned_data["name"], display_name=form.cleaned_data["display_name"],
context=self.context_object, context=self.context_object,
created_by=request.user, created_by=request.user,
spec_data=form.get_nested_data().get("spec"), spec_data=form.get_nested_data().get("spec"),
@ -762,6 +762,9 @@ class ServiceInstanceUpdateView(
current_spec = dict(self.object.spec) if self.object.spec else {} current_spec = dict(self.object.spec) if self.object.spec else {}
spec_data = self._deep_merge(current_spec, spec_data) spec_data = self._deep_merge(current_spec, spec_data)
if display_name := form_data.get("display_name"):
self.object.display_name = display_name
compute_plan_assignment = None compute_plan_assignment = None
if self.plan_form.is_valid(): if self.plan_form.is_valid():
compute_plan_assignment = self.plan_form.cleaned_data.get( compute_plan_assignment = self.plan_form.cleaned_data.get(
@ -776,7 +779,7 @@ class ServiceInstanceUpdateView(
messages.success( messages.success(
self.request, self.request,
_("Service instance '{name}' updated successfully.").format( _("Service instance '{name}' updated successfully.").format(
name=self.object.name name=self.object.display_name
), ),
) )
return redirect(self.object.urls.base) return redirect(self.object.urls.base)
@ -837,7 +840,7 @@ class ServiceInstanceDeleteView(
messages.success( messages.success(
self.request, self.request,
_("Service instance '{name}' has been scheduled for deletion.").format( _("Service instance '{name}' has been scheduled for deletion.").format(
name=self.object.name name=self.object.display_name
), ),
) )
response = HttpResponse() response = HttpResponse()
@ -848,7 +851,7 @@ class ServiceInstanceDeleteView(
self.request, self.request,
self.organization.add_support_message( self.organization.add_support_message(
_( _(
f"An error occurred while trying to delete instance '{self.object.name}': {str(e)}." f"An error occurred while trying to delete instance '{self.object.display_name}': {str(e)}."
) )
), ),
) )

View file

@ -270,6 +270,9 @@ SESSION_COOKIE_SECURE = not DEBUG
DEFAULT_LABEL_KEY = "appcat.vshn.io/provider-config" DEFAULT_LABEL_KEY = "appcat.vshn.io/provider-config"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Prefix for auto-generated Kubernetes resource names for service instances
SERVALA_INSTANCE_NAME_PREFIX = os.environ.get("SERVALA_INSTANCE_NAME_PREFIX", "si")
# TODO # TODO
TIME_ZONE = "UTC" TIME_ZONE = "UTC"

View file

@ -1,6 +1,6 @@
const initializeFqdnGeneration = (prefix) => { const initializeFqdnGeneration = (prefix) => {
const nameField = document.querySelector(`input#id_${prefix}-name`); const nameField = document.querySelector(`input#id_${prefix}-display_name`);
if (!nameField) return if (!nameField) return
// Try to find array input first (DynamicArrayWidget), then fallback to regular text input // Try to find array input first (DynamicArrayWidget), then fallback to regular text input
@ -23,9 +23,19 @@ const initializeFqdnGeneration = (prefix) => {
if (!fqdnField) return if (!fqdnField) return
if (nameField && fqdnField) { if (nameField && fqdnField) {
const sanitizeForFqdn = (name) => {
return name
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, ''); // Trim hyphens from start/end
}
const generateFqdn = (instanceName) => { const generateFqdn = (instanceName) => {
if (!instanceName) return ''; if (!instanceName) return '';
return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`; const sanitized = sanitizeForFqdn(instanceName);
if (!sanitized) return ''
return `${sanitized}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
} }
nameField.addEventListener('input', function() { nameField.addEventListener('input', function() {

View file

@ -18,6 +18,7 @@ from servala.core.models.service import (
Service, Service,
ServiceCategory, ServiceCategory,
ServiceDefinition, ServiceDefinition,
ServiceInstance,
ServiceOffering, ServiceOffering,
) )
@ -44,15 +45,38 @@ def other_organization(origin):
@pytest.fixture @pytest.fixture
def org_owner(organization): 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( OrganizationMembership.objects.create(
organization=organization, user=user, role="owner" organization=organization, user=owner, role="owner"
) )
return user return owner
@pytest.fixture @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( return ServiceCategory.objects.create(
name="Databases", name="Databases",
description="Database services", description="Database services",
@ -60,18 +84,18 @@ def test_service_category():
@pytest.fixture @pytest.fixture
def test_service(test_service_category): def service(service_category):
return Service.objects.create( return Service.objects.create(
name="Redis", name="Redis",
slug="redis", slug="redis",
category=test_service_category, category=service_category,
description="Redis database service", description="Redis database service",
osb_service_id="test-service-123", osb_service_id="test-service-123",
) )
@pytest.fixture @pytest.fixture
def test_cloud_provider(): def cloud_provider():
return CloudProvider.objects.create( return CloudProvider.objects.create(
name="Exoscale", name="Exoscale",
description="Exoscale cloud provider", description="Exoscale cloud provider",
@ -79,10 +103,10 @@ def test_cloud_provider():
@pytest.fixture @pytest.fixture
def test_service_offering(test_service, test_cloud_provider): def service_offering(service, cloud_provider):
return ServiceOffering.objects.create( return ServiceOffering.objects.create(
service=test_service, service=service,
provider=test_cloud_provider, provider=cloud_provider,
description="Redis on Exoscale", description="Redis on Exoscale",
osb_plan_id="test-plan-123", osb_plan_id="test-plan-123",
) )
@ -125,11 +149,11 @@ def mock_odoo_failure(mocker):
@pytest.fixture @pytest.fixture
def test_control_plane(test_cloud_provider): def control_plane(cloud_provider):
return ControlPlane.objects.create( return ControlPlane.objects.create(
name="Geneva (CH-GVA-2)", name="Geneva (CH-GVA-2)",
description="Geneva control plane", description="Geneva control plane",
cloud_provider=test_cloud_provider, cloud_provider=cloud_provider,
api_credentials={ api_credentials={
"server": "https://k8s.example.com", "server": "https://k8s.example.com",
"token": "test-token", "token": "test-token",
@ -139,10 +163,10 @@ def test_control_plane(test_cloud_provider):
@pytest.fixture @pytest.fixture
def test_service_definition(test_service): def service_definition(service):
return ServiceDefinition.objects.create( return ServiceDefinition.objects.create(
name="Redis Standard", name="Redis Standard",
service=test_service, service=service,
api_definition={ api_definition={
"group": "vshn.appcat.vshn.io", "group": "vshn.appcat.vshn.io",
"version": "v1", "version": "v1",
@ -152,13 +176,11 @@ def test_service_definition(test_service):
@pytest.fixture @pytest.fixture
def test_control_plane_crd( def control_plane_crd(service_offering, control_plane, service_definition):
test_service_offering, test_control_plane, test_service_definition
):
return ControlPlaneCRD.objects.create( return ControlPlaneCRD.objects.create(
service_offering=test_service_offering, service_offering=service_offering,
control_plane=test_control_plane, control_plane=control_plane,
service_definition=test_service_definition, service_definition=service_definition,
) )
@ -176,10 +198,10 @@ def compute_plan():
@pytest.fixture @pytest.fixture
def compute_plan_assignment(compute_plan, test_control_plane_crd): def compute_plan_assignment(compute_plan, control_plane_crd):
return ComputePlanAssignment.objects.create( return ComputePlanAssignment.objects.create(
compute_plan=compute_plan, compute_plan=compute_plan,
control_plane_crd=test_control_plane_crd, control_plane_crd=control_plane_crd,
sla="besteffort", sla="besteffort",
odoo_product_id="test-product-id", odoo_product_id="test-product-id",
odoo_unit_id="test-unit-id", odoo_unit_id="test-unit-id",
@ -187,3 +209,30 @@ def compute_plan_assignment(compute_plan, test_control_plane_crd):
unit="hour", unit="hour",
is_active=True, is_active=True,
) )
@pytest.fixture
def service_instance(organization, control_plane_crd):
return ServiceInstance.objects.create(
name="test-abc12345",
display_name="My Test Instance",
organization=organization,
context=control_plane_crd,
)
@pytest.fixture
def control_plane_with_storage(cloud_provider):
return ControlPlane.objects.create(
name="Storage Zone",
description="Zone with storage billing",
cloud_provider=cloud_provider,
api_credentials={
"server": "https://k8s.example.com",
"token": "test-token",
"certificate-authority-data": "test-ca-data",
},
storage_plan_odoo_product_id="storage-product-123",
storage_plan_odoo_unit_id="storage-unit-456",
storage_plan_price_per_gib="0.10",
)

View file

@ -55,14 +55,14 @@ def valid_osb_payload():
def test_successful_onboarding_new_organization( def test_successful_onboarding_new_organization(
mock_odoo_success, mock_odoo_success,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
valid_osb_payload, valid_osb_payload,
exoscale_origin, exoscale_origin,
instance_id, instance_id,
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
response = osb_client.put( response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}", f"/api/osb/v2/service_instances/{instance_id}",
@ -107,15 +107,15 @@ def test_successful_onboarding_new_organization(
@pytest.mark.django_db @pytest.mark.django_db
def test_new_organization_inherits_origin( def test_new_organization_inherits_origin(
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
valid_osb_payload, valid_osb_payload,
exoscale_origin, exoscale_origin,
instance_id, instance_id,
billing_entity, billing_entity,
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
exoscale_origin.billing_entity = billing_entity exoscale_origin.billing_entity = billing_entity
exoscale_origin.save() exoscale_origin.save()
@ -137,8 +137,8 @@ def test_new_organization_inherits_origin(
@pytest.mark.django_db @pytest.mark.django_db
def test_duplicate_organization_returns_existing( def test_duplicate_organization_returns_existing(
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
valid_osb_payload, valid_osb_payload,
exoscale_origin, exoscale_origin,
instance_id, instance_id,
@ -148,10 +148,10 @@ def test_duplicate_organization_returns_existing(
osb_guid="test-org-guid-123", osb_guid="test-org-guid-123",
origin=exoscale_origin, origin=exoscale_origin,
) )
org.limit_osb_services.add(test_service) org.limit_osb_services.add(service)
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
response = osb_client.put( response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}", f"/api/osb/v2/service_instances/{instance_id}",
@ -169,13 +169,13 @@ def test_duplicate_organization_returns_existing(
@pytest.mark.django_db @pytest.mark.django_db
def test_unauthenticated_osb_api_request_fails( def test_unauthenticated_osb_api_request_fails(
client, client,
test_service, service,
test_service_offering, service_offering,
valid_osb_payload, valid_osb_payload,
instance_id, instance_id,
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
response = client.put( response = client.put(
f"/api/osb/v2/service_instances/{instance_id}", f"/api/osb/v2/service_instances/{instance_id}",
@ -205,15 +205,15 @@ def test_unauthenticated_osb_api_request_fails(
) )
def test_missing_required_fields_error( def test_missing_required_fields_error(
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
valid_osb_payload, valid_osb_payload,
field_to_remove, field_to_remove,
expected_error, expected_error,
instance_id, instance_id,
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
if isinstance(field_to_remove, tuple): if isinstance(field_to_remove, tuple):
if field_to_remove[0] == "context": if field_to_remove[0] == "context":
@ -251,10 +251,8 @@ def test_invalid_service_id_error(osb_client, valid_osb_payload, instance_id):
@pytest.mark.django_db @pytest.mark.django_db
def test_invalid_plan_id_error( def test_invalid_plan_id_error(osb_client, service, valid_osb_payload, instance_id):
osb_client, test_service, valid_osb_payload, instance_id valid_osb_payload["service_id"] = service.osb_service_id
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = 99999 valid_osb_payload["plan_id"] = 99999
response = osb_client.put( response = osb_client.put(
@ -266,17 +264,17 @@ def test_invalid_plan_id_error(
assert response.status_code == 400 assert response.status_code == 400
response_data = json.loads(response.content) response_data = json.loads(response.content)
assert ( assert (
f"Unknown plan_id: 99999 for service_id: {test_service.osb_service_id}" f"Unknown plan_id: 99999 for service_id: {service.osb_service_id}"
in response_data["error"] in response_data["error"]
) )
@pytest.mark.django_db @pytest.mark.django_db
def test_empty_users_array_error( def test_empty_users_array_error(
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id osb_client, service, service_offering, valid_osb_payload, instance_id
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"] = [] valid_osb_payload["parameters"]["users"] = []
response = osb_client.put( response = osb_client.put(
@ -292,10 +290,10 @@ def test_empty_users_array_error(
@pytest.mark.django_db @pytest.mark.django_db
def test_multiple_users_error( def test_multiple_users_error(
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id osb_client, service, service_offering, valid_osb_payload, instance_id
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"] = [ valid_osb_payload["parameters"]["users"] = [
{"email": "user1@example.com", "full_name": "User One"}, {"email": "user1@example.com", "full_name": "User One"},
{"email": "user2@example.com", "full_name": "User Two"}, {"email": "user2@example.com", "full_name": "User Two"},
@ -314,10 +312,10 @@ def test_multiple_users_error(
@pytest.mark.django_db @pytest.mark.django_db
def test_empty_email_address_error( def test_empty_email_address_error(
osb_client, test_service, test_service_offering, valid_osb_payload, instance_id osb_client, service, service_offering, valid_osb_payload, instance_id
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"] = [ valid_osb_payload["parameters"]["users"] = [
{"email": "", "full_name": "User With No Email"}, {"email": "", "full_name": "User With No Email"},
] ]
@ -350,14 +348,14 @@ def test_invalid_json_error(osb_client, instance_id):
def test_user_creation_with_name_parsing( def test_user_creation_with_name_parsing(
mock_odoo_success, mock_odoo_success,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
valid_osb_payload, valid_osb_payload,
exoscale_origin, exoscale_origin,
instance_id, instance_id,
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"][0]["full_name"] = "John Doe Smith" valid_osb_payload["parameters"]["users"][0]["full_name"] = "John Doe Smith"
response = osb_client.put( response = osb_client.put(
@ -376,14 +374,14 @@ def test_user_creation_with_name_parsing(
def test_email_normalization( def test_email_normalization(
mock_odoo_success, mock_odoo_success,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
valid_osb_payload, valid_osb_payload,
exoscale_origin, exoscale_origin,
instance_id, instance_id,
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
valid_osb_payload["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM " valid_osb_payload["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM "
response = osb_client.put( response = osb_client.put(
@ -401,14 +399,14 @@ def test_email_normalization(
def test_odoo_integration_failure_handling( def test_odoo_integration_failure_handling(
mock_odoo_failure, mock_odoo_failure,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
valid_osb_payload, valid_osb_payload,
exoscale_origin, exoscale_origin,
instance_id, instance_id,
): ):
valid_osb_payload["service_id"] = test_service.osb_service_id valid_osb_payload["service_id"] = service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id valid_osb_payload["plan_id"] = service_offering.osb_plan_id
response = osb_client.put( response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}", f"/api/osb/v2/service_instances/{instance_id}",
@ -425,14 +423,14 @@ def test_odoo_integration_failure_handling(
def test_organization_creation_with_context_only( def test_organization_creation_with_context_only(
mock_odoo_success, mock_odoo_success,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
exoscale_origin, exoscale_origin,
instance_id, instance_id,
): ):
payload = { payload = {
"service_id": test_service.osb_service_id, "service_id": service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id, "plan_id": service_offering.osb_plan_id,
"context": { "context": {
"organization_guid": "fallback-org-guid", "organization_guid": "fallback-org-guid",
"organization_name": "Fallback Organization", "organization_name": "Fallback Organization",
@ -462,13 +460,13 @@ def test_organization_creation_with_context_only(
def test_delete_offboarding_success( def test_delete_offboarding_success(
mock_odoo_success, mock_odoo_success,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
instance_id, instance_id,
): ):
response = osb_client.delete( response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}" f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}" f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}"
) )
assert response.status_code == 200 assert response.status_code == 200
@ -476,9 +474,9 @@ def test_delete_offboarding_success(
@pytest.mark.django_db @pytest.mark.django_db
def test_delete_missing_service_id(osb_client, test_service_offering, instance_id): def test_delete_missing_service_id(osb_client, service_offering, instance_id):
response = osb_client.delete( response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?plan_id={test_service_offering.osb_plan_id}" f"/api/osb/v2/service_instances/{instance_id}?plan_id={service_offering.osb_plan_id}"
) )
assert response.status_code == 400 assert response.status_code == 400
@ -487,9 +485,9 @@ def test_delete_missing_service_id(osb_client, test_service_offering, instance_i
@pytest.mark.django_db @pytest.mark.django_db
def test_delete_missing_plan_id(osb_client, test_service, instance_id): def test_delete_missing_plan_id(osb_client, service, instance_id):
response = osb_client.delete( response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?service_id={test_service.osb_service_id}" f"/api/osb/v2/service_instances/{instance_id}?service_id={service.osb_service_id}"
) )
assert response.status_code == 400 assert response.status_code == 400
@ -509,16 +507,16 @@ def test_delete_invalid_service_id(osb_client, instance_id):
@pytest.mark.django_db @pytest.mark.django_db
def test_delete_invalid_plan_id(osb_client, test_service, instance_id): def test_delete_invalid_plan_id(osb_client, service, instance_id):
response = osb_client.delete( response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}" f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id=invalid" f"?service_id={service.osb_service_id}&plan_id=invalid"
) )
assert response.status_code == 400 assert response.status_code == 400
response_data = json.loads(response.content) response_data = json.loads(response.content)
assert ( assert (
f"Unknown plan_id: invalid for service_id: {test_service.osb_service_id}" f"Unknown plan_id: invalid for service_id: {service.osb_service_id}"
in response_data["error"] in response_data["error"]
) )
@ -527,13 +525,13 @@ def test_delete_invalid_plan_id(osb_client, test_service, instance_id):
def test_patch_suspension_success( def test_patch_suspension_success(
mock_odoo_success, mock_odoo_success,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
instance_id, instance_id,
): ):
payload = { payload = {
"service_id": test_service.osb_service_id, "service_id": service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id, "plan_id": service_offering.osb_plan_id,
"parameters": { "parameters": {
"users": [ "users": [
{ {
@ -556,9 +554,9 @@ def test_patch_suspension_success(
@pytest.mark.django_db @pytest.mark.django_db
def test_patch_missing_service_id(osb_client, test_service_offering, instance_id): def test_patch_missing_service_id(osb_client, service_offering, instance_id):
payload = { payload = {
"plan_id": test_service_offering.osb_plan_id, "plan_id": service_offering.osb_plan_id,
"parameters": {"users": []}, "parameters": {"users": []},
} }
@ -574,9 +572,9 @@ def test_patch_missing_service_id(osb_client, test_service_offering, instance_id
@pytest.mark.django_db @pytest.mark.django_db
def test_patch_missing_plan_id(osb_client, test_service, instance_id): def test_patch_missing_plan_id(osb_client, service, instance_id):
payload = { payload = {
"service_id": test_service.osb_service_id, "service_id": service.osb_service_id,
"parameters": {"users": []}, "parameters": {"users": []},
} }
@ -609,8 +607,8 @@ def test_delete_creates_ticket_with_admin_links(
mocker, mocker,
mock_odoo_success, mock_odoo_success,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
instance_id, instance_id,
): ):
# Mock the create_helpdesk_ticket function # Mock the create_helpdesk_ticket function
@ -618,7 +616,7 @@ def test_delete_creates_ticket_with_admin_links(
response = osb_client.delete( response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}" f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}" f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}"
) )
assert response.status_code == 200 assert response.status_code == 200
@ -629,10 +627,10 @@ def test_delete_creates_ticket_with_admin_links(
# Check that the description contains an admin URL # Check that the description contains an admin URL
assert "admin/core/serviceoffering" in call_kwargs["description"] assert "admin/core/serviceoffering" in call_kwargs["description"]
assert f"/{test_service_offering.pk}/" in call_kwargs["description"] assert f"/{service_offering.pk}/" in call_kwargs["description"]
assert ( assert (
call_kwargs["title"] call_kwargs["title"]
== f"Exoscale OSB Offboard - {test_service.name} - {instance_id}" == f"Exoscale OSB Offboard - {service.name} - {instance_id}"
) )
@ -641,8 +639,8 @@ def test_patch_creates_ticket_with_user_admin_links(
mocker, mocker,
mock_odoo_success, mock_odoo_success,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
instance_id, instance_id,
org_owner, org_owner,
): ):
@ -650,8 +648,8 @@ def test_patch_creates_ticket_with_user_admin_links(
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket") mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
payload = { payload = {
"service_id": test_service.osb_service_id, "service_id": service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id, "plan_id": service_offering.osb_plan_id,
"parameters": { "parameters": {
"users": [ "users": [
{ {
@ -680,8 +678,7 @@ def test_patch_creates_ticket_with_user_admin_links(
assert "admin/core/user" in call_kwargs["description"] assert "admin/core/user" in call_kwargs["description"]
assert f"/{org_owner.pk}/" in call_kwargs["description"] assert f"/{org_owner.pk}/" in call_kwargs["description"]
assert ( assert (
call_kwargs["title"] call_kwargs["title"] == f"Exoscale OSB Suspend - {service.name} - {instance_id}"
== f"Exoscale OSB Suspend - {test_service.name} - {instance_id}"
) )
@ -690,8 +687,8 @@ def test_ticket_includes_organization_and_instance_when_found(
mocker, mocker,
mock_odoo_success, mock_odoo_success,
osb_client, osb_client,
test_service, service,
test_service_offering, service_offering,
organization, organization,
): ):
# Mock the create_helpdesk_ticket function # Mock the create_helpdesk_ticket function
@ -699,12 +696,12 @@ def test_ticket_includes_organization_and_instance_when_found(
service_definition = ServiceDefinition.objects.create( service_definition = ServiceDefinition.objects.create(
name="Test Definition", name="Test Definition",
service=test_service, service=service,
api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"}, api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"},
) )
control_plane = ControlPlane.objects.create( control_plane = ControlPlane.objects.create(
name="Test Control Plane", name="Test Control Plane",
cloud_provider=test_service_offering.provider, cloud_provider=service_offering.provider,
api_credentials={ api_credentials={
"certificate-authority-data": "test", "certificate-authority-data": "test",
"server": "https://test", "server": "https://test",
@ -712,20 +709,22 @@ def test_ticket_includes_organization_and_instance_when_found(
}, },
) )
crd = ControlPlaneCRD.objects.create( crd = ControlPlaneCRD.objects.create(
service_offering=test_service_offering, service_offering=service_offering,
control_plane=control_plane, control_plane=control_plane,
service_definition=service_definition, service_definition=service_definition,
) )
instance_name = "test-instance-123" instance_name = "test-instance-123"
instance_display_name = "Test Instance 123"
service_instance = ServiceInstance.objects.create( service_instance = ServiceInstance.objects.create(
name=instance_name, name=instance_name,
display_name=instance_display_name,
organization=organization, organization=organization,
context=crd, context=crd,
) )
response = osb_client.delete( response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_name}" f"/api/osb/v2/service_instances/{instance_name}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}" f"?service_id={service.osb_service_id}&plan_id={service_offering.osb_plan_id}"
) )
assert response.status_code == 200 assert response.status_code == 200
@ -739,7 +738,10 @@ def test_ticket_includes_organization_and_instance_when_found(
assert "admin/core/organization" in call_kwargs["description"] assert "admin/core/organization" in call_kwargs["description"]
assert f"/{organization.pk}/" in call_kwargs["description"] assert f"/{organization.pk}/" in call_kwargs["description"]
# Check instance is included # Check instance is included (format: "display_name (resource_name)")
assert f"Instance: {service_instance.name}" in call_kwargs["description"] assert (
f"Instance: {service_instance.display_name} ({service_instance.name})"
in call_kwargs["description"]
)
assert "admin/core/serviceinstance" in call_kwargs["description"] assert "admin/core/serviceinstance" in call_kwargs["description"]
assert f"/{service_instance.pk}/" in call_kwargs["description"] assert f"/{service_instance.pk}/" in call_kwargs["description"]

View file

@ -22,7 +22,7 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
} }
], ],
@ -32,7 +32,7 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
crd.service_definition = service_def crd.service_definition = service_def
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
class Meta: class Meta:
app_label = "test" app_label = "test"
@ -193,7 +193,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
"""Test that choice fields use custom choices when provided in form_config""" """Test that choice fields use custom choices when provided in form_config"""
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
environment = models.CharField( environment = models.CharField(
max_length=20, max_length=20,
choices=[ choices=[
@ -215,7 +215,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -246,7 +246,7 @@ def test_choice_field_uses_custom_choices_from_form_config():
def test_choice_field_uses_control_plane_choices_when_no_custom_choices(): def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
environment = models.CharField( environment = models.CharField(
max_length=20, max_length=20,
choices=[ choices=[
@ -267,7 +267,7 @@ def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -292,7 +292,7 @@ def test_choice_field_uses_control_plane_choices_when_no_custom_choices():
def test_choice_field_validates_against_control_plane_choices(): def test_choice_field_validates_against_control_plane_choices():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
environment = models.CharField( environment = models.CharField(
max_length=20, max_length=20,
choices=[ choices=[
@ -313,7 +313,7 @@ def test_choice_field_validates_against_control_plane_choices():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -330,15 +330,15 @@ def test_choice_field_validates_against_control_plane_choices():
form_class = generate_custom_form_class(form_config, TestModel) form_class = generate_custom_form_class(form_config, TestModel)
form = form_class(data={"name": "test-service", "environment": "dev"}) form = form_class(data={"display_name": "test-service", "environment": "dev"})
form.fields["context"].required = False # Skip context validation form.fields["context"].required = False # Skip context validation
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
form = form_class(data={"name": "test-service", "environment": "prod"}) form = form_class(data={"display_name": "test-service", "environment": "prod"})
form.fields["context"].required = False # Skip context validation form.fields["context"].required = False # Skip context validation
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
form = form_class(data={"name": "test-service", "environment": "invalid"}) form = form_class(data={"display_name": "test-service", "environment": "invalid"})
form.fields["context"].required = False # Skip context validation form.fields["context"].required = False # Skip context validation
assert not form.is_valid() assert not form.is_valid()
assert "environment" in form.errors assert "environment" in form.errors
@ -368,7 +368,7 @@ def test_admin_form_validates_choice_values_against_schema():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
}, },
{ {
"type": "choice", "type": "choice",
@ -399,7 +399,7 @@ def test_admin_form_validates_choice_values_against_schema():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
}, },
{ {
"type": "choice", "type": "choice",
@ -431,7 +431,7 @@ def test_admin_form_validates_choice_values_against_schema():
def test_number_field_min_max_sets_widget_attributes(): def test_number_field_min_max_sets_widget_attributes():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
port = models.IntegerField() port = models.IntegerField()
replica_count = models.IntegerField() replica_count = models.IntegerField()
@ -446,7 +446,7 @@ def test_number_field_min_max_sets_widget_attributes():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -494,7 +494,7 @@ def test_number_field_min_max_sets_widget_attributes():
def test_default_value_for_all_field_types(): def test_default_value_for_all_field_types():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
description = models.TextField() description = models.TextField()
port = models.IntegerField() port = models.IntegerField()
environment = models.CharField( environment = models.CharField(
@ -518,7 +518,7 @@ def test_default_value_for_all_field_types():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"default_value": "default-name", "default_value": "default-name",
}, },
{ {
@ -559,7 +559,7 @@ def test_default_value_for_all_field_types():
form_class = generate_custom_form_class(form_config, TestModel) form_class = generate_custom_form_class(form_config, TestModel)
form = form_class() form = form_class()
assert form.fields["name"].initial == "default-name" assert form.fields["display_name"].initial == "default-name"
assert form.fields["description"].initial == "Default description text" assert form.fields["description"].initial == "Default description text"
assert form.fields["port"].initial == "8080" assert form.fields["port"].initial == "8080"
assert form.fields["environment"].initial == "dev" assert form.fields["environment"].initial == "dev"
@ -570,7 +570,7 @@ def test_default_value_for_all_field_types():
def test_default_value_not_override_existing_instance(): def test_default_value_not_override_existing_instance():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
port = models.IntegerField() port = models.IntegerField()
class Meta: class Meta:
@ -583,7 +583,7 @@ def test_default_value_not_override_existing_instance():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"default_value": "default-name", "default_value": "default-name",
}, },
{ {
@ -597,11 +597,11 @@ def test_default_value_not_override_existing_instance():
] ]
} }
instance = TestModel(name="existing-name", port=3000) instance = TestModel(display_name="existing-name", port=3000)
form_class = generate_custom_form_class(form_config, TestModel) form_class = generate_custom_form_class(form_config, TestModel)
form = form_class(instance=instance) form = form_class(instance=instance)
assert form.initial["name"] == "existing-name" assert form.initial["display_name"] == "existing-name"
assert form.initial["port"] == 3000 assert form.initial["port"] == 3000
@ -708,7 +708,7 @@ def test_form_config_handles_empty_string_as_none():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"max_length": "", # Empty string "max_length": "", # Empty string
}, },
] ]
@ -744,7 +744,7 @@ def test_single_element_choices_are_normalized():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
}, },
{ {
"type": "choice", "type": "choice",
@ -879,7 +879,7 @@ def test_three_plus_element_choices_fail_validation():
def test_field_with_default_config_only_needs_mapping(): def test_field_with_default_config_only_needs_mapping():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
class Meta: class Meta:
app_label = "test" app_label = "test"
@ -889,7 +889,7 @@ def test_field_with_default_config_only_needs_mapping():
{ {
"fields": [ "fields": [
{ {
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
}, },
] ]
} }
@ -899,16 +899,16 @@ def test_field_with_default_config_only_needs_mapping():
form_class = generate_custom_form_class(minimal_config, TestModel) form_class = generate_custom_form_class(minimal_config, TestModel)
form = form_class() form = form_class()
name_field = form.fields["name"] name_field = form.fields["display_name"]
assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"] assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"]
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
assert name_field.required == DEFAULT_FIELD_CONFIGS["name"]["required"] assert name_field.required == DEFAULT_FIELD_CONFIGS["display_name"]["required"]
def test_field_with_default_config_can_override_defaults(): def test_field_with_default_config_can_override_defaults():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
class Meta: class Meta:
app_label = "test" app_label = "test"
@ -918,7 +918,7 @@ def test_field_with_default_config_can_override_defaults():
{ {
"fields": [ "fields": [
{ {
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"label": "Custom Name Label", "label": "Custom Name Label",
"required": False, "required": False,
}, },
@ -930,16 +930,16 @@ def test_field_with_default_config_can_override_defaults():
form_class = generate_custom_form_class(override_config, TestModel) form_class = generate_custom_form_class(override_config, TestModel)
form = form_class() form = form_class()
name_field = form.fields["name"] name_field = form.fields["display_name"]
assert name_field.label == "Custom Name Label" assert name_field.label == "Custom Name Label"
assert name_field.required is False assert name_field.required is False
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
def test_empty_values_dont_override_default_configs(): def test_empty_values_dont_override_default_configs():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
class Meta: class Meta:
app_label = "test" app_label = "test"
@ -949,7 +949,7 @@ def test_empty_values_dont_override_default_configs():
{ {
"fields": [ "fields": [
{ {
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"type": "", "type": "",
"label": "", "label": "",
"help_text": None, "help_text": None,
@ -964,11 +964,11 @@ def test_empty_values_dont_override_default_configs():
form_class = generate_custom_form_class(admin_form_config, TestModel) form_class = generate_custom_form_class(admin_form_config, TestModel)
form = form_class() form = form_class()
name_field = form.fields["name"] name_field = form.fields["display_name"]
assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"] assert name_field.label == DEFAULT_FIELD_CONFIGS["display_name"]["label"]
assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] assert name_field.help_text == DEFAULT_FIELD_CONFIGS["display_name"]["help_text"]
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"] assert name_field.max_length == DEFAULT_FIELD_CONFIGS["display_name"]["max_length"]
assert name_field.required is False # Was overridden by explicit False assert name_field.required is False # Was overridden by explicit False
@ -976,7 +976,7 @@ def test_empty_values_dont_override_default_configs():
def test_number_field_validates_min_max_values(): def test_number_field_validates_min_max_values():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
port = models.IntegerField() port = models.IntegerField()
class Meta: class Meta:
@ -990,7 +990,7 @@ def test_number_field_validates_min_max_values():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -1009,26 +1009,26 @@ def test_number_field_validates_min_max_values():
form_class = generate_custom_form_class(form_config, TestModel) form_class = generate_custom_form_class(form_config, TestModel)
# Test value below minimum fails validation # Test value below minimum fails validation
form = form_class(data={"name": "test-service", "port": 0}) form = form_class(data={"display_name": "test-service", "port": 0})
form.fields["context"].required = False form.fields["context"].required = False
assert not form.is_valid() assert not form.is_valid()
assert "port" in form.errors assert "port" in form.errors
# Test value above maximum fails validation # Test value above maximum fails validation
form = form_class(data={"name": "test-service", "port": 65536}) form = form_class(data={"display_name": "test-service", "port": 65536})
form.fields["context"].required = False form.fields["context"].required = False
assert not form.is_valid() assert not form.is_valid()
assert "port" in form.errors assert "port" in form.errors
# Test valid value passes validation # Test valid value passes validation
form = form_class(data={"name": "test-service", "port": 8080}) form = form_class(data={"display_name": "test-service", "port": 8080})
form.fields["context"].required = False form.fields["context"].required = False
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
def test_number_field_with_addon_text_roundtrip(): def test_number_field_with_addon_text_roundtrip():
class TestModel(models.Model): class TestModel(models.Model):
name = models.CharField(max_length=100) display_name = models.CharField(max_length=100)
disk_size = models.IntegerField() disk_size = models.IntegerField()
class Meta: class Meta:
@ -1041,7 +1041,7 @@ def test_number_field_with_addon_text_roundtrip():
{ {
"type": "text", "type": "text",
"label": "Name", "label": "Name",
"controlplane_field_mapping": "name", "controlplane_field_mapping": "display_name",
"required": True, "required": True,
}, },
{ {
@ -1059,7 +1059,7 @@ def test_number_field_with_addon_text_roundtrip():
form = form_class(initial={"name": "test-instance", "disk_size": "25Gi"}) form = form_class(initial={"name": "test-instance", "disk_size": "25Gi"})
assert form.initial["disk_size"] == 25 assert form.initial["disk_size"] == 25
form = form_class(data={"name": "test-instance", "disk_size": "25"}) form = form_class(data={"display_name": "test-instance", "disk_size": "25"})
form.fields["context"].required = False form.fields["context"].required = False
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
nested_data = form.get_nested_data() nested_data = form.get_nested_data()

View file

@ -109,7 +109,7 @@ class TestReencryptFieldsCommand:
assert "Starting re-encryption" in output assert "Starting re-encryption" in output
assert "Re-encrypted 0 ControlPlane objects" in output assert "Re-encrypted 0 ControlPlane objects" in output
def test_reencrypt_fields_with_control_plane(self, test_control_plane): def test_reencrypt_fields_with_control_plane(self, control_plane):
out = StringIO() out = StringIO()
call_command("reencrypt_fields", stdout=out) call_command("reencrypt_fields", stdout=out)
@ -147,11 +147,11 @@ class TestSyncBillingMetadataCommand:
assert "No control planes found with the specified IDs" in out.getvalue() assert "No control planes found with the specified IDs" in out.getvalue()
def test_sync_billing_metadata_dry_run_with_control_plane(self, test_control_plane): def test_sync_billing_metadata_dry_run_with_control_plane(self, control_plane):
out = StringIO() out = StringIO()
call_command("sync_billing_metadata", "--dry-run", stdout=out) call_command("sync_billing_metadata", "--dry-run", stdout=out)
output = out.getvalue() output = out.getvalue()
assert "DRY RUN" in output assert "DRY RUN" in output
assert "Syncing billing metadata on 1 control plane(s)" in output assert "Syncing billing metadata on 1 control plane(s)" in output
assert test_control_plane.name in output assert control_plane.name in output

151
src/tests/test_rules.py Normal file
View 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

View 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 "&lt;script&gt;" 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
View 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"

View file

@ -1,6 +1,14 @@
from unittest.mock import MagicMock, patch
import pytest import pytest
from servala.core.models.service import CloudProvider, ServiceOffering from servala.core.models.service import (
CloudProvider,
ControlPlane,
ControlPlaneCRD,
ServiceInstance,
ServiceOffering,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -51,76 +59,76 @@ def test_organization_linked_in_sidebar(
@pytest.mark.django_db @pytest.mark.django_db
def test_service_detail_redirects_with_single_offering( def test_service_detail_redirects_with_single_offering(
client, org_owner, organization, test_service, test_service_offering client, org_owner, organization, service, service_offering
): ):
client.force_login(org_owner) client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/" url = f"/org/{organization.slug}/services/{service.slug}/"
response = client.get(url) response = client.get(url)
assert response.status_code == 302 assert response.status_code == 302
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/" expected_url = f"/org/{organization.slug}/services/{service.slug}/offering/{service_offering.pk}/"
assert response.url == expected_url assert response.url == expected_url
@pytest.mark.django_db @pytest.mark.django_db
def test_service_detail_shows_multiple_offerings( def test_service_detail_shows_multiple_offerings(
client, org_owner, organization, test_service, test_service_offering client, org_owner, organization, service, service_offering
): ):
second_provider = CloudProvider.objects.create( second_provider = CloudProvider.objects.create(
name="AWS", description="Amazon Web Services" name="AWS", description="Amazon Web Services"
) )
second_offering = ServiceOffering.objects.create( second_offering = ServiceOffering.objects.create(
service=test_service, service=service,
provider=second_provider, provider=second_provider,
description="Redis on AWS", description="Redis on AWS",
osb_plan_id="test-plan-456", osb_plan_id="test-plan-456",
) )
client.force_login(org_owner) client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/" url = f"/org/{organization.slug}/services/{service.slug}/"
response = client.get(url) response = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
assert test_service_offering.provider.name in content assert service_offering.provider.name in content
assert second_offering.provider.name in content assert second_offering.provider.name in content
assert "Create Instance" in content assert "Create Instance" in content
@pytest.mark.django_db @pytest.mark.django_db
def test_service_detail_respects_cloud_provider_restrictions( def test_service_detail_respects_cloud_provider_restrictions(
client, org_owner, organization, test_service, test_service_offering client, org_owner, organization, service, service_offering
): ):
second_provider = CloudProvider.objects.create( second_provider = CloudProvider.objects.create(
name="AWS", description="Amazon Web Services" name="AWS", description="Amazon Web Services"
) )
ServiceOffering.objects.create( ServiceOffering.objects.create(
service=test_service, service=service,
provider=second_provider, provider=second_provider,
description="Redis on AWS", description="Redis on AWS",
osb_plan_id="test-plan-456", osb_plan_id="test-plan-456",
) )
organization.origin.limit_cloudproviders.add(test_service_offering.provider) organization.origin.limit_cloudproviders.add(service_offering.provider)
client.force_login(org_owner) client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/" url = f"/org/{organization.slug}/services/{service.slug}/"
response = client.get(url) response = client.get(url)
assert response.status_code == 302 assert response.status_code == 302
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/" expected_url = f"/org/{organization.slug}/services/{service.slug}/offering/{service_offering.pk}/"
assert response.url == expected_url assert response.url == expected_url
@pytest.mark.django_db @pytest.mark.django_db
def test_service_detail_no_redirect_with_restricted_multiple_offerings( def test_service_detail_no_redirect_with_restricted_multiple_offerings(
client, org_owner, organization, test_service, test_service_offering client, org_owner, organization, service, service_offering
): ):
second_provider = CloudProvider.objects.create( second_provider = CloudProvider.objects.create(
name="AWS", description="Amazon Web Services" name="AWS", description="Amazon Web Services"
) )
second_offering = ServiceOffering.objects.create( second_offering = ServiceOffering.objects.create(
service=test_service, service=service,
provider=second_provider, provider=second_provider,
description="Redis on AWS", description="Redis on AWS",
osb_plan_id="test-plan-456", osb_plan_id="test-plan-456",
@ -129,21 +137,80 @@ def test_service_detail_no_redirect_with_restricted_multiple_offerings(
name="Azure", description="Microsoft Azure" name="Azure", description="Microsoft Azure"
) )
third_offering = ServiceOffering.objects.create( third_offering = ServiceOffering.objects.create(
service=test_service, service=service,
provider=third_provider, provider=third_provider,
description="Redis on Azure", description="Redis on Azure",
osb_plan_id="test-plan-789", osb_plan_id="test-plan-789",
) )
organization.origin.limit_cloudproviders.add( organization.origin.limit_cloudproviders.add(
test_service_offering.provider, second_provider service_offering.provider, second_provider
) )
client.force_login(org_owner) client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/" url = f"/org/{organization.slug}/services/{service.slug}/"
response = client.get(url) response = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
assert test_service_offering.provider.name in content assert service_offering.provider.name in content
assert second_offering.provider.name in content assert second_offering.provider.name in content
assert third_offering.provider.name not in content assert third_offering.provider.name not in content
@pytest.mark.django_db
def test_service_instance_update_spec_pushes_display_name_annotation(
organization, control_plane_crd, org_owner
):
instance = ServiceInstance.objects.create(
name="test-instance",
display_name="Original Name",
organization=organization,
context=control_plane_crd,
)
mock_api = MagicMock()
with (
patch.object(ControlPlane, "custom_objects_api", mock_api),
patch.object(ControlPlaneCRD, "kind_plural", "testkinds"),
):
instance.display_name = "Updated Name"
instance.update_spec(spec_data={}, updated_by=org_owner)
mock_api.patch_namespaced_custom_object.assert_called_once()
call_kwargs = mock_api.patch_namespaced_custom_object.call_args[1]
assert "metadata" in call_kwargs["body"]
annotations = call_kwargs["body"]["metadata"]["annotations"]
assert annotations["servala.com/displayName"] == "Updated Name"
instance.refresh_from_db()
assert instance.display_name == "Updated Name"
@pytest.mark.django_db
def test_build_billing_annotations_includes_display_name():
annotations = ServiceInstance._build_billing_annotations(
compute_plan_assignment=None,
control_plane=MagicMock(
storage_plan_odoo_product_id=None,
storage_plan_odoo_unit_id=None,
),
instance_name="test-instance",
display_name="My Display Name",
organization=None,
service=None,
)
assert annotations["servala.com/displayName"] == "My Display Name"
@pytest.mark.django_db
def test_build_billing_annotations_omits_display_name_when_none():
annotations = ServiceInstance._build_billing_annotations(
compute_plan_assignment=None,
control_plane=MagicMock(
storage_plan_odoo_product_id=None,
storage_plan_odoo_unit_id=None,
),
instance_name="test-instance",
display_name=None,
organization=None,
service=None,
)
assert "servala.com/displayName" not in annotations

103
uv.lock generated
View file

@ -65,7 +65,7 @@ wheels = [
[[package]] [[package]]
name = "black" name = "black"
version = "25.11.0" version = "25.12.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@ -75,41 +75,42 @@ dependencies = [
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "pytokens" }, { 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 = [ 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/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/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/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/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/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/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/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/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/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]] [[package]]
name = "boto3" name = "boto3"
version = "1.42.0" version = "1.42.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "botocore" }, { name = "botocore" },
{ name = "jmespath" }, { name = "jmespath" },
{ name = "s3transfer" }, { 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 = [ 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]] [[package]]
name = "botocore" name = "botocore"
version = "1.41.6" version = "1.42.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jmespath" }, { name = "jmespath" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "urllib3" }, { 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 = [ 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]] [[package]]
@ -226,37 +227,37 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.12.0" version = "7.13.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]
@ -720,11 +721,11 @@ wheels = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.5.0" version = "4.5.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -823,7 +824,7 @@ wheels = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.1" version = "9.0.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@ -832,9 +833,9 @@ dependencies = [
{ name = "pluggy" }, { name = "pluggy" },
{ name = "pygments" }, { 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 = [ 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]] [[package]]
@ -1154,15 +1155,15 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "black", specifier = ">=25.11.0" }, { name = "black", specifier = ">=25.12.0" },
{ name = "bumpver", specifier = ">=2025.1131" }, { name = "bumpver", specifier = ">=2025.1131" },
{ name = "coverage", specifier = ">=7.12.0" }, { name = "coverage", specifier = ">=7.13.0" },
{ name = "djlint", specifier = ">=1.36.4" }, { name = "djlint", specifier = ">=1.36.4" },
{ name = "flake8", specifier = ">=7.3.0" }, { name = "flake8", specifier = ">=7.3.0" },
{ name = "flake8-bugbear", specifier = ">=25.11.29" }, { name = "flake8-bugbear", specifier = ">=25.11.29" },
{ name = "flake8-pyproject", specifier = ">=1.2.4" }, { name = "flake8-pyproject", specifier = ">=1.2.4" },
{ name = "isort", specifier = ">=7.0.0" }, { 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-cov", specifier = ">=7.0.0" },
{ name = "pytest-django", specifier = ">=4.11.1" }, { name = "pytest-django", specifier = ">=4.11.1" },
{ name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pytest-mock", specifier = ">=3.15.1" },