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
SERVALA_OSB_USERNAME=''
SERVALA_OSB_PASSWORD=''
# Prefix for auto-generated Kubernetes resource names for service instances.
# Format: {prefix}-{hash}. Defaults to 'si' (service instance).
SERVALA_INSTANCE_NAME_PREFIX='si'

View file

@ -41,7 +41,7 @@ spec:
mkdir -p /app/.ssh && chmod 700 /app/.ssh
echo "$SSH_PRIVATE_KEY" > /app/.ssh/id
chmod 600 /app/.ssh/id
ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 8443:127.0.0.1:8443 -N -i /app/.ssh/id -v
ssh $SSH_HOST -l $SSH_USER -o StrictHostKeyChecking=no -L 6443:127.0.0.1:6443 -N -i /app/.ssh/id -v
env:
- name: SSH_HOST
valueFrom:

View file

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

View file

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

View file

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

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.
"""
# We always need these three fields to know our own name and our full namespace
# We always need these fields to know our display name and our full namespace
model_fields = {"__module__": "crd_models"}
for field_name in ("name", "context"):
for field_name in ("display_name", "context"):
model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
# All other fields are generated from the schema, except for the

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -26,7 +26,7 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div>
{% else %}
{% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %}
{% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form hide_form_errors=True %}
{% endif %}
</div>
</div>

View file

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

View file

@ -2,7 +2,7 @@
{% load i18n static pprint_filters %}
{% block html_title %}
{% block page_title %}
{{ instance.name }}
{{ instance.display_name }}
{% endblock page_title %}
{% endblock html_title %}
{% block page_title_extra %}
@ -39,6 +39,10 @@
</div>
<div class="card-body">
<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>
<dd class="col-sm-8">
{{ instance.context.service_definition.service.name }}

View file

@ -5,7 +5,7 @@
{% block html_title %}
{% block page_title %}
{% block title %}
{% blocktranslate with instance_name=instance.name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %}
{% blocktranslate with instance_name=instance.display_name organization_name=request.organization.name %}Update {{ instance_name }} in {{ organization_name }}{% endblocktranslate %}
{% endblock %}
{% endblock page_title %}
{% endblock html_title %}
@ -22,7 +22,7 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div>
{% else %}
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %}
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form hide_form_errors=True %}
{% endif %}
</div>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
const initializeFqdnGeneration = (prefix) => {
const nameField = document.querySelector(`input#id_${prefix}-name`);
const nameField = document.querySelector(`input#id_${prefix}-display_name`);
if (!nameField) return
// Try to find array input first (DynamicArrayWidget), then fallback to regular text input

View file

@ -42,13 +42,41 @@ def other_organization(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
def org_owner(organization):
user = User.objects.create(email="user@example.org", password="example")
owner = User.objects.create(email="owner@example.org", password="example")
OrganizationMembership.objects.create(
organization=organization, user=user, role="owner"
organization=organization, user=owner, role="owner"
)
return user
return owner
@pytest.fixture
def 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

View file

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

View file

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

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]]
name = "black"
version = "25.11.0"
version = "25.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@ -75,41 +75,42 @@ dependencies = [
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" },
{ url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" },
{ url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" },
{ url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" },
{ url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" },
{ url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" },
{ url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" },
{ url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" },
]
[[package]]
name = "boto3"
version = "1.42.0"
version = "1.42.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/9b/eef5346ce3148bf4856318fe629e0fd7f6dd73ffd55ea08e316c967f8af0/boto3-1.42.0.tar.gz", hash = "sha256:9c67729a6112b7dced521ea70b0369fba138e89852b029a7876041cd1460c084", size = 112854, upload-time = "2025-12-01T02:31:09.157Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/31/246916eec4fc5ff7bebf7e75caf47ee4d72b37d4120b6943e3460956e618/boto3-1.42.4.tar.gz", hash = "sha256:65f0d98a3786ec729ba9b5f70448895b2d1d1f27949aa7af5cb4f39da341bbc4", size = 112826, upload-time = "2025-12-05T20:27:14.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/2c/6c6ee5667426aee6629106b9e51668449fb34ec077655da82bf4b15d8890/boto3-1.42.0-py3-none-any.whl", hash = "sha256:af32b7f61dd6293cad728ec205bcb3611ab1bf7b7dbccfd0f2bd7b9c9af96039", size = 140617, upload-time = "2025-12-01T02:31:07.238Z" },
{ url = "https://files.pythonhosted.org/packages/00/25/9ae819385aad79f524859f7179cecf8ac019b63ac8f150c51b250967f6db/boto3-1.42.4-py3-none-any.whl", hash = "sha256:0f4089e230d55f981d67376e48cefd41c3d58c7f694480f13288e6ff7b1fefbc", size = 140621, upload-time = "2025-12-05T20:27:12.803Z" },
]
[[package]]
name = "botocore"
version = "1.41.6"
version = "1.42.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/04/8e8ca38631eeb499a1099dcc2a081faaea399f9d46080720540ff54ec609/botocore-1.41.6.tar.gz", hash = "sha256:08fe47e9b306f4436f5eaf6a02cb6d55c7745d13d2d093ce5d917d3ef3d3df75", size = 14770281, upload-time = "2025-12-01T02:30:54.286Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/b7/dec048c124619b2702b5236c5fc9d8e5b0a87013529e9245dc49aaaf31ff/botocore-1.42.4.tar.gz", hash = "sha256:d4816023492b987a804f693c2d76fb751fdc8755d49933106d69e2489c4c0f98", size = 14848605, upload-time = "2025-12-05T20:27:02.919Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/d4/587a71c599997b0f7aa842ea71604348f5a7d239cfff338292904f236983/botocore-1.41.6-py3-none-any.whl", hash = "sha256:963cc946e885acb941c96e7d343cb6507b479812ca22566ceb3e9410d0588de0", size = 14442076, upload-time = "2025-12-01T02:30:50.724Z" },
{ url = "https://files.pythonhosted.org/packages/9b/a2/7b50f12a9c5a33cd85a5f23fdf78a0cbc445c0245c16051bb627f328be06/botocore-1.42.4-py3-none-any.whl", hash = "sha256:c3b091fd33809f187824b6434e518b889514ded5164cb379358367c18e8b0d7d", size = 14519938, upload-time = "2025-12-05T20:26:58.881Z" },
]
[[package]]
@ -226,37 +227,37 @@ wheels = [
[[package]]
name = "coverage"
version = "7.12.0"
version = "7.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" },
{ url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" },
{ url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" },
{ url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" },
{ url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" },
{ url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" },
{ url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" },
{ url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" },
{ url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" },
{ url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" },
{ url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" },
{ url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" },
{ url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" },
{ url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" },
{ url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" },
{ url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" },
{ url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" },
{ url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" },
{ url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" },
{ url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" },
{ url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
{ url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
{ url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
{ url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
{ url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
{ url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
{ url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
{ url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
{ url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
{ url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
{ url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
{ url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
{ url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
{ url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
{ url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
{ url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
{ url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
{ url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
]
[[package]]
@ -720,11 +721,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.5.0"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
]
[[package]]
@ -823,7 +824,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.1"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@ -832,9 +833,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
@ -1154,15 +1155,15 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = ">=25.11.0" },
{ name = "black", specifier = ">=25.12.0" },
{ name = "bumpver", specifier = ">=2025.1131" },
{ name = "coverage", specifier = ">=7.12.0" },
{ name = "coverage", specifier = ">=7.13.0" },
{ name = "djlint", specifier = ">=1.36.4" },
{ name = "flake8", specifier = ">=7.3.0" },
{ name = "flake8-bugbear", specifier = ">=25.11.29" },
{ name = "flake8-pyproject", specifier = ">=1.2.4" },
{ name = "isort", specifier = ">=7.0.0" },
{ name = "pytest", specifier = ">=9.0.1" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pytest-django", specifier = ">=4.11.1" },
{ name = "pytest-mock", specifier = ">=3.15.1" },