Compare commits
11 commits
main
...
264-servic
| Author | SHA1 | Date | |
|---|---|---|---|
| ad622ef14b | |||
| 83f60711bb | |||
| 7d42820026 | |||
| 2a63677539 | |||
| ef4f76b290 | |||
| c5b2c58305 | |||
| 29661aa7cd | |||
| 2bbd643cf9 | |||
| 5cee0194f5 | |||
| cce071397c | |||
| 9a3734192e |
28 changed files with 1832 additions and 332 deletions
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
@ -72,7 +72,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Determine image tag
|
- name: Determine image tag
|
||||||
id: determine-tag
|
id: determine-tag
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
@ -53,7 +53,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Deploy to OpenShift
|
- name: Deploy to OpenShift
|
||||||
uses: docker://quay.io/appuio/oc:v4.19
|
uses: docker://quay.io/appuio/oc:v4.19
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
@ -49,7 +49,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Deploy to OpenShift
|
- name: Deploy to OpenShift
|
||||||
uses: docker://quay.io/appuio/oc:v4.19
|
uses: docker://quay.io/appuio/oc:v4.19
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ jobs:
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
||||||
- name: Renovate
|
- name: Renovate
|
||||||
uses: https://github.com/renovatebot/github-action@v44.0.5
|
uses: https://github.com/renovatebot/github-action@v44.0.3
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ dependencies = [
|
||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"cryptography>=46.0.3",
|
"cryptography>=46.0.3",
|
||||||
"django==5.2.8",
|
"django==5.2.8",
|
||||||
"django-allauth>=65.13.1",
|
"django-allauth>=65.13.0",
|
||||||
"django-auditlog>=3.3.0",
|
"django-auditlog>=3.3.0",
|
||||||
"django-fernet-encrypted-fields>=0.3.1",
|
"django-fernet-encrypted-fields>=0.3.1",
|
||||||
"django-jsonform>=2.23.2",
|
"django-jsonform>=2.23.2",
|
||||||
|
|
@ -22,7 +22,7 @@ dependencies = [
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"rules>=3.5",
|
"rules>=3.5",
|
||||||
"sentry-sdk[django]>=2.46.0",
|
"sentry-sdk[django]>=2.45.0",
|
||||||
"urlman>=2.0.2",
|
"urlman>=2.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ dev = [
|
||||||
"coverage>=7.12.0",
|
"coverage>=7.12.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.10.21",
|
||||||
"flake8-pyproject>=1.2.3",
|
"flake8-pyproject>=1.2.3",
|
||||||
"isort>=7.0.0",
|
"isort>=7.0.0",
|
||||||
"pytest>=9.0.1",
|
"pytest>=9.0.1",
|
||||||
|
|
|
||||||
|
|
@ -239,9 +239,9 @@ The Servala Team"""
|
||||||
service_offering = ServiceOffering.objects.get(
|
service_offering = ServiceOffering.objects.get(
|
||||||
osb_plan_id=plan_id, service=service
|
osb_plan_id=plan_id, service=service
|
||||||
)
|
)
|
||||||
except Service.DoesNotExist: # pragma: no-cover
|
except Service.DoesNotExist:
|
||||||
return self._error(f"Unknown service_id: {service_id}")
|
return self._error(f"Unknown service_id: {service_id}")
|
||||||
except ServiceOffering.DoesNotExist: # pragma: no-cover
|
except ServiceOffering.DoesNotExist:
|
||||||
return self._error(
|
return self._error(
|
||||||
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
|
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
|
||||||
)
|
)
|
||||||
|
|
@ -284,7 +284,7 @@ The Servala Team"""
|
||||||
|
|
||||||
if service_instance:
|
if service_instance:
|
||||||
organization = service_instance.organization
|
organization = service_instance.organization
|
||||||
except Exception: # pragma: no cover
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
description_parts = [f"Action: {action}", f"Service: {service.name}"]
|
description_parts = [f"Action: {action}", f"Service: {service.name}"]
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
BillingEntity,
|
BillingEntity,
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
|
ComputePlan,
|
||||||
|
ComputePlanAssignment,
|
||||||
ControlPlane,
|
ControlPlane,
|
||||||
ControlPlaneCRD,
|
ControlPlaneCRD,
|
||||||
|
OdooObjectCache,
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationInvitation,
|
OrganizationInvitation,
|
||||||
OrganizationMembership,
|
OrganizationMembership,
|
||||||
|
|
@ -269,6 +272,19 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
_("Storage Plan"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"storage_plan_odoo_product_id",
|
||||||
|
"storage_plan_odoo_unit_id",
|
||||||
|
"storage_plan_price_per_gib",
|
||||||
|
),
|
||||||
|
"description": _(
|
||||||
|
"Storage plan configuration for this control plane (hardcoded per control plane)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_exclude(self, request, obj=None):
|
def get_exclude(self, request, obj=None):
|
||||||
|
|
@ -363,15 +379,21 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
@admin.register(ServiceInstance)
|
@admin.register(ServiceInstance)
|
||||||
class ServiceInstanceAdmin(admin.ModelAdmin):
|
class ServiceInstanceAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "organization", "context", "created_by")
|
list_display = (
|
||||||
list_filter = ("organization", "context")
|
"name",
|
||||||
|
"organization",
|
||||||
|
"context",
|
||||||
|
"compute_plan_assignment",
|
||||||
|
"created_by",
|
||||||
|
)
|
||||||
|
list_filter = ("organization", "context", "compute_plan_assignment")
|
||||||
search_fields = (
|
search_fields = (
|
||||||
"name",
|
"name",
|
||||||
"organization__name",
|
"organization__name",
|
||||||
"context__service_offering__service__name",
|
"context__service_offering__service__name",
|
||||||
)
|
)
|
||||||
readonly_fields = ("name", "organization", "context")
|
readonly_fields = ("name", "organization", "context")
|
||||||
autocomplete_fields = ("organization", "context")
|
autocomplete_fields = ("organization", "context", "compute_plan_assignment")
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None):
|
def get_readonly_fields(self, request, obj=None):
|
||||||
if obj: # If this is an edit (not a new instance)
|
if obj: # If this is an edit (not a new instance)
|
||||||
|
|
@ -390,6 +412,10 @@ class ServiceInstanceAdmin(admin.ModelAdmin):
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
_("Plan"),
|
||||||
|
{"fields": ("compute_plan_assignment",)},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -420,3 +446,138 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||||
schema=external_links_schema
|
schema=external_links_schema
|
||||||
)
|
)
|
||||||
return form
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
class ComputePlanAssignmentInline(admin.TabularInline):
|
||||||
|
model = ComputePlanAssignment
|
||||||
|
extra = 1
|
||||||
|
autocomplete_fields = ("control_plane_crd",)
|
||||||
|
fields = (
|
||||||
|
"compute_plan",
|
||||||
|
"control_plane_crd",
|
||||||
|
"sla",
|
||||||
|
"odoo_product_id",
|
||||||
|
"odoo_unit_id",
|
||||||
|
"price",
|
||||||
|
"unit",
|
||||||
|
"minimum_service_size",
|
||||||
|
"sort_order",
|
||||||
|
"is_active",
|
||||||
|
)
|
||||||
|
readonly_fields = ()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ComputePlan)
|
||||||
|
class ComputePlanAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"is_active",
|
||||||
|
"memory_limits",
|
||||||
|
"cpu_limits",
|
||||||
|
)
|
||||||
|
list_filter = ("is_active",)
|
||||||
|
search_fields = ("name", "description")
|
||||||
|
inlines = (ComputePlanAssignmentInline,)
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"is_active",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Resources"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"memory_requests",
|
||||||
|
"memory_limits",
|
||||||
|
"cpu_requests",
|
||||||
|
"cpu_limits",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ComputePlanAssignment)
|
||||||
|
class ComputePlanAssignmentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"compute_plan",
|
||||||
|
"control_plane_crd",
|
||||||
|
"sla",
|
||||||
|
"price",
|
||||||
|
"unit",
|
||||||
|
"sort_order",
|
||||||
|
"is_active",
|
||||||
|
)
|
||||||
|
list_filter = ("is_active", "sla", "control_plane_crd")
|
||||||
|
search_fields = (
|
||||||
|
"compute_plan__name",
|
||||||
|
"control_plane_crd__service_offering__service__name",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("compute_plan", "control_plane_crd")
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"compute_plan",
|
||||||
|
"control_plane_crd",
|
||||||
|
"sla",
|
||||||
|
"is_active",
|
||||||
|
"sort_order",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Odoo Integration"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"odoo_product_id",
|
||||||
|
"odoo_unit_id",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Pricing & Constraints"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"price",
|
||||||
|
"unit",
|
||||||
|
"minimum_service_size",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(OdooObjectCache)
|
||||||
|
class OdooObjectCacheAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("odoo_model", "odoo_id", "updated_at", "expires_at", "is_expired")
|
||||||
|
list_filter = ("odoo_model", "updated_at", "expires_at")
|
||||||
|
search_fields = ("odoo_model", "odoo_id")
|
||||||
|
readonly_fields = ("created_at", "updated_at")
|
||||||
|
actions = ["refresh_caches"]
|
||||||
|
|
||||||
|
def is_expired(self, obj):
|
||||||
|
return obj.is_expired()
|
||||||
|
|
||||||
|
is_expired.boolean = True
|
||||||
|
is_expired.short_description = _("Expired")
|
||||||
|
|
||||||
|
def refresh_caches(self, request, queryset):
|
||||||
|
"""Admin action to refresh selected Odoo caches."""
|
||||||
|
refreshed_count = 0
|
||||||
|
for cache_obj in queryset:
|
||||||
|
cache_obj.fetch_and_update()
|
||||||
|
refreshed_count += 1
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
_(f"Successfully refreshed {refreshed_count} cache(s)."),
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_caches.short_description = _("Refresh caches")
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
from contextlib import suppress
|
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.forms.models import ModelForm, ModelFormMetaclass
|
from django.forms.models import ModelForm, ModelFormMetaclass
|
||||||
|
|
||||||
from servala.core.crd.utils import deslugify
|
from servala.core.crd.utils import deslugify
|
||||||
from servala.core.models import ControlPlaneCRD
|
from servala.core.models import ControlPlaneCRD
|
||||||
from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon
|
from servala.frontend.forms.widgets import DynamicArrayWidget
|
||||||
|
|
||||||
# Fields that must be present in every form
|
# Fields that must be present in every form
|
||||||
MANDATORY_FIELDS = ["name"]
|
MANDATORY_FIELDS = ["name"]
|
||||||
|
|
@ -27,11 +25,6 @@ DEFAULT_FIELD_CONFIGS = {
|
||||||
"help_text": "Domain names for accessing this service",
|
"help_text": "Domain names for accessing this service",
|
||||||
"required": False,
|
"required": False,
|
||||||
},
|
},
|
||||||
"spec.parameters.size.disk": {
|
|
||||||
"type": "number",
|
|
||||||
"label": "Disk size",
|
|
||||||
"addon_text": "Gi",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,13 +69,17 @@ class CrdModelFormMixin(FormGeneratorMixin):
|
||||||
"spec.parameters.network.serviceType",
|
"spec.parameters.network.serviceType",
|
||||||
"spec.parameters.scheduling",
|
"spec.parameters.scheduling",
|
||||||
"spec.parameters.security",
|
"spec.parameters.security",
|
||||||
|
"spec.publishConnectionDetailsTo",
|
||||||
|
"spec.resourceRef",
|
||||||
|
"spec.writeConnectionSecretToRef",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fields populated from compute plan
|
||||||
|
READONLY_FIELDS = [
|
||||||
"spec.parameters.size.cpu",
|
"spec.parameters.size.cpu",
|
||||||
"spec.parameters.size.memory",
|
"spec.parameters.size.memory",
|
||||||
"spec.parameters.size.requests.cpu",
|
"spec.parameters.size.requests.cpu",
|
||||||
"spec.parameters.size.requests.memory",
|
"spec.parameters.size.requests.memory",
|
||||||
"spec.publishConnectionDetailsTo",
|
|
||||||
"spec.resourceRef",
|
|
||||||
"spec.writeConnectionSecretToRef",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
@ -95,6 +92,15 @@ class CrdModelFormMixin(FormGeneratorMixin):
|
||||||
):
|
):
|
||||||
field.widget = forms.HiddenInput()
|
field.widget = forms.HiddenInput()
|
||||||
field.required = False
|
field.required = False
|
||||||
|
elif name in self.READONLY_FIELDS or any(
|
||||||
|
name.startswith(f) for f in self.READONLY_FIELDS
|
||||||
|
):
|
||||||
|
field.disabled = True
|
||||||
|
field.required = False
|
||||||
|
field.widget.attrs["readonly"] = "readonly"
|
||||||
|
field.widget.attrs["class"] = (
|
||||||
|
field.widget.attrs.get("class", "") + " form-control-plaintext"
|
||||||
|
)
|
||||||
|
|
||||||
def strip_title(self, field_name, label):
|
def strip_title(self, field_name, label):
|
||||||
field = self.fields[field_name]
|
field = self.fields[field_name]
|
||||||
|
|
@ -342,19 +348,6 @@ class CustomFormMixin(FormGeneratorMixin):
|
||||||
if field_type == "number":
|
if field_type == "number":
|
||||||
min_val = field_config.get("min_value")
|
min_val = field_config.get("min_value")
|
||||||
max_val = field_config.get("max_value")
|
max_val = field_config.get("max_value")
|
||||||
unit = field_config.get("addon_text")
|
|
||||||
|
|
||||||
if unit:
|
|
||||||
field.widget = NumberInputWithAddon(addon_text=unit)
|
|
||||||
field.addon_text = unit
|
|
||||||
value = self.initial.get(field_name)
|
|
||||||
if value and isinstance(value, str) and value.endswith(unit):
|
|
||||||
numeric_value = value[: -len(unit)]
|
|
||||||
with suppress(ValueError):
|
|
||||||
if "." in numeric_value:
|
|
||||||
self.initial[field_name] = float(numeric_value)
|
|
||||||
else:
|
|
||||||
self.initial[field_name] = int(numeric_value)
|
|
||||||
|
|
||||||
validators = []
|
validators = []
|
||||||
if min_val is not None:
|
if min_val is not None:
|
||||||
|
|
@ -426,11 +419,6 @@ class CustomFormMixin(FormGeneratorMixin):
|
||||||
|
|
||||||
mapping = field_name
|
mapping = field_name
|
||||||
value = self.cleaned_data.get(field_name)
|
value = self.cleaned_data.get(field_name)
|
||||||
field = self.fields[field_name]
|
|
||||||
|
|
||||||
if addon_text := getattr(field, "addon_text", None):
|
|
||||||
value = f"{value}{addon_text}"
|
|
||||||
|
|
||||||
parts = mapping.split(".")
|
parts = mapping.split(".")
|
||||||
current = nested
|
current = nested
|
||||||
for part in parts[:-1]:
|
for part in parts[:-1]:
|
||||||
|
|
|
||||||
309
src/servala/core/migrations/0016_computeplan_and_more.py
Normal file
309
src/servala/core/migrations/0016_computeplan_and_more.py
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
# Generated by Django 5.2.8 on 2025-12-02 09:51
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import rules.contrib.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0015_add_hide_expert_mode_to_service_definition"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ComputePlan",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(blank=True, verbose_name="Description"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this plan is available for selection",
|
||||||
|
verbose_name="Is active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"memory_requests",
|
||||||
|
models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="Memory requests",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"memory_limits",
|
||||||
|
models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="Memory limits",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cpu_requests",
|
||||||
|
models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="CPU requests",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"cpu_limits",
|
||||||
|
models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="CPU limits",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Compute Plan",
|
||||||
|
"verbose_name_plural": "Compute Plans",
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="controlplane",
|
||||||
|
name="storage_plan_odoo_product_id",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="ID of the storage product in Odoo",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Storage plan Odoo product ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="controlplane",
|
||||||
|
name="storage_plan_odoo_unit_id",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True,
|
||||||
|
help_text="ID of the unit of measure in Odoo (uom.uom)",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Storage plan Odoo unit ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="controlplane",
|
||||||
|
name="storage_plan_price_per_gib",
|
||||||
|
field=models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
help_text="Price per GiB of storage",
|
||||||
|
max_digits=10,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Storage plan price per GiB",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ComputePlanAssignment",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sla",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("besteffort", "Best Effort"),
|
||||||
|
("guaranteed", "Guaranteed Availability"),
|
||||||
|
],
|
||||||
|
help_text="Service Level Agreement",
|
||||||
|
max_length=20,
|
||||||
|
verbose_name="SLA",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"odoo_product_id",
|
||||||
|
models.IntegerField(
|
||||||
|
help_text="ID of the product in Odoo (product.product or product.template)",
|
||||||
|
verbose_name="Odoo product ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"odoo_unit_id",
|
||||||
|
models.IntegerField(
|
||||||
|
help_text="ID of the unit of measure in Odoo (uom.uom)",
|
||||||
|
verbose_name="Odoo unit ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"price",
|
||||||
|
models.DecimalField(
|
||||||
|
decimal_places=2,
|
||||||
|
help_text="Price per unit",
|
||||||
|
max_digits=10,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(Decimal("0.00"))
|
||||||
|
],
|
||||||
|
verbose_name="Price",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"minimum_service_size",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
help_text="Minimum value for spec.parameters.instances (Guaranteed Availability may require multiple instances)",
|
||||||
|
validators=[django.core.validators.MinValueValidator(1)],
|
||||||
|
verbose_name="Minimum service size",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sort_order",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Order in which plans are displayed to users",
|
||||||
|
verbose_name="Sort order",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"is_active",
|
||||||
|
models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this plan is available for this CRD",
|
||||||
|
verbose_name="Is active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"compute_plan",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="assignments",
|
||||||
|
to="core.computeplan",
|
||||||
|
verbose_name="Compute plan",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"control_plane_crd",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="compute_plan_assignments",
|
||||||
|
to="core.controlplanecrd",
|
||||||
|
verbose_name="Control plane CRD",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Compute Plan Assignment",
|
||||||
|
"verbose_name_plural": "Compute Plan Assignments",
|
||||||
|
"ordering": ["sort_order", "compute_plan__name", "sla"],
|
||||||
|
"unique_together": {("compute_plan", "control_plane_crd", "sla")},
|
||||||
|
},
|
||||||
|
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="serviceinstance",
|
||||||
|
name="compute_plan_assignment",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Compute plan with SLA for this instance",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="instances",
|
||||||
|
to="core.computeplanassignment",
|
||||||
|
verbose_name="Compute plan assignment",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="OdooObjectCache",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_at",
|
||||||
|
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"odoo_model",
|
||||||
|
models.CharField(
|
||||||
|
help_text="Odoo model name: 'product.product', 'product.template', 'uom.uom', etc.",
|
||||||
|
max_length=100,
|
||||||
|
verbose_name="Odoo model",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"odoo_id",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
help_text="ID in the Odoo model", verbose_name="Odoo ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"data",
|
||||||
|
models.JSONField(
|
||||||
|
help_text="Cached Odoo data including price, reporting_product_id, etc.",
|
||||||
|
verbose_name="Cached data",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"expires_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
blank=True,
|
||||||
|
help_text="When cache should be refreshed (null = never expires)",
|
||||||
|
null=True,
|
||||||
|
verbose_name="Expires at",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Odoo Object Cache",
|
||||||
|
"verbose_name_plural": "Odoo Object Caches",
|
||||||
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["odoo_model", "odoo_id"],
|
||||||
|
name="core_odooob_odoo_mo_51e258_idx",
|
||||||
|
),
|
||||||
|
models.Index(
|
||||||
|
fields=["expires_at"], name="core_odooob_expires_8fc00b_idx"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"unique_together": {("odoo_model", "odoo_id")},
|
||||||
|
},
|
||||||
|
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Generated by Django 5.2.8 on 2025-12-02 10:35
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0016_computeplan_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="computeplanassignment",
|
||||||
|
name="unit",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("hour", "Hour"),
|
||||||
|
("day", "Day"),
|
||||||
|
("month", "Month (30 days)"),
|
||||||
|
("year", "Year"),
|
||||||
|
],
|
||||||
|
default="hour",
|
||||||
|
help_text="Unit for the price (e.g., price per hour)",
|
||||||
|
max_length=10,
|
||||||
|
verbose_name="Billing unit",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="computeplanassignment",
|
||||||
|
name="odoo_product_id",
|
||||||
|
field=models.CharField(
|
||||||
|
help_text="Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')",
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="Odoo product ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="computeplanassignment",
|
||||||
|
name="odoo_unit_id",
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name="Odoo unit ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="controlplane",
|
||||||
|
name="storage_plan_odoo_product_id",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Storage product ID in Odoo",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Storage plan Odoo product ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="controlplane",
|
||||||
|
name="storage_plan_odoo_unit_id",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Unit of measure ID in Odoo",
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Storage plan Odoo unit ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from .odoo_cache import OdooObjectCache
|
||||||
from .organization import (
|
from .organization import (
|
||||||
BillingEntity,
|
BillingEntity,
|
||||||
Organization,
|
Organization,
|
||||||
|
|
@ -6,6 +7,10 @@ from .organization import (
|
||||||
OrganizationOrigin,
|
OrganizationOrigin,
|
||||||
OrganizationRole,
|
OrganizationRole,
|
||||||
)
|
)
|
||||||
|
from .plan import (
|
||||||
|
ComputePlan,
|
||||||
|
ComputePlanAssignment,
|
||||||
|
)
|
||||||
from .service import (
|
from .service import (
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
ControlPlane,
|
ControlPlane,
|
||||||
|
|
@ -21,8 +26,11 @@ from .user import User
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BillingEntity",
|
"BillingEntity",
|
||||||
"CloudProvider",
|
"CloudProvider",
|
||||||
|
"ComputePlan",
|
||||||
|
"ComputePlanAssignment",
|
||||||
"ControlPlane",
|
"ControlPlane",
|
||||||
"ControlPlaneCRD",
|
"ControlPlaneCRD",
|
||||||
|
"OdooObjectCache",
|
||||||
"Organization",
|
"Organization",
|
||||||
"OrganizationInvitation",
|
"OrganizationInvitation",
|
||||||
"OrganizationMembership",
|
"OrganizationMembership",
|
||||||
|
|
@ -30,8 +38,8 @@ __all__ = [
|
||||||
"OrganizationRole",
|
"OrganizationRole",
|
||||||
"Service",
|
"Service",
|
||||||
"ServiceCategory",
|
"ServiceCategory",
|
||||||
"ServiceInstance",
|
|
||||||
"ServiceDefinition",
|
"ServiceDefinition",
|
||||||
|
"ServiceInstance",
|
||||||
"ServiceOffering",
|
"ServiceOffering",
|
||||||
"User",
|
"User",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
124
src/servala/core/models/odoo_cache.py
Normal file
124
src/servala/core/models/odoo_cache.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from servala.core.models.mixins import ServalaModelMixin
|
||||||
|
|
||||||
|
|
||||||
|
class OdooObjectCache(ServalaModelMixin):
|
||||||
|
"""
|
||||||
|
Generic cache for Odoo API responses.
|
||||||
|
|
||||||
|
Caches data from various Odoo models (product.product, product.template, uom.uom, etc.)
|
||||||
|
to reduce API calls and improve performance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
odoo_model = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
verbose_name=_("Odoo model"),
|
||||||
|
help_text=_(
|
||||||
|
"Odoo model name: 'product.product', 'product.template', 'uom.uom', etc."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
odoo_id = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("Odoo ID"),
|
||||||
|
help_text=_("ID in the Odoo model"),
|
||||||
|
)
|
||||||
|
data = models.JSONField(
|
||||||
|
verbose_name=_("Cached data"),
|
||||||
|
help_text=_("Cached Odoo data including price, reporting_product_id, etc."),
|
||||||
|
)
|
||||||
|
expires_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Expires at"),
|
||||||
|
help_text=_("When cache should be refreshed (null = never expires)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Odoo Object Cache")
|
||||||
|
verbose_name_plural = _("Odoo Object Caches")
|
||||||
|
unique_together = [["odoo_model", "odoo_id"]]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["odoo_model", "odoo_id"]),
|
||||||
|
models.Index(fields=["expires_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.odoo_model}({self.odoo_id})"
|
||||||
|
|
||||||
|
def is_expired(self):
|
||||||
|
"""Check if cache needs refresh."""
|
||||||
|
if self.expires_at is None:
|
||||||
|
return False
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
return timezone.now() > self.expires_at
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_or_fetch(cls, odoo_model, odoo_id, ttl_hours=24):
|
||||||
|
"""
|
||||||
|
Get cached data or fetch from Odoo if expired/missing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
odoo_model: Odoo model name (e.g., 'product.product')
|
||||||
|
odoo_id: ID in the Odoo model
|
||||||
|
ttl_hours: Time-to-live in hours for the cache
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OdooObjectCache instance with fresh data
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache_obj = cls.objects.get(odoo_model=odoo_model, odoo_id=odoo_id)
|
||||||
|
if not cache_obj.is_expired():
|
||||||
|
return cache_obj
|
||||||
|
# Cache exists but expired, refresh it
|
||||||
|
cache_obj.fetch_and_update(ttl_hours=ttl_hours)
|
||||||
|
return cache_obj
|
||||||
|
except cls.DoesNotExist:
|
||||||
|
# Create new cache entry
|
||||||
|
cache_obj = cls.objects.create(
|
||||||
|
odoo_model=odoo_model,
|
||||||
|
odoo_id=odoo_id,
|
||||||
|
data={},
|
||||||
|
expires_at=(
|
||||||
|
timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cache_obj.fetch_and_update(ttl_hours=ttl_hours)
|
||||||
|
return cache_obj
|
||||||
|
|
||||||
|
def fetch_and_update(self, ttl_hours=24):
|
||||||
|
"""
|
||||||
|
Fetch latest data from Odoo and update cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttl_hours: Time-to-live in hours for the cache
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from servala.core.odoo import CLIENT
|
||||||
|
|
||||||
|
# Fetch data from Odoo
|
||||||
|
results = CLIENT.search_read(
|
||||||
|
self.odoo_model,
|
||||||
|
[[("id", "=", self.odoo_id)]],
|
||||||
|
fields=None, # Fetch all fields
|
||||||
|
)
|
||||||
|
|
||||||
|
if results:
|
||||||
|
self.data = results[0]
|
||||||
|
self.expires_at = (
|
||||||
|
timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None
|
||||||
|
)
|
||||||
|
self.save(update_fields=["data", "expires_at", "updated_at"])
|
||||||
|
else:
|
||||||
|
# Object not found in Odoo, mark as expired immediately
|
||||||
|
self.data = {}
|
||||||
|
self.expires_at = timezone.now()
|
||||||
|
self.save(update_fields=["data", "expires_at", "updated_at"])
|
||||||
165
src/servala/core/models/plan.py
Normal file
165
src/servala/core/models/plan.py
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from auditlog.registry import auditlog
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from servala.core.models.mixins import ServalaModelMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ComputePlan(ServalaModelMixin):
|
||||||
|
"""
|
||||||
|
Compute resource plans for service instances.
|
||||||
|
|
||||||
|
Defines CPU and memory allocations. Pricing and service level are configured
|
||||||
|
per assignment to a ControlPlaneCRD.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
verbose_name=_("Name"),
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Description"),
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name=_("Is active"),
|
||||||
|
help_text=_("Whether this plan is available for selection"),
|
||||||
|
)
|
||||||
|
|
||||||
|
memory_requests = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
verbose_name=_("Memory requests"),
|
||||||
|
)
|
||||||
|
memory_limits = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
verbose_name=_("Memory limits"),
|
||||||
|
)
|
||||||
|
cpu_requests = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
verbose_name=_("CPU requests"),
|
||||||
|
)
|
||||||
|
cpu_limits = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
verbose_name=_("CPU limits"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Compute Plan")
|
||||||
|
verbose_name_plural = _("Compute Plans")
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_resource_summary(self):
|
||||||
|
return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM"
|
||||||
|
|
||||||
|
|
||||||
|
class ComputePlanAssignment(ServalaModelMixin):
|
||||||
|
"""
|
||||||
|
Links compute plans to control plane CRDs with pricing and service level.
|
||||||
|
|
||||||
|
A product in Odoo represents a service with a specific compute plan, control plane,
|
||||||
|
and SLA. This model stores that correlation. The same compute plan can be assigned
|
||||||
|
multiple times to the same CRD with different SLAs and pricing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SLA_CHOICES = [
|
||||||
|
("besteffort", _("Best Effort")),
|
||||||
|
("guaranteed", _("Guaranteed Availability")),
|
||||||
|
]
|
||||||
|
|
||||||
|
compute_plan = models.ForeignKey(
|
||||||
|
ComputePlan,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="assignments",
|
||||||
|
verbose_name=_("Compute plan"),
|
||||||
|
)
|
||||||
|
control_plane_crd = models.ForeignKey(
|
||||||
|
"ControlPlaneCRD",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="compute_plan_assignments",
|
||||||
|
verbose_name=_("Control plane CRD"),
|
||||||
|
)
|
||||||
|
sla = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=SLA_CHOICES,
|
||||||
|
verbose_name=_("SLA"),
|
||||||
|
help_text=_("Service Level Agreement"),
|
||||||
|
)
|
||||||
|
odoo_product_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("Odoo product ID"),
|
||||||
|
help_text=_(
|
||||||
|
"Product ID in Odoo (e.g., 'openshift-exoscale-workervcpu-standard')"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
odoo_unit_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_("Odoo unit ID"),
|
||||||
|
)
|
||||||
|
price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
validators=[MinValueValidator(Decimal("0.00"))],
|
||||||
|
verbose_name=_("Price"),
|
||||||
|
help_text=_("Price per unit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
BILLING_UNIT_CHOICES = [
|
||||||
|
("hour", _("Hour")),
|
||||||
|
("day", _("Day")),
|
||||||
|
("month", _("Month (30 days / 720 hours)")),
|
||||||
|
("year", _("Year")),
|
||||||
|
]
|
||||||
|
unit = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=BILLING_UNIT_CHOICES,
|
||||||
|
default="hour",
|
||||||
|
verbose_name=_("Billing unit"),
|
||||||
|
help_text=_("Unit for the price (e.g., price per hour)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
minimum_service_size = models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
validators=[MinValueValidator(1)],
|
||||||
|
verbose_name=_("Minimum service size"),
|
||||||
|
help_text=_(
|
||||||
|
"Minimum value for spec.parameters.instances "
|
||||||
|
"(Guaranteed Availability may require multiple instances)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sort_order = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
verbose_name=_("Sort order"),
|
||||||
|
help_text=_("Order in which plans are displayed to users"),
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name=_("Is active"),
|
||||||
|
help_text=_("Whether this plan is available for this CRD"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Compute Plan Assignment")
|
||||||
|
verbose_name_plural = _("Compute Plan Assignments")
|
||||||
|
unique_together = [["compute_plan", "control_plane_crd", "sla"]]
|
||||||
|
ordering = ["sort_order", "compute_plan__name", "sla"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}"
|
||||||
|
|
||||||
|
def get_odoo_reporting_product_id(self):
|
||||||
|
# TODO: Implement Odoo cache lookup when OdooObjectCache is integrated
|
||||||
|
# For now, just return the product ID
|
||||||
|
return self.odoo_product_id
|
||||||
|
|
||||||
|
|
||||||
|
auditlog.register(ComputePlan, exclude_fields=["updated_at"], serialize_data=True)
|
||||||
|
auditlog.register(
|
||||||
|
ComputePlanAssignment, exclude_fields=["updated_at"], serialize_data=True
|
||||||
|
)
|
||||||
|
|
@ -170,6 +170,29 @@ class ControlPlane(ServalaModelMixin, models.Model):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
storage_plan_odoo_product_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Storage plan Odoo product ID"),
|
||||||
|
help_text=_("Storage product ID in Odoo"),
|
||||||
|
)
|
||||||
|
storage_plan_odoo_unit_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Storage plan Odoo unit ID"),
|
||||||
|
help_text=_("Unit of measure ID in Odoo"),
|
||||||
|
)
|
||||||
|
storage_plan_price_per_gib = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Storage plan price per GiB"),
|
||||||
|
help_text=_("Price per GiB of storage"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Control plane")
|
verbose_name = _("Control plane")
|
||||||
verbose_name_plural = _("Control planes")
|
verbose_name_plural = _("Control planes")
|
||||||
|
|
@ -613,6 +636,15 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
related_name="service_instances",
|
related_name="service_instances",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
|
compute_plan_assignment = models.ForeignKey(
|
||||||
|
to="core.ComputePlanAssignment",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="instances",
|
||||||
|
verbose_name=_("Compute plan assignment"),
|
||||||
|
help_text=_("Compute plan with SLA for this instance"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Service instance")
|
verbose_name = _("Service instance")
|
||||||
|
|
@ -654,6 +686,60 @@ 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 _apply_compute_plan_to_spec(spec_data, compute_plan_assignment):
|
||||||
|
"""
|
||||||
|
Apply compute plan resource allocations and SLA to spec.
|
||||||
|
"""
|
||||||
|
if not compute_plan_assignment:
|
||||||
|
return spec_data
|
||||||
|
|
||||||
|
compute_plan = compute_plan_assignment.compute_plan
|
||||||
|
|
||||||
|
if "parameters" not in spec_data:
|
||||||
|
spec_data["parameters"] = {}
|
||||||
|
if "size" not in spec_data["parameters"]:
|
||||||
|
spec_data["parameters"]["size"] = {}
|
||||||
|
if "requests" not in spec_data["parameters"]["size"]:
|
||||||
|
spec_data["parameters"]["size"]["requests"] = {}
|
||||||
|
if "service" not in spec_data["parameters"]:
|
||||||
|
spec_data["parameters"]["service"] = {}
|
||||||
|
|
||||||
|
spec_data["parameters"]["size"]["memory"] = compute_plan.memory_limits
|
||||||
|
spec_data["parameters"]["size"]["cpu"] = compute_plan.cpu_limits
|
||||||
|
spec_data["parameters"]["size"]["requests"][
|
||||||
|
"memory"
|
||||||
|
] = compute_plan.memory_requests
|
||||||
|
spec_data["parameters"]["size"]["requests"]["cpu"] = compute_plan.cpu_requests
|
||||||
|
spec_data["parameters"]["service"]["serviceLevel"] = compute_plan_assignment.sla
|
||||||
|
return spec_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_billing_annotations(compute_plan_assignment, control_plane):
|
||||||
|
"""
|
||||||
|
Build Kubernetes annotations for billing integration.
|
||||||
|
"""
|
||||||
|
annotations = {}
|
||||||
|
|
||||||
|
if compute_plan_assignment:
|
||||||
|
annotations["servala.com/erp_product_id_resource"] = str(
|
||||||
|
compute_plan_assignment.odoo_product_id
|
||||||
|
)
|
||||||
|
annotations["servala.com/erp_unit_id_resource"] = str(
|
||||||
|
compute_plan_assignment.odoo_unit_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if control_plane.storage_plan_odoo_product_id:
|
||||||
|
annotations["servala.com/erp_product_id_storage"] = str(
|
||||||
|
control_plane.storage_plan_odoo_product_id
|
||||||
|
)
|
||||||
|
if control_plane.storage_plan_odoo_unit_id:
|
||||||
|
annotations["servala.com/erp_unit_id_storage"] = str(
|
||||||
|
control_plane.storage_plan_odoo_unit_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return annotations
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _format_kubernetes_error(cls, error_message):
|
def _format_kubernetes_error(cls, error_message):
|
||||||
if not error_message:
|
if not error_message:
|
||||||
|
|
@ -708,7 +794,15 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_instance(cls, name, organization, context, created_by, spec_data):
|
def create_instance(
|
||||||
|
cls,
|
||||||
|
name,
|
||||||
|
organization,
|
||||||
|
context,
|
||||||
|
created_by,
|
||||||
|
spec_data,
|
||||||
|
compute_plan_assignment=None,
|
||||||
|
):
|
||||||
# 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:
|
||||||
|
|
@ -717,6 +811,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
organization=organization,
|
organization=organization,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
context=context,
|
context=context,
|
||||||
|
compute_plan_assignment=compute_plan_assignment,
|
||||||
)
|
)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
message = _(
|
message = _(
|
||||||
|
|
@ -727,6 +822,11 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
try:
|
try:
|
||||||
spec_data = cls._prepare_spec_data(spec_data)
|
spec_data = cls._prepare_spec_data(spec_data)
|
||||||
|
|
||||||
|
if compute_plan_assignment:
|
||||||
|
spec_data = cls._apply_compute_plan_to_spec(
|
||||||
|
spec_data, compute_plan_assignment
|
||||||
|
)
|
||||||
|
|
||||||
if "writeConnectionSecretToRef" not in spec_data:
|
if "writeConnectionSecretToRef" not in spec_data:
|
||||||
spec_data["writeConnectionSecretToRef"] = {}
|
spec_data["writeConnectionSecretToRef"] = {}
|
||||||
|
|
||||||
|
|
@ -744,6 +844,13 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
},
|
},
|
||||||
"spec": spec_data,
|
"spec": spec_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
annotations = cls._build_billing_annotations(
|
||||||
|
compute_plan_assignment, context.control_plane
|
||||||
|
)
|
||||||
|
if annotations:
|
||||||
|
create_data["metadata"]["annotations"] = annotations
|
||||||
|
|
||||||
if label := context.control_plane.required_label:
|
if label := context.control_plane.required_label:
|
||||||
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
|
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
|
||||||
api_instance = context.control_plane.custom_objects_api
|
api_instance = context.control_plane.custom_objects_api
|
||||||
|
|
@ -781,12 +888,23 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
raise ValidationError(organization.add_support_message(message))
|
raise ValidationError(organization.add_support_message(message))
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
def update_spec(self, spec_data, updated_by):
|
def update_spec(self, spec_data, updated_by, compute_plan_assignment=None):
|
||||||
try:
|
try:
|
||||||
spec_data = self._prepare_spec_data(spec_data)
|
spec_data = self._prepare_spec_data(spec_data)
|
||||||
|
|
||||||
|
plan_to_use = compute_plan_assignment or self.compute_plan_assignment
|
||||||
|
if plan_to_use:
|
||||||
|
spec_data = self._apply_compute_plan_to_spec(spec_data, plan_to_use)
|
||||||
|
|
||||||
api_instance = self.context.control_plane.custom_objects_api
|
api_instance = self.context.control_plane.custom_objects_api
|
||||||
patch_body = {"spec": spec_data}
|
patch_body = {"spec": spec_data}
|
||||||
|
|
||||||
|
annotations = self._build_billing_annotations(
|
||||||
|
plan_to_use, self.context.control_plane
|
||||||
|
)
|
||||||
|
if annotations:
|
||||||
|
patch_body["metadata"] = {"annotations": annotations}
|
||||||
|
|
||||||
api_instance.patch_namespaced_custom_object(
|
api_instance.patch_namespaced_custom_object(
|
||||||
group=self.context.group,
|
group=self.context.group,
|
||||||
version=self.context.version,
|
version=self.context.version,
|
||||||
|
|
@ -796,7 +914,14 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
body=patch_body,
|
body=patch_body,
|
||||||
)
|
)
|
||||||
self._clear_kubernetes_caches()
|
self._clear_kubernetes_caches()
|
||||||
self.save() # Updates updated_at timestamp
|
|
||||||
|
if (
|
||||||
|
compute_plan_assignment
|
||||||
|
and compute_plan_assignment != self.compute_plan_assignment
|
||||||
|
):
|
||||||
|
self.compute_plan_assignment = compute_plan_assignment
|
||||||
|
# Saving to update updated_at timestamp even if nothing was visibly changed
|
||||||
|
self.save()
|
||||||
except ApiException as e:
|
except ApiException as e:
|
||||||
if e.status == 404:
|
if e.status == 404:
|
||||||
message = _(
|
message = _(
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
CloudProvider,
|
CloudProvider,
|
||||||
|
ComputePlanAssignment,
|
||||||
ControlPlane,
|
ControlPlane,
|
||||||
Service,
|
Service,
|
||||||
ServiceCategory,
|
ServiceCategory,
|
||||||
|
|
@ -56,6 +57,34 @@ class ControlPlaneSelectForm(forms.Form):
|
||||||
self.fields["control_plane"].initial = planes.first()
|
self.fields["control_plane"].initial = planes.first()
|
||||||
|
|
||||||
|
|
||||||
|
class ComputePlanSelectionForm(forms.Form):
|
||||||
|
compute_plan_assignment = forms.ModelChoiceField(
|
||||||
|
queryset=ComputePlanAssignment.objects.none(),
|
||||||
|
widget=forms.RadioSelect,
|
||||||
|
required=True,
|
||||||
|
label=_("Compute Plan"),
|
||||||
|
empty_label=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, control_plane_crd=None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if control_plane_crd:
|
||||||
|
self.fields["compute_plan_assignment"].queryset = (
|
||||||
|
ComputePlanAssignment.objects.filter(
|
||||||
|
control_plane_crd=control_plane_crd, is_active=True
|
||||||
|
)
|
||||||
|
.select_related("compute_plan")
|
||||||
|
.order_by("sort_order", "compute_plan__name", "sla")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not self.is_bound
|
||||||
|
and self.fields["compute_plan_assignment"].queryset.exists()
|
||||||
|
):
|
||||||
|
self.fields["compute_plan_assignment"].initial = self.fields[
|
||||||
|
"compute_plan_assignment"
|
||||||
|
].queryset.first()
|
||||||
|
|
||||||
|
|
||||||
class ServiceInstanceFilterForm(forms.Form):
|
class ServiceInstanceFilterForm(forms.Form):
|
||||||
name = forms.CharField(required=False, label=_("Name"))
|
name = forms.CharField(required=False, label=_("Name"))
|
||||||
service = forms.ModelChoiceField(
|
service = forms.ModelChoiceField(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.forms.widgets import NumberInput
|
|
||||||
|
|
||||||
|
|
||||||
class DynamicArrayWidget(forms.Widget):
|
class DynamicArrayWidget(forms.Widget):
|
||||||
|
|
@ -217,21 +216,3 @@ class DynamicArrayField(forms.JSONField):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Item {i + 1} must be one of: {', '.join(enum_values)}"
|
f"Item {i + 1} must be one of: {', '.join(enum_values)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NumberInputWithAddon(NumberInput):
|
|
||||||
"""
|
|
||||||
Widget for number input fields with a suffix add-on (e.g., "Gi", "MB").
|
|
||||||
Renders as a Bootstrap input-group with the suffix displayed as an add-on.
|
|
||||||
"""
|
|
||||||
|
|
||||||
template_name = "frontend/forms/number_input_with_addon.html"
|
|
||||||
|
|
||||||
def __init__(self, addon_text="", attrs=None):
|
|
||||||
super().__init__(attrs)
|
|
||||||
self.addon_text = addon_text
|
|
||||||
|
|
||||||
def get_context(self, name, value, attrs):
|
|
||||||
context = super().get_context(name, value, attrs)
|
|
||||||
context["widget"]["addon_text"] = self.addon_text
|
|
||||||
return context
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
<div class="input-group">
|
|
||||||
<input type="{{ widget.type }}"
|
|
||||||
name="{{ widget.name }}"
|
|
||||||
{% if widget.value != None %}value="{{ widget.value }}"{% endif %}
|
|
||||||
{% if widget.attrs.id %}id="{{ widget.attrs.id }}"{% endif %}
|
|
||||||
{% for name, value in widget.attrs.items %} {% if value is not False and name != "id" %} {{ name }}{% if value is not True %}="{{ value }}"{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
class="form-control{% if widget.attrs.class %} {{ widget.attrs.class }}{% endif %}" />
|
|
||||||
<span class="input-group-text">{{ widget.addon_text }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
@ -51,6 +51,29 @@
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{{ instance.context.control_plane.name }}
|
{{ instance.context.control_plane.name }}
|
||||||
</dd>
|
</dd>
|
||||||
|
{% if compute_plan_assignment %}
|
||||||
|
<dt class="col-sm-4">{% translate "Compute Plan" %}</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
{{ compute_plan_assignment.compute_plan.name }}
|
||||||
|
<span class="badge bg-{% if compute_plan_assignment.sla == 'guaranteed' %}success{% else %}secondary{% endif %} ms-1">
|
||||||
|
{{ compute_plan_assignment.get_sla_display }}
|
||||||
|
</span>
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
<i class="bi bi-cpu"></i> {{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<i class="bi bi-memory"></i> {{ compute_plan_assignment.compute_plan.memory_limits }} RAM
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<strong>CHF {{ compute_plan_assignment.price }}</strong>/{{ compute_plan_assignment.get_unit_display }}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if storage_plan %}
|
||||||
|
<dt class="col-sm-4">{% translate "Storage Plan" %}</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<strong>CHF {{ storage_plan.price_per_gib }}</strong> per GiB
|
||||||
|
<div class="text-muted small">{% translate "Billed separately based on disk usage" %}</div>
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
<dt class="col-sm-4">{% translate "Created By" %}</dt>
|
<dt class="col-sm-4">{% translate "Created By" %}</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{{ instance.created_by|default:"-" }}
|
{{ instance.created_by|default:"-" }}
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,42 @@
|
||||||
{% endpartialdef %}
|
{% endpartialdef %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="card">
|
<form class="form form-vertical crd-form" method="post" novalidate>
|
||||||
{% if not form and not custom_form %}
|
{% csrf_token %}
|
||||||
<div class="alert alert-warning" role="alert">
|
{% if plan_form.errors or form.errors or custom_form.errors %}
|
||||||
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
{% include "frontend/forms/errors.html" with form=plan_form %}
|
||||||
|
{% if form %}
|
||||||
|
{% include "frontend/forms/errors.html" with form=form %}
|
||||||
|
{% endif %}
|
||||||
|
{% if custom_form %}
|
||||||
|
{% include "frontend/forms/errors.html" with form=custom_form %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
|
||||||
<div id="service-form">{% partial service-form %}</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
<!-- Compute Plan Selection -->
|
||||||
|
{% if plan_form %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% translate "Compute Plan" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Service Form -->
|
||||||
|
<div class="card">
|
||||||
|
{% if not form and not custom_form %}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div id="service-form">{% partial service-form %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
||||||
|
|
@ -124,12 +124,61 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Service Form (unchanged) -->
|
<form class="form form-vertical crd-form" method="post" novalidate>
|
||||||
<div class="row mt-3">
|
{% csrf_token %}
|
||||||
<div class="col-12">
|
{% if plan_form.errors or service_form.errors or custom_service_form.errors %}
|
||||||
<div id="service-form">{% partial service-form %}</div>
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
{% include "frontend/forms/errors.html" with form=plan_form %}
|
||||||
|
{% if service_form %}
|
||||||
|
{% include "frontend/forms/errors.html" with form=service_form %}
|
||||||
|
{% endif %}
|
||||||
|
{% if custom_service_form %}
|
||||||
|
{% include "frontend/forms/errors.html" with form=custom_service_form %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Compute Plan Selection -->
|
||||||
|
{% if context_object %}
|
||||||
|
{% if not has_available_plans %}
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<div>
|
||||||
|
<strong>{% translate "No Compute Plans Available" %}</strong>
|
||||||
|
<p class="mb-0">
|
||||||
|
{% translate "Service instances cannot be created for this offering because no billing plans are configured. Please contact support." %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">{% translate "Select Compute Plan" %}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<!-- Service Form -->
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<fieldset {% if context_object and not has_available_plans %}disabled{% endif %}>
|
||||||
|
<div id="service-form">{% partial service-form %}</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
|
|
||||||
178
src/servala/frontend/templates/includes/plan_selection.html
Normal file
178
src/servala/frontend/templates/includes/plan_selection.html
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
{% load i18n %}
|
||||||
|
<style>
|
||||||
|
.plan-selection .plan-card {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection .plan-card .card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection .plan-card .card-body {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection .plan-card input[type="radio"]:checked+label .card {
|
||||||
|
border-color: #a1afdf;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection .plan-card .card:hover {
|
||||||
|
border-color: #a1afdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection .form-check-input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection h6 {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection .badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection .price-display {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-selection .storage-info {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left: 3px solid #6c757d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="plan-selection">
|
||||||
|
{% if plan_form %}
|
||||||
|
{% for assignment in plan_form.fields.compute_plan_assignment.queryset %}
|
||||||
|
<div class="form-check plan-card">
|
||||||
|
<input class="form-check-input"
|
||||||
|
type="radio"
|
||||||
|
name="{{ plan_form.compute_plan_assignment.html_name }}"
|
||||||
|
id="{{ plan_form.compute_plan_assignment.auto_id }}_{{ forloop.counter0 }}"
|
||||||
|
value="{{ assignment.pk }}"
|
||||||
|
{% if plan_form.compute_plan_assignment.value == assignment.pk|stringformat:"s" or plan_form.fields.compute_plan_assignment.initial == assignment or not plan_form.is_bound and forloop.first %}checked{% endif %}
|
||||||
|
data-memory-limits="{{ assignment.compute_plan.memory_limits }}"
|
||||||
|
data-memory-requests="{{ assignment.compute_plan.memory_requests }}"
|
||||||
|
data-cpu-limits="{{ assignment.compute_plan.cpu_limits }}"
|
||||||
|
data-cpu-requests="{{ assignment.compute_plan.cpu_requests }}"
|
||||||
|
required>
|
||||||
|
<label class="form-check-label w-100"
|
||||||
|
for="{{ plan_form.compute_plan_assignment.auto_id }}_{{ forloop.counter0 }}">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<h6>{{ assignment.compute_plan.name }}</h6>
|
||||||
|
<span class="badge bg-{% if assignment.sla == 'guaranteed' %}success{% else %}secondary{% endif %}">
|
||||||
|
{{ assignment.get_sla_display }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="price-display">CHF {{ assignment.price }}</div>
|
||||||
|
<div class="text-muted small">{% trans "per" %} {{ assignment.get_unit_display }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-muted small">
|
||||||
|
<i class="bi bi-cpu"></i> {{ assignment.compute_plan.cpu_limits }} {% trans "vCPU" %}
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<i class="bi bi-memory"></i> {{ assignment.compute_plan.memory_limits }} {% trans "RAM" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="storage-info">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong class="d-block mb-1">{% trans "Storage" %}</strong>
|
||||||
|
<span class="text-muted small">{% trans "Billed separately based on disk usage" %}</span>
|
||||||
|
</div>
|
||||||
|
{% if storage_plan %}
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="fw-semibold">CHF {{ storage_plan.price_per_gib }}</div>
|
||||||
|
<div class="text-muted small">{% trans "per GiB" %}</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-end text-muted small">{% trans "Included" %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if storage_plan %}<div class="mt-2 text-muted small" id="storage-cost-display"></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-warning">{% trans "No compute plans available for this service offering." %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Update readonly CPU/memory fields when plan selection changes
|
||||||
|
document.querySelectorAll('input[name="{{ plan_form.compute_plan_assignment.html_name }}"]').forEach(radio => {
|
||||||
|
radio.addEventListener('change', function() {
|
||||||
|
if (this.checked) {
|
||||||
|
// Update CPU/memory fields in the form
|
||||||
|
const cpuLimit = document.querySelector('input[name="expert-spec.parameters.size.cpu"]');
|
||||||
|
const memoryLimit = document.querySelector('input[name="expert-spec.parameters.size.memory"]');
|
||||||
|
const cpuRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.cpu"]');
|
||||||
|
const memoryRequest = document.querySelector('input[name="expert-spec.parameters.size.requests.memory"]');
|
||||||
|
|
||||||
|
if (cpuLimit) cpuLimit.value = this.dataset.cpuLimits;
|
||||||
|
if (memoryLimit) memoryLimit.value = this.dataset.memoryLimits;
|
||||||
|
if (cpuRequest) cpuRequest.value = this.dataset.cpuRequests;
|
||||||
|
if (memoryRequest) memoryRequest.value = this.dataset.memoryRequests;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger initial update
|
||||||
|
const checkedRadio = document.querySelector('input[name="{{ plan_form.compute_plan_assignment.html_name }}"]:checked');
|
||||||
|
if (checkedRadio) {
|
||||||
|
checkedRadio.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup storage cost calculator
|
||||||
|
function setupStorageCostCalculator() {
|
||||||
|
const diskInput = document.getElementById('id_custom-spec.parameters.size.disk');
|
||||||
|
if (diskInput && !diskInput.dataset.storageListenerAttached) {
|
||||||
|
diskInput.dataset.storageListenerAttached = 'true';
|
||||||
|
diskInput.addEventListener('input', function() {
|
||||||
|
const sizeGiB = parseFloat(this.value) || 0;
|
||||||
|
const pricePerGiB = {
|
||||||
|
{
|
||||||
|
storage_plan.price_per_gib |
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const totalCost = (sizeGiB * pricePerGiB).toFixed(2);
|
||||||
|
const display = document.getElementById('storage-cost-display');
|
||||||
|
if (display && sizeGiB > 0) {
|
||||||
|
display.innerHTML = '<i class="bi bi-calculator"></i> ' + sizeGiB + ' GiB × CHF ' + pricePerGiB + ' = <strong>CHF ' + totalCost + '</strong> {% trans "per hour" %}';
|
||||||
|
} else if (display) {
|
||||||
|
display.textContent = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger initial calculation if disk field has a value
|
||||||
|
if (diskInput.value) {
|
||||||
|
diskInput.dispatchEvent(new Event('input'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to setup immediately (in case form is already loaded)
|
||||||
|
setupStorageCostCalculator();
|
||||||
|
|
||||||
|
// Also setup after HTMX swaps the form in
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
if (event.detail.target.id === 'service-form' || event.detail.target.id === 'control-plane-info') {
|
||||||
|
setupStorageCostCalculator();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -1,26 +1,64 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load get_field %}
|
{% load get_field %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<form class="form form-vertical crd-form"
|
{% include "frontend/forms/errors.html" %}
|
||||||
method="post"
|
{% if form and expert_form and not hide_expert_mode %}
|
||||||
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
<div class="mb-3 text-end">
|
||||||
{% csrf_token %}
|
<a href="#"
|
||||||
{% include "frontend/forms/errors.html" %}
|
class="text-muted small"
|
||||||
{% if form and expert_form and not hide_expert_mode %}
|
id="expert-mode-toggle"
|
||||||
<div class="mb-3 text-end">
|
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
|
||||||
<a href="#"
|
</div>
|
||||||
class="text-muted small"
|
{% endif %}
|
||||||
id="expert-mode-toggle"
|
<div id="custom-form-container"
|
||||||
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
|
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}">
|
||||||
</div>
|
{% if form and form.context %}{{ form.context }}{% endif %}
|
||||||
{% endif %}
|
{% if form and form.get_fieldsets|length == 1 %}
|
||||||
<div id="custom-form-container"
|
{# Single fieldset - render without tabs #}
|
||||||
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}">
|
{% for fieldset in form.get_fieldsets %}
|
||||||
{% if form and form.context %}{{ form.context }}{% endif %}
|
<div class="my-2">
|
||||||
{% if form and form.get_fieldsets|length == 1 %}
|
{% for field in fieldset.fields %}
|
||||||
{# Single fieldset - render without tabs #}
|
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for subfieldset in fieldset.fieldsets %}
|
||||||
|
{% if subfieldset.fields %}
|
||||||
|
<div>
|
||||||
|
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
||||||
|
{% for field in subfieldset.fields %}
|
||||||
|
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% elif form %}
|
||||||
|
{# Multiple fieldsets or auto-generated form - render with tabs #}
|
||||||
|
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||||
{% for fieldset in form.get_fieldsets %}
|
{% for fieldset in form.get_fieldsets %}
|
||||||
<div class="my-2">
|
{% if not fieldset.hidden %}
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
||||||
|
id="{{ fieldset.title|slugify }}-tab"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#custom-{{ fieldset.title|slugify }}"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="custom-{{ fieldset.title|slugify }}"
|
||||||
|
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||||
|
{{ fieldset.title }}
|
||||||
|
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content" id="myTabContent">
|
||||||
|
{% for fieldset in form.get_fieldsets %}
|
||||||
|
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
||||||
|
id="custom-{{ fieldset.title|slugify }}"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="custom-{{ fieldset.title|slugify }}-tab">
|
||||||
{% for field in fieldset.fields %}
|
{% for field in fieldset.fields %}
|
||||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -36,113 +74,70 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elif form %}
|
|
||||||
{# Multiple fieldsets or auto-generated form - render with tabs #}
|
|
||||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
|
||||||
{% for fieldset in form.get_fieldsets %}
|
|
||||||
{% if not fieldset.hidden %}
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
|
||||||
id="{{ fieldset.title|slugify }}-tab"
|
|
||||||
data-bs-toggle="tab"
|
|
||||||
data-bs-target="#custom-{{ fieldset.title|slugify }}"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-controls="custom-{{ fieldset.title|slugify }}"
|
|
||||||
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
|
||||||
{{ fieldset.title }}
|
|
||||||
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<div class="tab-content" id="myTabContent">
|
|
||||||
{% for fieldset in form.get_fieldsets %}
|
|
||||||
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
|
||||||
id="custom-{{ fieldset.title|slugify }}"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="custom-{{ fieldset.title|slugify }}-tab">
|
|
||||||
{% for field in fieldset.fields %}
|
|
||||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
{% for subfieldset in fieldset.fieldsets %}
|
|
||||||
{% if subfieldset.fields %}
|
|
||||||
<div>
|
|
||||||
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
|
||||||
{% for field in subfieldset.fields %}
|
|
||||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if expert_form and not hide_expert_mode %}
|
|
||||||
<div id="expert-form-container"
|
|
||||||
class="expert-crd-form"
|
|
||||||
style="{% if form %}display:none{% endif %}">
|
|
||||||
{% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %}
|
|
||||||
<ul class="nav nav-tabs" id="expertTab" role="tablist">
|
|
||||||
{% for fieldset in expert_form.get_fieldsets %}
|
|
||||||
{% if not fieldset.hidden %}
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
|
||||||
id="expert-{{ fieldset.title|slugify }}-tab"
|
|
||||||
data-bs-toggle="tab"
|
|
||||||
data-bs-target="#expert-{{ fieldset.title|slugify }}"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-controls="expert-{{ fieldset.title|slugify }}"
|
|
||||||
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
|
||||||
{{ fieldset.title }}
|
|
||||||
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<div class="tab-content" id="expertTabContent">
|
|
||||||
{% for fieldset in expert_form.get_fieldsets %}
|
|
||||||
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
|
||||||
id="expert-{{ fieldset.title|slugify }}"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="expert-{{ fieldset.title|slugify }}-tab">
|
|
||||||
{% for field in fieldset.fields %}
|
|
||||||
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
{% for subfieldset in fieldset.fieldsets %}
|
|
||||||
{% if subfieldset.fields %}
|
|
||||||
<div>
|
|
||||||
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
|
||||||
{% for field in subfieldset.fields %}
|
|
||||||
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if form %}
|
</div>
|
||||||
<input type="hidden"
|
{% if expert_form and not hide_expert_mode %}
|
||||||
name="active_form"
|
<div id="expert-form-container"
|
||||||
id="active-form-input"
|
class="expert-crd-form"
|
||||||
value="custom">
|
style="{% if form %}display:none{% endif %}">
|
||||||
{% endif %}
|
{% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %}
|
||||||
<div class="col-sm-12 d-flex justify-content-end">
|
<ul class="nav nav-tabs" id="expertTab" role="tablist">
|
||||||
{# browser form validation fails when there are fields missing/invalid that are hidden #}
|
{% for fieldset in expert_form.get_fieldsets %}
|
||||||
<input class="btn btn-primary me-1 mb-1"
|
{% if not fieldset.hidden %}
|
||||||
type="submit"
|
<li class="nav-item" role="presentation">
|
||||||
{% if form and expert_form %}formnovalidate{% endif %}
|
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
||||||
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
|
id="expert-{{ fieldset.title|slugify }}-tab"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#expert-{{ fieldset.title|slugify }}"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="expert-{{ fieldset.title|slugify }}"
|
||||||
|
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||||
|
{{ fieldset.title }}
|
||||||
|
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content" id="expertTabContent">
|
||||||
|
{% for fieldset in expert_form.get_fieldsets %}
|
||||||
|
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
||||||
|
id="expert-{{ fieldset.title|slugify }}"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="expert-{{ fieldset.title|slugify }}-tab">
|
||||||
|
{% for field in fieldset.fields %}
|
||||||
|
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for subfieldset in fieldset.fieldsets %}
|
||||||
|
{% if subfieldset.fields %}
|
||||||
|
<div>
|
||||||
|
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
||||||
|
{% for field in subfieldset.fields %}
|
||||||
|
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{% endif %}
|
||||||
|
{% if form %}
|
||||||
|
<input type="hidden"
|
||||||
|
name="active_form"
|
||||||
|
id="active-form-input"
|
||||||
|
value="custom">
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-sm-12 d-flex justify-content-end">
|
||||||
|
{# browser form validation fails when there are fields missing/invalid that are hidden #}
|
||||||
|
<input class="btn btn-primary me-1 mb-1"
|
||||||
|
type="submit"
|
||||||
|
{% if form and expert_form %}formnovalidate{% endif %}
|
||||||
|
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
|
||||||
|
</div>
|
||||||
<script defer src="{% static 'js/bootstrap-tabs.js' %}"></script>
|
<script defer src="{% static 'js/bootstrap-tabs.js' %}"></script>
|
||||||
{% if form and not hide_expert_mode %}
|
{% if form and not hide_expert_mode %}
|
||||||
<script defer src="{% static 'js/expert-mode.js' %}"></script>
|
<script defer src="{% static 'js/expert-mode.js' %}"></script>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from servala.core.models import (
|
||||||
ServiceOffering,
|
ServiceOffering,
|
||||||
)
|
)
|
||||||
from servala.frontend.forms.service import (
|
from servala.frontend.forms.service import (
|
||||||
|
ComputePlanSelectionForm,
|
||||||
ControlPlaneSelectForm,
|
ControlPlaneSelectForm,
|
||||||
ServiceFilterForm,
|
ServiceFilterForm,
|
||||||
ServiceInstanceDeleteForm,
|
ServiceInstanceDeleteForm,
|
||||||
|
|
@ -152,6 +153,13 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
control_plane=self.selected_plane, service_offering=self.object
|
control_plane=self.selected_plane, service_offering=self.object
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def plan_form(self):
|
||||||
|
data = self.request.POST if self.request.method == "POST" else None
|
||||||
|
return ComputePlanSelectionForm(
|
||||||
|
data=data, control_plane_crd=self.context_object, prefix="plans"
|
||||||
|
)
|
||||||
|
|
||||||
def get_instance_form_kwargs(self, ignore_data=False):
|
def get_instance_form_kwargs(self, ignore_data=False):
|
||||||
return {
|
return {
|
||||||
"initial": {
|
"initial": {
|
||||||
|
|
@ -205,6 +213,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
context["select_form"] = self.select_form
|
context["select_form"] = self.select_form
|
||||||
context["has_control_planes"] = self.planes.exists()
|
context["has_control_planes"] = self.planes.exists()
|
||||||
context["selected_plane"] = self.selected_plane
|
context["selected_plane"] = self.selected_plane
|
||||||
|
context["context_object"] = self.context_object
|
||||||
context["hide_expert_mode"] = self.hide_expert_mode
|
context["hide_expert_mode"] = self.hide_expert_mode
|
||||||
if self.request.method == "POST":
|
if self.request.method == "POST":
|
||||||
if self.is_custom_form:
|
if self.is_custom_form:
|
||||||
|
|
@ -222,6 +231,17 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
if self.selected_plane and self.selected_plane.wildcard_dns:
|
if self.selected_plane and self.selected_plane.wildcard_dns:
|
||||||
context["wildcard_dns"] = self.selected_plane.wildcard_dns
|
context["wildcard_dns"] = self.selected_plane.wildcard_dns
|
||||||
context["organization_namespace"] = self.request.organization.namespace
|
context["organization_namespace"] = self.request.organization.namespace
|
||||||
|
|
||||||
|
if self.context_object:
|
||||||
|
context["plan_form"] = self.plan_form
|
||||||
|
context["has_available_plans"] = self.plan_form.fields[
|
||||||
|
"compute_plan_assignment"
|
||||||
|
].queryset.exists()
|
||||||
|
if self.context_object.control_plane.storage_plan_price_per_gib:
|
||||||
|
context["storage_plan"] = {
|
||||||
|
"price_per_gib": self.context_object.control_plane.storage_plan_price_per_gib,
|
||||||
|
}
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
@ -232,6 +252,9 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
context["form_error"] = True
|
context["form_error"] = True
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
if not self.plan_form.is_valid():
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
if self.is_custom_form:
|
if self.is_custom_form:
|
||||||
form = self.get_custom_instance_form()
|
form = self.get_custom_instance_form()
|
||||||
else:
|
else:
|
||||||
|
|
@ -245,7 +268,11 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
)
|
)
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid() and self.plan_form.is_valid():
|
||||||
|
compute_plan_assignment = self.plan_form.cleaned_data[
|
||||||
|
"compute_plan_assignment"
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
service_instance = ServiceInstance.create_instance(
|
service_instance = ServiceInstance.create_instance(
|
||||||
organization=self.request.organization,
|
organization=self.request.organization,
|
||||||
|
|
@ -253,16 +280,22 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
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"),
|
||||||
|
compute_plan_assignment=compute_plan_assignment,
|
||||||
)
|
)
|
||||||
return redirect(service_instance.urls.base)
|
return redirect(service_instance.urls.base)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
form.add_error(None, e.message or str(e))
|
form.add_error(None, e.message or str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = self.organization.add_support_message(
|
error_message = self.organization.add_support_message(
|
||||||
_(f"Error creating instance: {str(e)}.")
|
_("Error creating instance: {error}.").format(error=str(e))
|
||||||
)
|
)
|
||||||
form.add_error(None, error_message)
|
form.add_error(None, error_message)
|
||||||
|
|
||||||
|
if self.is_custom_form:
|
||||||
|
context["custom_service_form"] = form
|
||||||
|
else:
|
||||||
|
context["service_form"] = form
|
||||||
|
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -332,6 +365,18 @@ class ServiceInstanceDetailView(
|
||||||
context["has_delete_permission"] = self.request.user.has_perm(
|
context["has_delete_permission"] = self.request.user.has_perm(
|
||||||
ServiceInstance.get_perm("delete"), self.object
|
ServiceInstance.get_perm("delete"), self.object
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.object.compute_plan_assignment:
|
||||||
|
context["compute_plan_assignment"] = self.object.compute_plan_assignment
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.object.context
|
||||||
|
and self.object.context.control_plane.storage_plan_price_per_gib
|
||||||
|
):
|
||||||
|
context["storage_plan"] = {
|
||||||
|
"price_per_gib": self.object.context.control_plane.storage_plan_price_per_gib,
|
||||||
|
}
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_nested_spec(self):
|
def get_nested_spec(self):
|
||||||
|
|
@ -475,6 +520,17 @@ class ServiceInstanceUpdateView(
|
||||||
kwargs.pop("data", None)
|
kwargs.pop("data", None)
|
||||||
return cls(**kwargs)
|
return cls(**kwargs)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def plan_form(self):
|
||||||
|
data = self.request.POST if self.request.method == "POST" else None
|
||||||
|
initial = self.object.compute_plan_assignment if self.object else None
|
||||||
|
return ComputePlanSelectionForm(
|
||||||
|
data=data,
|
||||||
|
control_plane_crd=self.object.context if self.object else None,
|
||||||
|
prefix="plans",
|
||||||
|
initial={"compute_plan_assignment": initial} if initial else None,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_custom_form(self):
|
def is_custom_form(self):
|
||||||
# Note: "custom form" = user-friendly, subset of fields
|
# Note: "custom form" = user-friendly, subset of fields
|
||||||
|
|
@ -489,7 +545,7 @@ class ServiceInstanceUpdateView(
|
||||||
else:
|
else:
|
||||||
form = self.get_form()
|
form = self.get_form()
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid() and self.plan_form.is_valid():
|
||||||
return self.form_valid(form)
|
return self.form_valid(form)
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
|
@ -506,14 +562,29 @@ class ServiceInstanceUpdateView(
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["hide_expert_mode"] = self.hide_expert_mode
|
context["hide_expert_mode"] = self.hide_expert_mode
|
||||||
|
|
||||||
|
# Check if a form was passed (e.g., from form_invalid)
|
||||||
|
form_from_kwargs = kwargs.get("form")
|
||||||
|
|
||||||
if self.request.method == "POST":
|
if self.request.method == "POST":
|
||||||
if self.is_custom_form:
|
if self.is_custom_form:
|
||||||
context["custom_form"] = self.get_custom_form()
|
# Use the form with errors if passed, otherwise create new
|
||||||
|
context["custom_form"] = form_from_kwargs or self.get_custom_form()
|
||||||
context["form"] = self.get_form(ignore_data=True)
|
context["form"] = self.get_form(ignore_data=True)
|
||||||
else:
|
else:
|
||||||
|
# Use the form with errors if passed, otherwise create new
|
||||||
|
context["form"] = form_from_kwargs or self.get_form()
|
||||||
context["custom_form"] = self.get_custom_form(ignore_data=True)
|
context["custom_form"] = self.get_custom_form(ignore_data=True)
|
||||||
else:
|
else:
|
||||||
context["custom_form"] = self.get_custom_form()
|
context["custom_form"] = self.get_custom_form()
|
||||||
|
|
||||||
|
if self.object and self.object.context:
|
||||||
|
context["plan_form"] = self.plan_form
|
||||||
|
if self.object.context.control_plane.storage_plan_price_per_gib:
|
||||||
|
context["storage_plan"] = {
|
||||||
|
"price_per_gib": self.object.context.control_plane.storage_plan_price_per_gib,
|
||||||
|
}
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def _deep_merge(self, base, update):
|
def _deep_merge(self, base, update):
|
||||||
|
|
@ -533,7 +604,17 @@ class ServiceInstanceUpdateView(
|
||||||
current_spec = dict(self.object.spec) if self.object.spec else {}
|
current_spec = dict(self.object.spec) if self.object.spec else {}
|
||||||
spec_data = self._deep_merge(current_spec, spec_data)
|
spec_data = self._deep_merge(current_spec, spec_data)
|
||||||
|
|
||||||
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
|
compute_plan_assignment = None
|
||||||
|
if self.plan_form.is_valid():
|
||||||
|
compute_plan_assignment = self.plan_form.cleaned_data.get(
|
||||||
|
"compute_plan_assignment"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.object.update_spec(
|
||||||
|
spec_data=spec_data,
|
||||||
|
updated_by=self.request.user,
|
||||||
|
compute_plan_assignment=compute_plan_assignment,
|
||||||
|
)
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
_("Service instance '{name}' updated successfully.").format(
|
_("Service instance '{name}' updated successfully.").format(
|
||||||
|
|
@ -546,7 +627,7 @@ class ServiceInstanceUpdateView(
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = self.organization.add_support_message(
|
error_message = self.organization.add_support_message(
|
||||||
_(f"Error updating instance: {str(e)}.")
|
_("Error updating instance: {error}.").format(error=str(e))
|
||||||
)
|
)
|
||||||
form.add_error(None, error_message)
|
form.add_error(None, error_message)
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ const initializeFqdnGeneration = (prefix) => {
|
||||||
let isArrayField = true;
|
let isArrayField = true;
|
||||||
|
|
||||||
if (fqdnFieldContainer) {
|
if (fqdnFieldContainer) {
|
||||||
let fqdnField = fqdnFieldContainer.querySelector('input.array-item-input');
|
fqdnField = fqdnFieldContainer.querySelector("input.array-item-input")
|
||||||
|
if (!fqdnField) {
|
||||||
|
// We retry, as there is a field meant to be here, but not rendered yet
|
||||||
|
setTimeout(() => {initializeFqdnGeneration(prefix)}, 200)
|
||||||
|
return
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`);
|
fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`);
|
||||||
isArrayField = false;
|
isArrayField = false;
|
||||||
|
|
@ -53,10 +58,14 @@ const initializeFqdnGeneration = (prefix) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")});
|
const runFqdnInit = () => {
|
||||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
initializeFqdnGeneration("custom");
|
||||||
if (event.detail.target.id === 'service-form') {
|
initializeFqdnGeneration("expert");
|
||||||
initializeFqdnGeneration("custom");
|
}
|
||||||
initializeFqdnGeneration("expert");
|
|
||||||
}
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
runFqdnInit()
|
||||||
|
});
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
if (event.detail.target.id === 'service-form') runFqdnInit()
|
||||||
});
|
});
|
||||||
|
|
|
||||||
199
src/tests/test_compute_plans.py
Normal file
199
src/tests/test_compute_plans.py
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from servala.core.models import (
|
||||||
|
ComputePlan,
|
||||||
|
ComputePlanAssignment,
|
||||||
|
ServiceInstance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_compute_plan():
|
||||||
|
plan = ComputePlan.objects.create(
|
||||||
|
name="Small",
|
||||||
|
description="Small resource plan",
|
||||||
|
memory_requests="512Mi",
|
||||||
|
memory_limits="1Gi",
|
||||||
|
cpu_requests="100m",
|
||||||
|
cpu_limits="500m",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.name == "Small"
|
||||||
|
assert plan.memory_requests == "512Mi"
|
||||||
|
assert plan.memory_limits == "1Gi"
|
||||||
|
assert plan.cpu_requests == "100m"
|
||||||
|
assert plan.cpu_limits == "500m"
|
||||||
|
assert plan.is_active is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_compute_plan_str():
|
||||||
|
plan = ComputePlan.objects.create(
|
||||||
|
name="Medium",
|
||||||
|
memory_requests="1Gi",
|
||||||
|
memory_limits="2Gi",
|
||||||
|
cpu_requests="500m",
|
||||||
|
cpu_limits="1000m",
|
||||||
|
)
|
||||||
|
assert str(plan) == "Medium"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_get_resource_summary():
|
||||||
|
plan = ComputePlan.objects.create(
|
||||||
|
name="Large",
|
||||||
|
memory_requests="2Gi",
|
||||||
|
memory_limits="4Gi",
|
||||||
|
cpu_requests="1000m",
|
||||||
|
cpu_limits="2000m",
|
||||||
|
)
|
||||||
|
summary = plan.get_resource_summary()
|
||||||
|
assert summary == "2000m vCPU, 4Gi RAM"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_compute_plan_to_spec():
|
||||||
|
compute_plan = Mock()
|
||||||
|
compute_plan.memory_requests = "512Mi"
|
||||||
|
compute_plan.memory_limits = "1Gi"
|
||||||
|
compute_plan.cpu_requests = "100m"
|
||||||
|
compute_plan.cpu_limits = "500m"
|
||||||
|
|
||||||
|
compute_plan_assignment = Mock()
|
||||||
|
compute_plan_assignment.compute_plan = compute_plan
|
||||||
|
compute_plan_assignment.sla = "besteffort"
|
||||||
|
|
||||||
|
spec_data = {"parameters": {}}
|
||||||
|
|
||||||
|
result = ServiceInstance._apply_compute_plan_to_spec(
|
||||||
|
spec_data, compute_plan_assignment
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["parameters"]["size"]["memory"] == "1Gi"
|
||||||
|
assert result["parameters"]["size"]["cpu"] == "500m"
|
||||||
|
assert result["parameters"]["size"]["requests"]["memory"] == "512Mi"
|
||||||
|
assert result["parameters"]["size"]["requests"]["cpu"] == "100m"
|
||||||
|
|
||||||
|
assert result["parameters"]["service"]["serviceLevel"] == "besteffort"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_compute_plan_preserves_existing_spec():
|
||||||
|
compute_plan = Mock()
|
||||||
|
compute_plan.memory_requests = "512Mi"
|
||||||
|
compute_plan.memory_limits = "1Gi"
|
||||||
|
compute_plan.cpu_requests = "100m"
|
||||||
|
compute_plan.cpu_limits = "500m"
|
||||||
|
|
||||||
|
compute_plan_assignment = Mock()
|
||||||
|
compute_plan_assignment.compute_plan = compute_plan
|
||||||
|
compute_plan_assignment.sla = "guaranteed"
|
||||||
|
|
||||||
|
spec_data = {
|
||||||
|
"parameters": {
|
||||||
|
"custom_field": "custom_value",
|
||||||
|
"service": {"existingField": "value"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = ServiceInstance._apply_compute_plan_to_spec(
|
||||||
|
spec_data, compute_plan_assignment
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["parameters"]["custom_field"] == "custom_value"
|
||||||
|
assert result["parameters"]["service"]["existingField"] == "value"
|
||||||
|
|
||||||
|
assert result["parameters"]["size"]["memory"] == "1Gi"
|
||||||
|
assert result["parameters"]["service"]["serviceLevel"] == "guaranteed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_compute_plan_with_none():
|
||||||
|
spec_data = {"parameters": {}}
|
||||||
|
result = ServiceInstance._apply_compute_plan_to_spec(spec_data, None)
|
||||||
|
|
||||||
|
assert result == spec_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_annotations_complete():
|
||||||
|
compute_plan_assignment = Mock()
|
||||||
|
compute_plan_assignment.odoo_product_id = "test-product-123"
|
||||||
|
compute_plan_assignment.odoo_unit_id = "test-unit-hour"
|
||||||
|
|
||||||
|
control_plane = Mock()
|
||||||
|
control_plane.storage_plan_odoo_product_id = "storage-product-id"
|
||||||
|
control_plane.storage_plan_odoo_unit_id = "storage-unit-id"
|
||||||
|
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(
|
||||||
|
compute_plan_assignment, control_plane
|
||||||
|
)
|
||||||
|
|
||||||
|
assert annotations["servala.com/erp_product_id_resource"] == "test-product-123"
|
||||||
|
assert annotations["servala.com/erp_unit_id_resource"] == "test-unit-hour"
|
||||||
|
|
||||||
|
assert annotations["servala.com/erp_product_id_storage"] == "storage-product-id"
|
||||||
|
assert annotations["servala.com/erp_unit_id_storage"] == "storage-unit-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_annotations_no_compute_plan():
|
||||||
|
control_plane = Mock()
|
||||||
|
control_plane.storage_plan_odoo_product_id = "storage-product-id"
|
||||||
|
control_plane.storage_plan_odoo_unit_id = "storage-unit-id"
|
||||||
|
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(None, control_plane)
|
||||||
|
|
||||||
|
assert "servala.com/erp_product_id_resource" not in annotations
|
||||||
|
assert "servala.com/erp_unit_id_resource" not in annotations
|
||||||
|
assert annotations["servala.com/erp_product_id_storage"] == "storage-product-id"
|
||||||
|
assert annotations["servala.com/erp_unit_id_storage"] == "storage-unit-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_annotations_no_storage_plan():
|
||||||
|
compute_plan_assignment = Mock()
|
||||||
|
compute_plan_assignment.odoo_product_id = "product-id"
|
||||||
|
compute_plan_assignment.odoo_unit_id = "unit-id"
|
||||||
|
|
||||||
|
control_plane = Mock()
|
||||||
|
control_plane.storage_plan_odoo_product_id = None
|
||||||
|
control_plane.storage_plan_odoo_unit_id = None
|
||||||
|
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(
|
||||||
|
compute_plan_assignment, control_plane
|
||||||
|
)
|
||||||
|
|
||||||
|
assert annotations["servala.com/erp_product_id_resource"] == "product-id"
|
||||||
|
assert annotations["servala.com/erp_unit_id_resource"] == "unit-id"
|
||||||
|
assert "servala.com/erp_product_id_storage" not in annotations
|
||||||
|
assert "servala.com/erp_unit_id_storage" not in annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_billing_annotations_empty():
|
||||||
|
control_plane = Mock()
|
||||||
|
control_plane.storage_plan_odoo_product_id = None
|
||||||
|
control_plane.storage_plan_odoo_unit_id = None
|
||||||
|
|
||||||
|
annotations = ServiceInstance._build_billing_annotations(None, control_plane)
|
||||||
|
|
||||||
|
assert annotations == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_hour_unit():
|
||||||
|
choices = dict(ComputePlanAssignment.BILLING_UNIT_CHOICES)
|
||||||
|
assert "hour" in choices
|
||||||
|
assert str(choices["hour"]) == "Hour"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_all_billing_units():
|
||||||
|
choices = dict(ComputePlanAssignment.BILLING_UNIT_CHOICES)
|
||||||
|
|
||||||
|
assert "hour" in choices
|
||||||
|
assert "day" in choices
|
||||||
|
assert "month" in choices
|
||||||
|
assert "year" in choices
|
||||||
|
|
||||||
|
assert str(choices["hour"]) == "Hour"
|
||||||
|
assert str(choices["day"]) == "Day"
|
||||||
|
assert "Month" in str(choices["month"])
|
||||||
|
assert str(choices["year"]) == "Year"
|
||||||
|
|
@ -10,6 +10,28 @@ from servala.core.forms import ServiceDefinitionAdminForm
|
||||||
from servala.core.models import ControlPlaneCRD
|
from servala.core.models import ControlPlaneCRD
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_model_form_class_is_none_when_no_form_config():
|
||||||
|
crd = Mock(spec=ControlPlaneCRD)
|
||||||
|
service_def = Mock()
|
||||||
|
service_def.form_config = None
|
||||||
|
crd.service_definition = service_def
|
||||||
|
crd.django_model = Mock()
|
||||||
|
|
||||||
|
if not (
|
||||||
|
crd.django_model
|
||||||
|
and crd.service_definition
|
||||||
|
and crd.service_definition.form_config
|
||||||
|
and crd.service_definition.form_config.get("fieldsets")
|
||||||
|
):
|
||||||
|
result = None
|
||||||
|
else:
|
||||||
|
result = generate_custom_form_class(
|
||||||
|
crd.service_definition.form_config, crd.django_model
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
def test_custom_model_form_class_returns_class_when_form_config_exists():
|
def test_custom_model_form_class_returns_class_when_form_config_exists():
|
||||||
|
|
||||||
crd = Mock(spec=ControlPlaneCRD)
|
crd = Mock(spec=ControlPlaneCRD)
|
||||||
|
|
@ -38,9 +60,18 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
|
||||||
app_label = "test"
|
app_label = "test"
|
||||||
|
|
||||||
crd.django_model = TestModel
|
crd.django_model = TestModel
|
||||||
result = generate_custom_form_class(
|
|
||||||
crd.service_definition.form_config, crd.django_model
|
if not (
|
||||||
)
|
crd.django_model
|
||||||
|
and crd.service_definition
|
||||||
|
and crd.service_definition.form_config
|
||||||
|
and crd.service_definition.form_config.get("fieldsets")
|
||||||
|
):
|
||||||
|
result = None
|
||||||
|
else:
|
||||||
|
result = generate_custom_form_class(
|
||||||
|
crd.service_definition.form_config, crd.django_model
|
||||||
|
)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert hasattr(result, "form_config")
|
assert hasattr(result, "form_config")
|
||||||
|
|
@ -1053,43 +1084,3 @@ def test_empty_values_dont_override_default_configs():
|
||||||
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"]
|
assert name_field.max_length == DEFAULT_FIELD_CONFIGS["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
|
||||||
|
|
||||||
|
|
||||||
def test_number_field_with_addon_text_roundtrip():
|
|
||||||
class TestModel(models.Model):
|
|
||||||
name = models.CharField(max_length=100)
|
|
||||||
disk_size = models.IntegerField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
app_label = "test"
|
|
||||||
|
|
||||||
form_config = {
|
|
||||||
"fieldsets": [
|
|
||||||
{
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"label": "Name",
|
|
||||||
"controlplane_field_mapping": "name",
|
|
||||||
"required": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "number",
|
|
||||||
"label": "Disk Size",
|
|
||||||
"controlplane_field_mapping": "disk_size",
|
|
||||||
"addon_text": "Gi",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
form_class = generate_custom_form_class(form_config, TestModel)
|
|
||||||
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.fields["context"].required = False
|
|
||||||
assert form.is_valid(), f"Form should be valid but has errors: {form.errors}"
|
|
||||||
nested_data = form.get_nested_data()
|
|
||||||
assert nested_data["disk_size"] == "25Gi"
|
|
||||||
|
|
|
||||||
120
uv.lock
generated
120
uv.lock
generated
|
|
@ -47,11 +47,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asgiref"
|
name = "asgiref"
|
||||||
version = "3.11.0"
|
version = "3.10.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -86,30 +86,30 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "boto3"
|
name = "boto3"
|
||||||
version = "1.42.0"
|
version = "1.40.74"
|
||||||
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/a2/37/0db5fc46548b347255310893f1a47971a1d8eb0dbc46dfb5ace8a1e7d45e/boto3-1.40.74.tar.gz", hash = "sha256:484e46bf394b03a7c31b34f90945ebe1390cb1e2ac61980d128a9079beac87d4", size = 111592, upload-time = "2025-11-14T20:29:10.991Z" }
|
||||||
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/d2/08/c52751748762901c0ca3c3019e3aa950010217f0fdf9940ebe68e6bb2f5a/boto3-1.40.74-py3-none-any.whl", hash = "sha256:41fc8844b37ae27b24bcabf8369769df246cc12c09453988d0696ad06d6aa9ef", size = 139360, upload-time = "2025-11-14T20:29:09.477Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "botocore"
|
name = "botocore"
|
||||||
version = "1.41.6"
|
version = "1.40.74"
|
||||||
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/81/dc/0412505f05286f282a75bb0c650e525ddcfaf3f6f1a05cd8e99d32a2db06/botocore-1.40.74.tar.gz", hash = "sha256:57de0b9ffeada06015b3c7e5186c77d0692b210d9e5efa294f3214df97e2f8ee", size = 14452479, upload-time = "2025-11-14T20:29:00.949Z" }
|
||||||
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/7d/a2/306dec16e3c84f3ca7aaead0084358c1c7fbe6501f6160844cbc93bc871e/botocore-1.40.74-py3-none-any.whl", hash = "sha256:f39f5763e35e75f0bd91212b7b36120b1536203e8003cd952ef527db79702b15", size = 14117911, upload-time = "2025-11-14T20:28:58.153Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -345,15 +345,15 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-allauth"
|
name = "django-allauth"
|
||||||
version = "65.13.1"
|
version = "65.13.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/42a048ba1dedbb6b553f5376a6126b1c753c10c70d1edab8f94c560c8066/django_allauth-65.13.1.tar.gz", hash = "sha256:2af0d07812f8c1a8e3732feaabe6a9db5ecf3fad6b45b6a0f7fd825f656c5a15", size = 1983857, upload-time = "2025-11-20T16:34:40.811Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7c/05/36b9de6d0109948717ee0fa8076d5b57396bc838d5239f5b44b7d4c29fb0/django_allauth-65.13.0.tar.gz", hash = "sha256:7d7b7e7ad603eb3864c142f051e2cce7be2f9a9c6945a51172ec83d48c6c843b", size = 1987616, upload-time = "2025-10-31T10:20:03.954Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/98/9d44ae1468abfdb521d651fb67f914165c7812dfdd97be16190c9b1cc246/django_allauth-65.13.1-py3-none-any.whl", hash = "sha256:2887294beedfd108b4b52ebd182e0ed373deaeb927fc5a22f77bbde3174704a6", size = 1787349, upload-time = "2025-11-20T16:34:37.354Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/17/f2fd703781aeeb6d314059408df77360f09625cc3ce85f264b104443108c/django_allauth-65.13.0-py3-none-any.whl", hash = "sha256:119c0cf1cc2e0d1a0fe2f13588f30951d64989256084de2d60f13ab9308f9fa0", size = 1787213, upload-time = "2025-10-31T10:20:00.587Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -489,26 +489,26 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flake8-bugbear"
|
name = "flake8-bugbear"
|
||||||
version = "25.11.29"
|
version = "25.10.21"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "attrs" },
|
{ name = "attrs" },
|
||||||
{ name = "flake8" },
|
{ name = "flake8" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/20/2a996e2fca7810bd1b031901d65fc4292630895afcb946ebd00568bdc669/flake8_bugbear-25.11.29.tar.gz", hash = "sha256:b5d06710f3d26e595541ad303ad4d5cb52578bd4bccbb2c2c0b2c72e243dafc8", size = 84896, upload-time = "2025-11-29T20:51:57.75Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/30/54/0f6e431adbc67fd420540e386cb20b57e73e8aeb393f0ae2311e91b4548f/flake8_bugbear-25.10.21.tar.gz", hash = "sha256:2876afcaed8bfb3464cf33e3ec42cc3bec0a004165b84400dc3392b0547c2714", size = 83080, upload-time = "2025-10-22T01:27:03.63Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/42/c18f199780d99a6f6a64c4a36f4ad28a445d9e11968a6025b21d0c8b6802/flake8_bugbear-25.11.29-py3-none-any.whl", hash = "sha256:9bf15e2970e736d2340da4c0a70493db964061c9c38f708cfe1f7b2d87392298", size = 37861, upload-time = "2025-11-29T20:51:56.439Z" },
|
{ url = "https://files.pythonhosted.org/packages/09/0e/8ba976f7d477cad69cc7af08dc7b0163181a5e19a82fe721f954e369c067/flake8_bugbear-25.10.21-py3-none-any.whl", hash = "sha256:f1c5654f9d9d3e62e90da1f0335551fdbc565c51749713177dbcfb9edb105405", size = 37257, upload-time = "2025-10-22T01:27:02.105Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flake8-pyproject"
|
name = "flake8-pyproject"
|
||||||
version = "1.2.4"
|
version = "1.2.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "flake8" },
|
{ name = "flake8" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/6a/cdee9ff7f2b7c6ddc219fd95b7c70c0a3d9f0367a506e9793eedfc72e337/flake8_pyproject-1.2.4-py3-none-any.whl", hash = "sha256:ea34c057f9a9329c76d98723bb2bb498cc6ba8ff9872c4d19932d48c91249a77", size = 5694, upload-time = "2025-11-28T21:40:01.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/1d/635e86f9f3a96b7ea9e9f19b5efe17a987e765c39ca496e4a893bb999112/flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a", size = 4756, upload-time = "2023-03-21T20:51:38.911Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1001,39 +1001,39 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rpds-py"
|
name = "rpds-py"
|
||||||
version = "0.30.0"
|
version = "0.29.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/98/33/23b3b3419b6a3e0f559c7c0d2ca8fc1b9448382b25245033788785921332/rpds_py-0.29.0.tar.gz", hash = "sha256:fe55fe686908f50154d1dc599232016e50c243b438c3b7432f24e2895b0e5359", size = 69359, upload-time = "2025-11-16T14:50:39.532Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/b1/0b1474e7899371d9540d3bbb2a499a3427ae1fc39c998563fe9035a1073b/rpds_py-0.29.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:394d27e4453d3b4d82bb85665dc1fcf4b0badc30fc84282defed71643b50e1a1", size = 363731, upload-time = "2025-11-16T14:49:26.683Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/28/12/3b7cf2068d0a334ed1d7b385a9c3c8509f4c2bcba3d4648ea71369de0881/rpds_py-0.29.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55d827b2ae95425d3be9bc9a5838b6c29d664924f98146557f7715e331d06df8", size = 354343, upload-time = "2025-11-16T14:49:28.24Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/73/5afcf8924bc02a749416eda64e17ac9c9b28f825f4737385295a0e99b0c1/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc31a07ed352e5462d3ee1b22e89285f4ce97d5266f6d1169da1142e78045626", size = 385406, upload-time = "2025-11-16T14:49:29.943Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/37/5db736730662508535221737a21563591b6f43c77f2e388951c42f143242/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4695dd224212f6105db7ea62197144230b808d6b2bba52238906a2762f1d1e7", size = 396162, upload-time = "2025-11-16T14:49:31.833Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/0d/491c1017d14f62ce7bac07c32768d209a50ec567d76d9f383b4cfad19b80/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcae1770b401167f8b9e1e3f566562e6966ffa9ce63639916248a9e25fa8a244", size = 517719, upload-time = "2025-11-16T14:49:33.804Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/25/b11132afcb17cd5d82db173f0c8dab270ffdfaba43e5ce7a591837ae9649/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90f30d15f45048448b8da21c41703b31c61119c06c216a1bf8c245812a0f0c17", size = 409498, upload-time = "2025-11-16T14:49:35.222Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/7d/e6543cedfb2e6403a1845710a5ab0e0ccf8fc288e0b5af9a70bfe2c12053/rpds_py-0.29.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44a91e0ab77bdc0004b43261a4b8cd6d6b451e8d443754cfda830002b5745b32", size = 382743, upload-time = "2025-11-16T14:49:36.704Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/11/a4ebc9f654293ae9fefb83b2b6be7f3253e85ea42a5db2f77d50ad19aaeb/rpds_py-0.29.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4aa195e5804d32c682e453b34474f411ca108e4291c6a0f824ebdc30a91c973c", size = 400317, upload-time = "2025-11-16T14:49:39.132Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/18/97677a60a81c7f0e5f64e51fb3f8271c5c8fcabf3a2df18e97af53d7c2bf/rpds_py-0.29.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7971bdb7bf4ee0f7e6f67fa4c7fbc6019d9850cc977d126904392d363f6f8318", size = 416979, upload-time = "2025-11-16T14:49:40.575Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/69/28ab391a9968f6c746b2a2db181eaa4d16afaa859fedc9c2f682d19f7e18/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8ae33ad9ce580c7a47452c3b3f7d8a9095ef6208e0a0c7e4e2384f9fc5bf8212", size = 567288, upload-time = "2025-11-16T14:49:42.24Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/d3/0c7afdcdb830eee94f5611b64e71354ffe6ac8df82d00c2faf2bfffd1d4e/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c661132ab2fb4eeede2ef69670fd60da5235209874d001a98f1542f31f2a8a94", size = 593157, upload-time = "2025-11-16T14:49:43.782Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/ac/a0fcbc2feed4241cf26d32268c195eb88ddd4bd862adfc9d4b25edfba535/rpds_py-0.29.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb78b3a0d31ac1bde132c67015a809948db751cb4e92cdb3f0b242e430b6ed0d", size = 554741, upload-time = "2025-11-16T14:49:45.557Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/f1/fcc24137c470df8588674a677f33719d5800ec053aaacd1de8a5d5d84d9e/rpds_py-0.29.0-cp314-cp314-win32.whl", hash = "sha256:f475f103488312e9bd4000bc890a95955a07b2d0b6e8884aef4be56132adbbf1", size = 215508, upload-time = "2025-11-16T14:49:47.562Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/c7/1d169b2045512eac019918fc1021ea07c30e84a4343f9f344e3e0aa8c788/rpds_py-0.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:b9cf2359a4fca87cfb6801fae83a76aedf66ee1254a7a151f1341632acf67f1b", size = 228125, upload-time = "2025-11-16T14:49:49.064Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/36/0cec88aaba70ec4a6e381c444b0d916738497d27f0c30406e3d9fcbd3bc2/rpds_py-0.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:9ba8028597e824854f0f1733d8b964e914ae3003b22a10c2c664cb6927e0feb9", size = 221992, upload-time = "2025-11-16T14:49:50.777Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/fa/a2e524631717c9c0eb5d90d30f648cfba6b731047821c994acacb618406c/rpds_py-0.29.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e71136fd0612556b35c575dc2726ae04a1669e6a6c378f2240312cf5d1a2ab10", size = 366425, upload-time = "2025-11-16T14:49:52.691Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/a4/6d43ebe0746ff694a30233f63f454aed1677bd50ab7a59ff6b2bb5ac61f2/rpds_py-0.29.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:76fe96632d53f3bf0ea31ede2f53bbe3540cc2736d4aec3b3801b0458499ef3a", size = 355282, upload-time = "2025-11-16T14:49:54.292Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/a7/52fd8270e0320b09eaf295766ae81dd175f65394687906709b3e75c71d06/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9459a33f077130dbb2c7c3cea72ee9932271fb3126404ba2a2661e4fe9eb7b79", size = 384968, upload-time = "2025-11-16T14:49:55.857Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/7d/e6bc526b7a14e1ef80579a52c1d4ad39260a058a51d66c6039035d14db9d/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9546cfdd5d45e562cc0444b6dddc191e625c62e866bf567a2c69487c7ad28a", size = 394714, upload-time = "2025-11-16T14:49:57.343Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/3f/f0ade3954e7db95c791e7eaf978aa7e08a756d2046e8bdd04d08146ed188/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12597d11d97b8f7e376c88929a6e17acb980e234547c92992f9f7c058f1a7310", size = 520136, upload-time = "2025-11-16T14:49:59.162Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/b3/07122ead1b97009715ab9d4082be6d9bd9546099b2b03fae37c3116f72be/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28de03cf48b8a9e6ec10318f2197b83946ed91e2891f651a109611be4106ac4b", size = 409250, upload-time = "2025-11-16T14:50:00.698Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/c6/dcbee61fd1dc892aedcb1b489ba661313101aa82ec84b1a015d4c63ebfda/rpds_py-0.29.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7951c964069039acc9d67a8ff1f0a7f34845ae180ca542b17dc1456b1f1808", size = 384940, upload-time = "2025-11-16T14:50:02.312Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/11/914ecb6f3574cf9bf8b38aced4063e0f787d6e1eb30b181a7efbc6c1da9a/rpds_py-0.29.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:c07d107b7316088f1ac0177a7661ca0c6670d443f6fe72e836069025e6266761", size = 399392, upload-time = "2025-11-16T14:50:03.829Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/fd/2f4bd9433f58f816434bb934313584caa47dbc6f03ce5484df8ac8980561/rpds_py-0.29.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de2345af363d25696969befc0c1688a6cb5e8b1d32b515ef84fc245c6cddba3", size = 416796, upload-time = "2025-11-16T14:50:05.558Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/a5/449f0281af33efa29d5c71014399d74842342ae908d8cd38260320167692/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:00e56b12d2199ca96068057e1ae7f9998ab6e99cda82431afafd32f3ec98cca9", size = 566843, upload-time = "2025-11-16T14:50:07.243Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/32/0a6a1ccee2e37fcb1b7ba9afde762b77182dbb57937352a729c6cd3cf2bb/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3919a3bbecee589300ed25000b6944174e07cd20db70552159207b3f4bbb45b8", size = 593956, upload-time = "2025-11-16T14:50:09.029Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/3d/eb820f95dce4306f07a495ede02fb61bef36ea201d9137d4fcd5ab94ec1e/rpds_py-0.29.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7fa2ccc312bbd91e43aa5e0869e46bc03278a3dddb8d58833150a18b0f0283a", size = 557288, upload-time = "2025-11-16T14:50:10.73Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/f8/b8ff786f40470462a252918e0836e0db903c28e88e3eec66bc4a7856ee5d/rpds_py-0.29.0-cp314-cp314t-win32.whl", hash = "sha256:97c817863ffc397f1e6a6e9d2d89fe5408c0a9922dac0329672fb0f35c867ea5", size = 211382, upload-time = "2025-11-16T14:50:12.827Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/7f/1a65ae870bc9d0576aebb0c501ea5dccf1ae2178fe2821042150ebd2e707/rpds_py-0.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2023473f444752f0f82a58dfcbee040d0a1b3d1b3c2ec40e884bd25db6d117d2", size = 225919, upload-time = "2025-11-16T14:50:14.734Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1059,27 +1059,27 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "s3transfer"
|
name = "s3transfer"
|
||||||
version = "0.16.0"
|
version = "0.14.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "botocore" },
|
{ name = "botocore" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sentry-sdk"
|
name = "sentry-sdk"
|
||||||
version = "2.46.0"
|
version = "2.45.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/d7/c140a5837649e2bf2ec758494fde1d9a016c76777eab64e75ef38d685bbb/sentry_sdk-2.46.0.tar.gz", hash = "sha256:91821a23460725734b7741523021601593f35731808afc0bb2ba46c27b8acd91", size = 374761, upload-time = "2025-11-24T09:34:13.932Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/89/1561b3dc8e28bf7978d031893297e89be266f53650c87bb14a29406a9791/sentry_sdk-2.45.0.tar.gz", hash = "sha256:e9bbfe69d5f6742f48bad22452beffb525bbc5b797d817c7f1b1f7d210cdd271", size = 373631, upload-time = "2025-11-18T13:23:22.475Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/b6/ce7c502a366f4835b1f9c057753f6989a92d3c70cbadb168193f5fb7499b/sentry_sdk-2.46.0-py2.py3-none-any.whl", hash = "sha256:4eeeb60198074dff8d066ea153fa6f241fef1668c10900ea53a4200abc8da9b1", size = 406266, upload-time = "2025-11-24T09:34:12.114Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/c6/039121a0355bc1b5bcceef0dabf211b021fd435d0ee5c46393717bb1c09f/sentry_sdk-2.45.0-py2.py3-none-any.whl", hash = "sha256:86c8ab05dc3e8666aece77a5c747b45b25aa1d5f35f06cde250608f495d50f23", size = 404791, upload-time = "2025-11-18T13:23:20.533Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|
@ -1134,7 +1134,7 @@ requires-dist = [
|
||||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||||
{ name = "cryptography", specifier = ">=46.0.3" },
|
{ name = "cryptography", specifier = ">=46.0.3" },
|
||||||
{ name = "django", specifier = "==5.2.8" },
|
{ name = "django", specifier = "==5.2.8" },
|
||||||
{ name = "django-allauth", specifier = ">=65.13.1" },
|
{ name = "django-allauth", specifier = ">=65.13.0" },
|
||||||
{ name = "django-auditlog", specifier = ">=3.3.0" },
|
{ name = "django-auditlog", specifier = ">=3.3.0" },
|
||||||
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.1" },
|
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.1" },
|
||||||
{ name = "django-jsonform", specifier = ">=2.23.2" },
|
{ name = "django-jsonform", specifier = ">=2.23.2" },
|
||||||
|
|
@ -1148,7 +1148,7 @@ requires-dist = [
|
||||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
{ name = "rules", specifier = ">=3.5" },
|
{ name = "rules", specifier = ">=3.5" },
|
||||||
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.46.0" },
|
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.45.0" },
|
||||||
{ name = "urlman", specifier = ">=2.0.2" },
|
{ name = "urlman", specifier = ">=2.0.2" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1159,7 +1159,7 @@ dev = [
|
||||||
{ name = "coverage", specifier = ">=7.12.0" },
|
{ name = "coverage", specifier = ">=7.12.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.10.21" },
|
||||||
{ name = "flake8-pyproject", specifier = ">=1.2.3" },
|
{ name = "flake8-pyproject", specifier = ">=1.2.3" },
|
||||||
{ name = "isort", specifier = ">=7.0.0" },
|
{ name = "isort", specifier = ">=7.0.0" },
|
||||||
{ name = "pytest", specifier = ">=9.0.1" },
|
{ name = "pytest", specifier = ">=9.0.1" },
|
||||||
|
|
@ -1179,11 +1179,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.4"
|
version = "0.5.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", size = 120112, upload-time = "2025-11-28T07:10:18.377Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue