Compare commits

..

11 commits

Author SHA1 Message Date
ad622ef14b Add tests for plans
All checks were successful
Tests / test (push) Successful in 28s
2025-12-02 16:28:54 +01:00
83f60711bb Show plans in forms 2025-12-02 16:26:32 +01:00
7d42820026 Show plans in detail view 2025-12-02 16:25:36 +01:00
2a63677539 Implement view logic 2025-12-02 16:25:16 +01:00
ef4f76b290 Implement form changes for plan integration 2025-12-02 16:11:51 +01:00
c5b2c58305 Add missing admin field 2025-12-02 16:09:48 +01:00
29661aa7cd Implement plan logic in create/update 2025-12-02 16:09:21 +01:00
2bbd643cf9 Add migrations and final model changes 2025-12-02 16:09:10 +01:00
5cee0194f5 Fix broken FQDN due to timing problems 2025-12-02 13:20:36 +01:00
cce071397c Adjustments to data model
All checks were successful
Tests / test (push) Successful in 25s
2025-11-25 13:56:51 +01:00
9a3734192e Initial Plan model 2025-11-25 13:30:00 +01:00
28 changed files with 1832 additions and 332 deletions

View file

@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -72,7 +72,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Determine image tag
id: determine-tag

View file

@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -53,7 +53,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.19

View file

@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -49,7 +49,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.19

View file

@ -11,7 +11,7 @@ jobs:
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
@ -19,7 +19,7 @@ jobs:
node-version: "24"
- name: Renovate
uses: https://github.com/renovatebot/github-action@v44.0.5
uses: https://github.com/renovatebot/github-action@v44.0.3
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:

View file

@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6

View file

@ -8,7 +8,7 @@ dependencies = [
"argon2-cffi>=25.1.0",
"cryptography>=46.0.3",
"django==5.2.8",
"django-allauth>=65.13.1",
"django-allauth>=65.13.0",
"django-auditlog>=3.3.0",
"django-fernet-encrypted-fields>=0.3.1",
"django-jsonform>=2.23.2",
@ -22,7 +22,7 @@ dependencies = [
"pyjwt>=2.10.1",
"requests>=2.32.5",
"rules>=3.5",
"sentry-sdk[django]>=2.46.0",
"sentry-sdk[django]>=2.45.0",
"urlman>=2.0.2",
]
@ -33,7 +33,7 @@ dev = [
"coverage>=7.12.0",
"djlint>=1.36.4",
"flake8>=7.3.0",
"flake8-bugbear>=25.11.29",
"flake8-bugbear>=25.10.21",
"flake8-pyproject>=1.2.3",
"isort>=7.0.0",
"pytest>=9.0.1",

View file

@ -239,9 +239,9 @@ The Servala Team"""
service_offering = ServiceOffering.objects.get(
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}")
except ServiceOffering.DoesNotExist: # pragma: no-cover
except ServiceOffering.DoesNotExist:
return self._error(
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
)
@ -284,7 +284,7 @@ The Servala Team"""
if service_instance:
organization = service_instance.organization
except Exception: # pragma: no cover
except Exception:
pass
description_parts = [f"Action: {action}", f"Service: {service.name}"]

View file

@ -9,8 +9,11 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
from servala.core.models import (
BillingEntity,
CloudProvider,
ComputePlan,
ComputePlanAssignment,
ControlPlane,
ControlPlaneCRD,
OdooObjectCache,
Organization,
OrganizationInvitation,
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):
@ -363,15 +379,21 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin):
@admin.register(ServiceInstance)
class ServiceInstanceAdmin(admin.ModelAdmin):
list_display = ("name", "organization", "context", "created_by")
list_filter = ("organization", "context")
list_display = (
"name",
"organization",
"context",
"compute_plan_assignment",
"created_by",
)
list_filter = ("organization", "context", "compute_plan_assignment")
search_fields = (
"name",
"organization__name",
"context__service_offering__service__name",
)
readonly_fields = ("name", "organization", "context")
autocomplete_fields = ("organization", "context")
autocomplete_fields = ("organization", "context", "compute_plan_assignment")
def get_readonly_fields(self, request, obj=None):
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
)
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")

View file

@ -1,12 +1,10 @@
from contextlib import suppress
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
from django.forms.models import ModelForm, ModelFormMetaclass
from servala.core.crd.utils import deslugify
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
MANDATORY_FIELDS = ["name"]
@ -27,11 +25,6 @@ DEFAULT_FIELD_CONFIGS = {
"help_text": "Domain names for accessing this service",
"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.scheduling",
"spec.parameters.security",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
]
# Fields populated from compute plan
READONLY_FIELDS = [
"spec.parameters.size.cpu",
"spec.parameters.size.memory",
"spec.parameters.size.requests.cpu",
"spec.parameters.size.requests.memory",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
]
def __init__(self, *args, **kwargs):
@ -95,6 +92,15 @@ class CrdModelFormMixin(FormGeneratorMixin):
):
field.widget = forms.HiddenInput()
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):
field = self.fields[field_name]
@ -342,19 +348,6 @@ class CustomFormMixin(FormGeneratorMixin):
if field_type == "number":
min_val = field_config.get("min_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 = []
if min_val is not None:
@ -426,11 +419,6 @@ class CustomFormMixin(FormGeneratorMixin):
mapping = 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(".")
current = nested
for part in parts[:-1]:

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

View file

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

View file

@ -1,3 +1,4 @@
from .odoo_cache import OdooObjectCache
from .organization import (
BillingEntity,
Organization,
@ -6,6 +7,10 @@ from .organization import (
OrganizationOrigin,
OrganizationRole,
)
from .plan import (
ComputePlan,
ComputePlanAssignment,
)
from .service import (
CloudProvider,
ControlPlane,
@ -21,8 +26,11 @@ from .user import User
__all__ = [
"BillingEntity",
"CloudProvider",
"ComputePlan",
"ComputePlanAssignment",
"ControlPlane",
"ControlPlaneCRD",
"OdooObjectCache",
"Organization",
"OrganizationInvitation",
"OrganizationMembership",
@ -30,8 +38,8 @@ __all__ = [
"OrganizationRole",
"Service",
"ServiceCategory",
"ServiceInstance",
"ServiceDefinition",
"ServiceInstance",
"ServiceOffering",
"User",
]

View 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"])

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

View file

@ -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:
verbose_name = _("Control plane")
verbose_name_plural = _("Control planes")
@ -613,6 +636,15 @@ class ServiceInstance(ServalaModelMixin, models.Model):
related_name="service_instances",
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:
verbose_name = _("Service instance")
@ -654,6 +686,60 @@ class ServiceInstance(ServalaModelMixin, models.Model):
spec_data = prune_empty_data(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
def _format_kubernetes_error(cls, error_message):
if not error_message:
@ -708,7 +794,15 @@ class ServiceInstance(ServalaModelMixin, models.Model):
@classmethod
@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
context.control_plane.get_or_create_namespace(organization)
try:
@ -717,6 +811,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
organization=organization,
created_by=created_by,
context=context,
compute_plan_assignment=compute_plan_assignment,
)
except IntegrityError:
message = _(
@ -727,6 +822,11 @@ class ServiceInstance(ServalaModelMixin, models.Model):
try:
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:
spec_data["writeConnectionSecretToRef"] = {}
@ -744,6 +844,13 @@ class ServiceInstance(ServalaModelMixin, models.Model):
},
"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:
create_data["metadata"]["labels"] = {settings.DEFAULT_LABEL_KEY: label}
api_instance = context.control_plane.custom_objects_api
@ -781,12 +888,23 @@ class ServiceInstance(ServalaModelMixin, models.Model):
raise ValidationError(organization.add_support_message(message))
return instance
def update_spec(self, spec_data, updated_by):
def update_spec(self, spec_data, updated_by, compute_plan_assignment=None):
try:
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
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(
group=self.context.group,
version=self.context.version,
@ -796,7 +914,14 @@ class ServiceInstance(ServalaModelMixin, models.Model):
body=patch_body,
)
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:
if e.status == 404:
message = _(

View file

@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from servala.core.models import (
CloudProvider,
ComputePlanAssignment,
ControlPlane,
Service,
ServiceCategory,
@ -56,6 +57,34 @@ class ControlPlaneSelectForm(forms.Form):
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):
name = forms.CharField(required=False, label=_("Name"))
service = forms.ModelChoiceField(

View file

@ -2,7 +2,6 @@ import json
from django import forms
from django.core.exceptions import ValidationError
from django.forms.widgets import NumberInput
class DynamicArrayWidget(forms.Widget):
@ -217,21 +216,3 @@ class DynamicArrayField(forms.JSONField):
raise ValidationError(
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

View file

@ -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>

View file

@ -51,6 +51,29 @@
<dd class="col-sm-8">
{{ instance.context.control_plane.name }}
</dd>
{% if compute_plan_assignment %}
<dt class="col-sm-4">{% translate "Compute Plan" %}</dt>
<dd class="col-sm-8">
{{ compute_plan_assignment.compute_plan.name }}
<span class="badge bg-{% if compute_plan_assignment.sla == 'guaranteed' %}success{% else %}secondary{% endif %} ms-1">
{{ compute_plan_assignment.get_sla_display }}
</span>
<div class="text-muted small mt-1">
<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>
<dd class="col-sm-8">
{{ instance.created_by|default:"-" }}

View file

@ -30,6 +30,33 @@
{% endpartialdef %}
{% block content %}
<section class="section">
<form class="form form-vertical crd-form" method="post" novalidate>
{% csrf_token %}
{% if plan_form.errors or form.errors or custom_form.errors %}
<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>
{% endif %}
<!-- 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">
@ -39,5 +66,6 @@
<div id="service-form">{% partial service-form %}</div>
{% endif %}
</div>
</form>
</section>
{% endblock content %}

View file

@ -124,12 +124,61 @@
</div>
</div>
{% endif %}
<!-- Service Form (unchanged) -->
<form class="form form-vertical crd-form" method="post" novalidate>
{% csrf_token %}
{% if plan_form.errors or service_form.errors or custom_service_form.errors %}
<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>
</form>
</section>
{% endblock content %}
{% block extra_js %}

View 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>

View file

@ -1,20 +1,16 @@
{% load i18n %}
{% load get_field %}
{% load static %}
<form class="form form-vertical crd-form"
method="post"
{% if form_action %}action="{{ form_action }}"{% endif %}>
{% csrf_token %}
{% include "frontend/forms/errors.html" %}
{% if form and expert_form and not hide_expert_mode %}
{% include "frontend/forms/errors.html" %}
{% if form and expert_form and not hide_expert_mode %}
<div class="mb-3 text-end">
<a href="#"
class="text-muted small"
id="expert-mode-toggle"
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
</div>
{% endif %}
<div id="custom-form-container"
{% endif %}
<div id="custom-form-container"
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}">
{% if form and form.context %}{{ form.context }}{% endif %}
{% if form and form.get_fieldsets|length == 1 %}
@ -80,8 +76,8 @@
{% endfor %}
</div>
{% endif %}
</div>
{% if expert_form and not hide_expert_mode %}
</div>
{% if expert_form and not hide_expert_mode %}
<div id="expert-form-container"
class="expert-crd-form"
style="{% if form %}display:none{% endif %}">
@ -128,21 +124,20 @@
{% endfor %}
</div>
</div>
{% endif %}
{% if 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">
{% 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>
</form>
</div>
<script defer src="{% static 'js/bootstrap-tabs.js' %}"></script>
{% if form and not hide_expert_mode %}
<script defer src="{% static 'js/expert-mode.js' %}"></script>

View file

@ -14,6 +14,7 @@ from servala.core.models import (
ServiceOffering,
)
from servala.frontend.forms.service import (
ComputePlanSelectionForm,
ControlPlaneSelectForm,
ServiceFilterForm,
ServiceInstanceDeleteForm,
@ -152,6 +153,13 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
control_plane=self.selected_plane, service_offering=self.object
).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):
return {
"initial": {
@ -205,6 +213,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context["select_form"] = self.select_form
context["has_control_planes"] = self.planes.exists()
context["selected_plane"] = self.selected_plane
context["context_object"] = self.context_object
context["hide_expert_mode"] = self.hide_expert_mode
if self.request.method == "POST":
if self.is_custom_form:
@ -222,6 +231,17 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
if self.selected_plane and self.selected_plane.wildcard_dns:
context["wildcard_dns"] = self.selected_plane.wildcard_dns
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
def post(self, request, *args, **kwargs):
@ -232,6 +252,9 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context["form_error"] = True
return self.render_to_response(context)
if not self.plan_form.is_valid():
return self.render_to_response(context)
if self.is_custom_form:
form = self.get_custom_instance_form()
else:
@ -245,7 +268,11 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
)
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:
service_instance = ServiceInstance.create_instance(
organization=self.request.organization,
@ -253,16 +280,22 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context=self.context_object,
created_by=request.user,
spec_data=form.get_nested_data().get("spec"),
compute_plan_assignment=compute_plan_assignment,
)
return redirect(service_instance.urls.base)
except ValidationError as e:
form.add_error(None, e.message or str(e))
except Exception as e:
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)
if self.is_custom_form:
context["custom_service_form"] = form
else:
context["service_form"] = form
return self.render_to_response(context)
@ -332,6 +365,18 @@ class ServiceInstanceDetailView(
context["has_delete_permission"] = self.request.user.has_perm(
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
def get_nested_spec(self):
@ -475,6 +520,17 @@ class ServiceInstanceUpdateView(
kwargs.pop("data", None)
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
def is_custom_form(self):
# Note: "custom form" = user-friendly, subset of fields
@ -489,7 +545,7 @@ class ServiceInstanceUpdateView(
else:
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_invalid(form)
@ -506,14 +562,29 @@ class ServiceInstanceUpdateView(
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
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.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)
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)
else:
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
def _deep_merge(self, base, update):
@ -533,7 +604,17 @@ class ServiceInstanceUpdateView(
current_spec = dict(self.object.spec) if self.object.spec else {}
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(
self.request,
_("Service instance '{name}' updated successfully.").format(
@ -546,7 +627,7 @@ class ServiceInstanceUpdateView(
return self.form_invalid(form)
except Exception as e:
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)
return self.form_invalid(form)

View file

@ -9,7 +9,12 @@ const initializeFqdnGeneration = (prefix) => {
let isArrayField = true;
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 {
fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`);
isArrayField = false;
@ -53,10 +58,14 @@ const initializeFqdnGeneration = (prefix) => {
}
}
document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")});
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form') {
const runFqdnInit = () => {
initializeFqdnGeneration("custom");
initializeFqdnGeneration("expert");
}
}
document.addEventListener('DOMContentLoaded', () => {
runFqdnInit()
});
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form') runFqdnInit()
});

View 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"

View file

@ -10,6 +10,28 @@ from servala.core.forms import ServiceDefinitionAdminForm
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():
crd = Mock(spec=ControlPlaneCRD)
@ -38,6 +60,15 @@ def test_custom_model_form_class_returns_class_when_form_config_exists():
app_label = "test"
crd.django_model = TestModel
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
)
@ -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.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
View file

@ -47,11 +47,11 @@ wheels = [
[[package]]
name = "asgiref"
version = "3.11.0"
version = "3.10.0"
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 = [
{ 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]]
@ -86,30 +86,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.42.0"
version = "1.40.74"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/9b/eef5346ce3148bf4856318fe629e0fd7f6dd73ffd55ea08e316c967f8af0/boto3-1.42.0.tar.gz", hash = "sha256:9c67729a6112b7dced521ea70b0369fba138e89852b029a7876041cd1460c084", size = 112854, upload-time = "2025-12-01T02:31:09.157Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/37/0db5fc46548b347255310893f1a47971a1d8eb0dbc46dfb5ace8a1e7d45e/boto3-1.40.74.tar.gz", hash = "sha256:484e46bf394b03a7c31b34f90945ebe1390cb1e2ac61980d128a9079beac87d4", size = 111592, upload-time = "2025-11-14T20:29:10.991Z" }
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]]
name = "botocore"
version = "1.41.6"
version = "1.40.74"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/04/8e8ca38631eeb499a1099dcc2a081faaea399f9d46080720540ff54ec609/botocore-1.41.6.tar.gz", hash = "sha256:08fe47e9b306f4436f5eaf6a02cb6d55c7745d13d2d093ce5d917d3ef3d3df75", size = 14770281, upload-time = "2025-12-01T02:30:54.286Z" }
sdist = { url = "https://files.pythonhosted.org/packages/81/dc/0412505f05286f282a75bb0c650e525ddcfaf3f6f1a05cd8e99d32a2db06/botocore-1.40.74.tar.gz", hash = "sha256:57de0b9ffeada06015b3c7e5186c77d0692b210d9e5efa294f3214df97e2f8ee", size = 14452479, upload-time = "2025-11-14T20:29:00.949Z" }
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]]
@ -345,15 +345,15 @@ wheels = [
[[package]]
name = "django-allauth"
version = "65.13.1"
version = "65.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ 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 = [
{ 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]]
@ -489,26 +489,26 @@ wheels = [
[[package]]
name = "flake8-bugbear"
version = "25.11.29"
version = "25.10.21"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ 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 = [
{ 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]]
name = "flake8-pyproject"
version = "1.2.4"
version = "1.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flake8" },
]
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]]
@ -1001,39 +1001,39 @@ wheels = [
[[package]]
name = "rpds-py"
version = "0.30.0"
version = "0.29.0"
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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
@ -1059,27 +1059,27 @@ wheels = [
[[package]]
name = "s3transfer"
version = "0.16.0"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ 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 = [
{ 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]]
name = "sentry-sdk"
version = "2.46.0"
version = "2.45.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ 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 = [
{ 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]
@ -1134,7 +1134,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "cryptography", specifier = ">=46.0.3" },
{ 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-fernet-encrypted-fields", specifier = ">=0.3.1" },
{ name = "django-jsonform", specifier = ">=2.23.2" },
@ -1148,7 +1148,7 @@ requires-dist = [
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "requests", specifier = ">=2.32.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" },
]
@ -1159,7 +1159,7 @@ dev = [
{ name = "coverage", specifier = ">=7.12.0" },
{ name = "djlint", specifier = ">=1.36.4" },
{ 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 = "isort", specifier = ">=7.0.0" },
{ name = "pytest", specifier = ">=9.0.1" },
@ -1179,11 +1179,11 @@ wheels = [
[[package]]
name = "sqlparse"
version = "0.5.4"
version = "0.5.3"
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 = [
{ 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]]