Compare commits

...

24 commits

Author SHA1 Message Date
848e5162bc Fix update view
All checks were successful
Tests / test (push) Successful in 29s
2025-12-10 10:21:04 +01:00
8da8f2fc44 Fix FQDN generation 2025-12-10 09:04:25 +01:00
16680aada0 Fix custom form config 2025-12-10 09:04:25 +01:00
6956c625e3 Fix default form config 2025-12-10 09:04:25 +01:00
896b16282f Use instance name where appropriate 2025-12-10 09:04:25 +01:00
8410b49bc5 Make display name editable 2025-12-10 09:04:25 +01:00
d598da9bf1 Use display name where appropriate 2025-12-10 09:04:25 +01:00
a9dbd80e53 Add model field, migrations, and backend methods 2025-12-10 09:04:25 +01:00
bdce85feae Add instance name prefix setting 2025-12-10 09:04:25 +01:00
a7d301c857 Add missing test fixture 2025-12-10 09:04:25 +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
30 changed files with 448 additions and 162 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

@ -41,7 +41,7 @@ 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: valueFrom:

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="Resource Name",
),
),
# Add display_name field with a temporary default
migrations.AddField(
model_name="serviceinstance",
name="display_name",
field=models.CharField(
default="",
help_text="Display name for this instance",
max_length=100,
verbose_name="Name",
),
preserve_default=False,
),
migrations.RunPython(populate_display_name, migrations.RunPython.noop),
]

View file

@ -18,6 +18,10 @@ from encrypted_fields.fields import EncryptedJSONField
from kubernetes import client, config from kubernetes import client, config
from kubernetes.client.rest import ApiException from kubernetes.client.rest import ApiException
import hashlib
from django.conf import settings
from servala.core import rules as perms from servala.core import rules as perms
from servala.core.models.mixins import ServalaModelMixin from servala.core.models.mixins import ServalaModelMixin
from servala.core.validators import kubernetes_name_validator from servala.core.validators import kubernetes_name_validator
@ -615,8 +619,16 @@ class ServiceInstance(ServalaModelMixin, models.Model):
on the fly. on the fly.
""" """
# The Kubernetes resource name (metadata.name). This field is immutable after
# creation and is auto-generated for new instances. Do not modify directly!
name = models.CharField( name = models.CharField(
max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator] max_length=63,
verbose_name=_("Resource Name"),
validators=[kubernetes_name_validator],
)
display_name = models.CharField(
max_length=100,
verbose_name=_("Name"),
) )
organization = models.ForeignKey( organization = models.ForeignKey(
to="core.Organization", to="core.Organization",
@ -686,6 +698,24 @@ class ServiceInstance(ServalaModelMixin, models.Model):
spec_data = prune_empty_data(spec_data) spec_data = prune_empty_data(spec_data)
return spec_data return spec_data
@staticmethod
def generate_resource_name(organization, display_name, service, attempt=0):
"""
Generate a unique Kubernetes-compatible resource name.
Format: {prefix}-{sha256[:8]}
The hash input is: org_slug:display_name:service_slug[:attempt if > 0]
On collision, we retry with an incremented attempt number included in hash.
"""
hash_input = (
f"{organization.slug}:{display_name.lower().strip()}:{service.slug}"
)
if attempt > 0:
hash_input += f":{attempt}"
hash_value = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()[:8]
return f"{settings.SERVALA_INSTANCE_NAME_PREFIX}-{hash_value}"
@staticmethod @staticmethod
def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment): def _apply_compute_plan_to_spec(spec_data, compute_plan_assignment):
""" """
@ -719,16 +749,20 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment, compute_plan_assignment,
control_plane, control_plane,
instance_name=None, instance_name=None,
display_name=None,
organization=None, organization=None,
service=None, service=None,
): ):
""" """
Build Kubernetes annotations for billing integration. Build Kubernetes annotations for billing integration and display name.
""" """
from servala.core.models.organization import InvoiceGroupingChoice from servala.core.models.organization import InvoiceGroupingChoice
annotations = {} annotations = {}
if display_name:
annotations["servala.com/displayName"] = display_name
if compute_plan_assignment: if compute_plan_assignment:
annotations["servala.com/erp_product_id_resource"] = str( annotations["servala.com/erp_product_id_resource"] = str(
compute_plan_assignment.odoo_product_id compute_plan_assignment.odoo_product_id
@ -830,18 +864,35 @@ class ServiceInstance(ServalaModelMixin, models.Model):
@transaction.atomic @transaction.atomic
def create_instance( def create_instance(
cls, cls,
name, display_name,
organization, organization,
context, context,
created_by, created_by,
spec_data, spec_data,
compute_plan_assignment=None, compute_plan_assignment=None,
): ):
service = context.service_offering.service
name = None
for attempt in range(10):
name = cls.generate_resource_name(
organization, display_name, service, attempt
)
if not cls.objects.filter(
name=name, organization=organization, context=context
).exists():
break
else:
message = _(
"Could not generate a unique resource name. Please try a different display name."
)
raise ValidationError(organization.add_support_message(message))
# Ensure the namespace exists # Ensure the namespace exists
context.control_plane.get_or_create_namespace(organization) context.control_plane.get_or_create_namespace(organization)
try: try:
instance = cls.objects.create( instance = cls.objects.create(
name=name, name=name,
display_name=display_name,
organization=organization, organization=organization,
created_by=created_by, created_by=created_by,
context=context, context=context,
@ -883,8 +934,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment=compute_plan_assignment, compute_plan_assignment=compute_plan_assignment,
control_plane=context.control_plane, control_plane=context.control_plane,
instance_name=name, instance_name=name,
display_name=display_name,
organization=organization, organization=organization,
service=context.service_offering.service, service=service,
) )
if annotations: if annotations:
create_data["metadata"]["annotations"] = annotations create_data["metadata"]["annotations"] = annotations
@ -941,6 +993,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
compute_plan_assignment=plan_to_use, compute_plan_assignment=plan_to_use,
control_plane=self.context.control_plane, control_plane=self.context.control_plane,
instance_name=self.name, instance_name=self.name,
display_name=self.display_name,
organization=self.organization, organization=self.organization,
service=self.context.service_offering.service, service=self.context.service_offering.service,
) )
@ -1065,7 +1118,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=_("Resource Name"),
max_length=63, max_length=63,
widget=forms.TextInput(attrs={"class": "form-control"}), widget=forms.TextInput(attrs={"class": "form-control"}),
) )
@ -132,7 +132,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
kwargs["initial"] = {"name": ""} kwargs["initial"] = {"name": ""}
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["name"].help_text = _( self.fields["name"].help_text = _(
"To confirm deletion, please type the instance name: <strong>{instance_name}</strong>" "To confirm deletion, please type the resource name: <strong>{instance_name}</strong>"
).format(instance_name=self.instance.name) ).format(instance_name=self.instance.name)
def clean_name(self): def clean_name(self):
@ -140,7 +140,7 @@ class ServiceInstanceDeleteForm(forms.ModelForm):
if entered_name != self.instance.name: if entered_name != self.instance.name:
raise forms.ValidationError( raise forms.ValidationError(
_( _(
"The entered name does not match the instance name. Deletion not confirmed." "The entered name does not match the resource name. Deletion not confirmed."
) )
) )
return entered_name return entered_name

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,7 @@
{% 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 %} {% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form 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 %}
@ -39,6 +39,10 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<dl class="row"> <dl class="row">
<dt class="col-sm-4">{% translate "Resource Name" %}</dt>
<dd class="col-sm-8">
<code>{{ instance.name }}</code>
</dd>
<dt class="col-sm-4">{% translate "Service" %}</dt> <dt class="col-sm-4">{% translate "Service" %}</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
{{ instance.context.service_definition.service.name }} {{ instance.context.service_definition.service.name }}

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,7 @@
{% 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 %} {% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form 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 "Resource Name" %}</th>
<th>{% translate "Service" %}</th> <th>{% translate "Service" %}</th>
<th>{% translate "Service Provider" %}</th> <th>{% translate "Service Provider" %}</th>
<th>{% translate "Service Provider Zone" %}</th> <th>{% translate "Service Provider Zone" %}</th>
@ -33,8 +34,9 @@
{% for instance in instances %} {% for instance in instances %}
<tr> <tr>
<td> <td>
<a href="{{ instance.urls.base }}">{{ instance.name }}</a> <a href="{{ instance.urls.base }}">{{ instance.display_name }}</a>
</td> </td>
<td><code>{{ instance.name }}</code></td>
<td>{{ instance.context.service_definition.service.name }}</td> <td>{{ instance.context.service_definition.service.name }}</td>
<td>{{ instance.context.service_offering.provider.name }}</td> <td>{{ instance.context.service_offering.provider.name }}</td>
<td>{{ instance.context.control_plane.name }}</td> <td>{{ instance.context.control_plane.name }}</td>
@ -42,7 +44,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="5">{% translate "No service instances found." %}</td> <td colspan="6">{% translate "No service instances found." %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View file

@ -1,7 +1,9 @@
{% load i18n %} {% load i18n %}
{% load get_field %} {% load get_field %}
{% load static %} {% load static %}
{% include "frontend/forms/errors.html" %} {% if not hide_form_errors %}
{% 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"),
@ -618,7 +618,7 @@ class ServiceInstanceUpdateView(
messages.success( messages.success(
self.request, self.request,
_("Service instance '{name}' updated successfully.").format( _("Service instance '{name}' updated successfully.").format(
name=self.object.name name=self.object.display_name
), ),
) )
return redirect(self.object.urls.base) return redirect(self.object.urls.base)
@ -679,7 +679,7 @@ class ServiceInstanceDeleteView(
messages.success( messages.success(
self.request, self.request,
_("Service instance '{name}' has been scheduled for deletion.").format( _("Service instance '{name}' has been scheduled for deletion.").format(
name=self.object.name name=self.object.display_name
), ),
) )
response = HttpResponse() response = HttpResponse()
@ -690,7 +690,7 @@ class ServiceInstanceDeleteView(
self.request, self.request,
self.organization.add_support_message( self.organization.add_support_message(
_( _(
f"An error occurred while trying to delete instance '{self.object.name}': {str(e)}." f"An error occurred while trying to delete instance '{self.object.display_name}': {str(e)}."
) )
), ),
) )

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

View file

@ -42,13 +42,41 @@ def other_organization(origin):
return Organization.objects.create(name="Test Org Alternate", origin=origin) return Organization.objects.create(name="Test Org Alternate", origin=origin)
@pytest.fixture
def user():
return User.objects.create(email="testuser@example.org", password="test")
@pytest.fixture @pytest.fixture
def org_owner(organization): def org_owner(organization):
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
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 @pytest.fixture

View file

@ -717,8 +717,10 @@ def test_ticket_includes_organization_and_instance_when_found(
service_definition=service_definition, service_definition=service_definition,
) )
instance_name = "test-instance-123" instance_name = "test-instance-123"
instance_display_name = "Test Instance 123"
service_instance = ServiceInstance.objects.create( service_instance = ServiceInstance.objects.create(
name=instance_name, name=instance_name,
display_name=instance_display_name,
organization=organization, organization=organization,
context=crd, context=crd,
) )
@ -739,7 +741,10 @@ def test_ticket_includes_organization_and_instance_when_found(
assert "admin/core/organization" in call_kwargs["description"] assert "admin/core/organization" in call_kwargs["description"]
assert f"/{organization.pk}/" in call_kwargs["description"] assert f"/{organization.pk}/" in call_kwargs["description"]
# Check instance is included # Check instance is included (format: "display_name (resource_name)")
assert f"Instance: {service_instance.name}" in call_kwargs["description"] assert (
f"Instance: {service_instance.display_name} ({service_instance.name})"
in call_kwargs["description"]
)
assert "admin/core/serviceinstance" in call_kwargs["description"] assert "admin/core/serviceinstance" in call_kwargs["description"]
assert f"/{service_instance.pk}/" in call_kwargs["description"] assert f"/{service_instance.pk}/" in call_kwargs["description"]

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()

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

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" },