diff --git a/.forgejo/workflows/build-deploy-prod.yaml b/.forgejo/workflows/build-deploy-prod.yaml index ddaeb1c..aa9315d 100644 --- a/.forgejo/workflows/build-deploy-prod.yaml +++ b/.forgejo/workflows/build-deploy-prod.yaml @@ -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 diff --git a/.forgejo/workflows/build-deploy-staging.yaml b/.forgejo/workflows/build-deploy-staging.yaml index 8f438cd..93c77b2 100644 --- a/.forgejo/workflows/build-deploy-staging.yaml +++ b/.forgejo/workflows/build-deploy-staging.yaml @@ -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 diff --git a/.forgejo/workflows/docs.yaml b/.forgejo/workflows/docs.yaml index b1e5fe5..0b6c77c 100644 --- a/.forgejo/workflows/docs.yaml +++ b/.forgejo/workflows/docs.yaml @@ -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 diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index a2577d9..7dbe5e0 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -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.2 with: token: ${{ secrets.RENOVATE_TOKEN }} env: diff --git a/.forgejo/workflows/tests.yaml b/.forgejo/workflows/tests.yaml index b2cddd0..e3900b3 100644 --- a/.forgejo/workflows/tests.yaml +++ b/.forgejo/workflows/tests.yaml @@ -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 diff --git a/README.md b/README.md index eaa1cdd..fa0e56a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The Servala Self-Service Portal -Latest release: 2025.11.17-0 +Latest release: 2025.11.13-0 ## Documentation diff --git a/docs/modules/ROOT/pages/web-portal-changelog.adoc b/docs/modules/ROOT/pages/web-portal-changelog.adoc index 0c5de9c..3eef510 100644 --- a/docs/modules/ROOT/pages/web-portal-changelog.adoc +++ b/docs/modules/ROOT/pages/web-portal-changelog.adoc @@ -1,41 +1,5 @@ = Portal Changelog -== 2025.11.17-0 - -=== API -* Exoscale offboarding MVP (link:https://servala.app.codey.ch/servala/servala-portal/pulls/282[#282]) - -=== UI/UX -* Allow admins to disable the expert mode form (link:https://servala.app.codey.ch/servala/servala-portal/pulls/296[#296]) -* Support single (non-array) FQDN values (link:https://servala.app.codey.ch/servala/servala-portal/pulls/295[#295]) -* "View Availability" is now "Get It" (link:https://servala.app.codey.ch/servala/servala-portal/pulls/285[#285]) -* Add "open" button to instances with FQDN (link:https://servala.app.codey.ch/servala/servala-portal/pulls/283[#283]) -* Hide billing addresses (link:https://servala.app.codey.ch/servala/servala-portal/pulls/281[#281]) -* Custom form configuration (link:https://servala.app.codey.ch/servala/servala-portal/pulls/268[#268]) -* Skip offering selection if there is only one (link:https://servala.app.codey.ch/servala/servala-portal/pulls/273[#273]) -* Make it more clear how to register an account (link:https://servala.app.codey.ch/servala/servala-portal/pulls/270[#270]) - -=== dependencies -* Update dependency django-template-partials to >=25.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/297[#297]) -* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/298[#298]) -* Update dependency pytest to >=9.0.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/284[#284]) -* Update Python to 3.14 tag (link:https://servala.app.codey.ch/servala/servala-portal/pulls/272[#272]) -* Update dependency django-fernet-encrypted-fields to >=0.3.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/278[#278]) -* Update https://github.com/renovatebot/github-action action to v44.0.2 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/279[#279]) -* Update dependency sentry-sdk to >=2.44.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/280[#280]) -* Update dependency coverage to >=7.11.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/274[#274]) -* Update dependency pytest to v9 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/276[#276]) -* Update dependency black to >=25.11.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/277[#277]) -* Update https://github.com/renovatebot/github-action action to v44 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/275[#275]) -* Update dependency django to v5.2.8 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/271[#271]) -* Update dependency django-allauth to >=65.13.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/265[#265]) -* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/266[#266]) -* Update https://github.com/renovatebot/github-action action to v43.0.20 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/267[#267]) -* Update https://github.com/renovatebot/github-action action to v43.0.19 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/259[#259]) -* Update dependency node to v24 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/260[#260]) -* Update dependency sentry-sdk to >=2.43.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/261[#261]) - - == 2025.11.13-0 === UI/UX diff --git a/pyproject.toml b/pyproject.toml index 3622227..a9f80c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,12 @@ name = "servala" version = "0.0.0" description = "Servala portal server and frontend" readme = "README.md" -requires-python = ">=3.14.1" +requires-python = ">=3.14.0" dependencies = [ "argon2-cffi>=25.1.0", "cryptography>=46.0.3", - "django==5.2.9", - "django-allauth>=65.13.1", + "django==5.2.8", + "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.47.0", + "sentry-sdk[django]>=2.44.0", "urlman>=2.0.2", ] @@ -30,11 +30,11 @@ dependencies = [ dev = [ "black>=25.11.0", "bumpver>=2025.1131", - "coverage>=7.12.0", + "coverage>=7.11.3", "djlint>=1.36.4", "flake8>=7.3.0", - "flake8-bugbear>=25.11.29", - "flake8-pyproject>=1.2.4", + "flake8-bugbear>=25.10.21", + "flake8-pyproject>=1.2.3", "isort>=7.0.0", "pytest>=9.0.1", "pytest-cov>=7.0.0", @@ -61,7 +61,7 @@ testpaths = "src/tests" pythonpath = "src" [tool.bumpver] -current_version = "2025.11.17-0" +current_version = "2025.11.13-0" version_pattern = "YYYY.0M.0D-INC0" commit_message = "bump version {old_version} -> {new_version}" tag_message = "{new_version}" diff --git a/src/servala/__about__.py b/src/servala/__about__.py index d6db270..acfbf71 100644 --- a/src/servala/__about__.py +++ b/src/servala/__about__.py @@ -1 +1 @@ -__version__ = "2025.11.17-0" +__version__ = "2025.11.13-0" diff --git a/src/servala/api/views.py b/src/servala/api/views.py index 015e091..5fdb91a 100644 --- a/src/servala/api/views.py +++ b/src/servala/api/views.py @@ -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}"] diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 29ddb2f..60fe147 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -9,11 +9,8 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm from servala.core.models import ( BillingEntity, CloudProvider, - ComputePlan, - ComputePlanAssignment, ControlPlane, ControlPlaneCRD, - OdooObjectCache, Organization, OrganizationInvitation, OrganizationMembership, @@ -272,19 +269,6 @@ 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): @@ -379,21 +363,15 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin): @admin.register(ServiceInstance) class ServiceInstanceAdmin(admin.ModelAdmin): - list_display = ( - "name", - "organization", - "context", - "compute_plan_assignment", - "created_by", - ) - list_filter = ("organization", "context", "compute_plan_assignment") + list_display = ("name", "organization", "context", "created_by") + list_filter = ("organization", "context") search_fields = ( "name", "organization__name", "context__service_offering__service__name", ) readonly_fields = ("name", "organization", "context") - autocomplete_fields = ("organization", "context", "compute_plan_assignment") + autocomplete_fields = ("organization", "context") def get_readonly_fields(self, request, obj=None): if obj: # If this is an edit (not a new instance) @@ -412,10 +390,6 @@ class ServiceInstanceAdmin(admin.ModelAdmin): ) }, ), - ( - _("Plan"), - {"fields": ("compute_plan_assignment",)}, - ), ) @@ -446,138 +420,3 @@ 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") diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd/forms.py index b022a5e..659684e 100644 --- a/src/servala/core/crd/forms.py +++ b/src/servala/core/crd/forms.py @@ -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,17 +69,13 @@ 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): @@ -99,15 +88,6 @@ 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] @@ -355,19 +335,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: @@ -439,11 +406,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]: diff --git a/src/servala/core/migrations/0016_computeplan_and_more.py b/src/servala/core/migrations/0016_computeplan_and_more.py deleted file mode 100644 index a64bf50..0000000 --- a/src/servala/core/migrations/0016_computeplan_and_more.py +++ /dev/null @@ -1,309 +0,0 @@ -# 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), - ), - ] diff --git a/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py b/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py deleted file mode 100644 index 38bfd46..0000000 --- a/src/servala/core/migrations/0017_add_unit_and_convert_odoo_ids_to_charfield.py +++ /dev/null @@ -1,68 +0,0 @@ -# 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", - ), - ), - ] diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 4cb8fd7..4c23f18 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -1,4 +1,3 @@ -from .odoo_cache import OdooObjectCache from .organization import ( BillingEntity, Organization, @@ -7,10 +6,6 @@ from .organization import ( OrganizationOrigin, OrganizationRole, ) -from .plan import ( - ComputePlan, - ComputePlanAssignment, -) from .service import ( CloudProvider, ControlPlane, @@ -26,11 +21,8 @@ from .user import User __all__ = [ "BillingEntity", "CloudProvider", - "ComputePlan", - "ComputePlanAssignment", "ControlPlane", "ControlPlaneCRD", - "OdooObjectCache", "Organization", "OrganizationInvitation", "OrganizationMembership", @@ -38,8 +30,8 @@ __all__ = [ "OrganizationRole", "Service", "ServiceCategory", - "ServiceDefinition", "ServiceInstance", + "ServiceDefinition", "ServiceOffering", "User", ] diff --git a/src/servala/core/models/odoo_cache.py b/src/servala/core/models/odoo_cache.py deleted file mode 100644 index 8f0bd3f..0000000 --- a/src/servala/core/models/odoo_cache.py +++ /dev/null @@ -1,124 +0,0 @@ -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"]) diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py deleted file mode 100644 index 0e493af..0000000 --- a/src/servala/core/models/plan.py +++ /dev/null @@ -1,165 +0,0 @@ -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 -) diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 3465d54..ab7b76f 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -170,29 +170,6 @@ 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") @@ -636,15 +613,6 @@ 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") @@ -686,60 +654,6 @@ 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: @@ -794,15 +708,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): @classmethod @transaction.atomic - def create_instance( - cls, - name, - organization, - context, - created_by, - spec_data, - compute_plan_assignment=None, - ): + def create_instance(cls, name, organization, context, created_by, spec_data): # Ensure the namespace exists context.control_plane.get_or_create_namespace(organization) try: @@ -811,7 +717,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): organization=organization, created_by=created_by, context=context, - compute_plan_assignment=compute_plan_assignment, ) except IntegrityError: message = _( @@ -822,11 +727,6 @@ 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"] = {} @@ -844,13 +744,6 @@ 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 @@ -888,23 +781,12 @@ class ServiceInstance(ServalaModelMixin, models.Model): raise ValidationError(organization.add_support_message(message)) return instance - def update_spec(self, spec_data, updated_by, compute_plan_assignment=None): + def update_spec(self, spec_data, updated_by): 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, @@ -914,14 +796,7 @@ class ServiceInstance(ServalaModelMixin, models.Model): body=patch_body, ) self._clear_kubernetes_caches() - - 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() + self.save() # Updates updated_at timestamp except ApiException as e: if e.status == 404: message = _( diff --git a/src/servala/frontend/forms/service.py b/src/servala/frontend/forms/service.py index 169d6ea..23325f3 100644 --- a/src/servala/frontend/forms/service.py +++ b/src/servala/frontend/forms/service.py @@ -4,7 +4,6 @@ from django.utils.translation import gettext_lazy as _ from servala.core.models import ( CloudProvider, - ComputePlanAssignment, ControlPlane, Service, ServiceCategory, @@ -57,34 +56,6 @@ 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( diff --git a/src/servala/frontend/forms/widgets.py b/src/servala/frontend/forms/widgets.py index 99b7a59..d67030f 100644 --- a/src/servala/frontend/forms/widgets.py +++ b/src/servala/frontend/forms/widgets.py @@ -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 diff --git a/src/servala/frontend/templates/frontend/forms/number_input_with_addon.html b/src/servala/frontend/templates/frontend/forms/number_input_with_addon.html deleted file mode 100644 index 4fe3b54..0000000 --- a/src/servala/frontend/templates/frontend/forms/number_input_with_addon.html +++ /dev/null @@ -1,11 +0,0 @@ -
- - {{ widget.addon_text }} -
diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html index 5c72de6..948a2df 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_detail.html @@ -51,29 +51,6 @@
{{ instance.context.control_plane.name }}
- {% if compute_plan_assignment %} -
{% translate "Compute Plan" %}
-
- {{ compute_plan_assignment.compute_plan.name }} - - {{ compute_plan_assignment.get_sla_display }} - -
- {{ compute_plan_assignment.compute_plan.cpu_limits }} vCPU - - {{ compute_plan_assignment.compute_plan.memory_limits }} RAM - - CHF {{ compute_plan_assignment.price }}/{{ compute_plan_assignment.get_unit_display }} -
-
- {% endif %} - {% if storage_plan %} -
{% translate "Storage Plan" %}
-
- CHF {{ storage_plan.price_per_gib }} per GiB -
{% translate "Billed separately based on disk usage" %}
-
- {% endif %}
{% translate "Created By" %}
{{ instance.created_by|default:"-" }} diff --git a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html index 021be3c..17b9a51 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html @@ -30,42 +30,14 @@ {% endpartialdef %} {% block content %}
-
- {% csrf_token %} - {% if plan_form.errors or form.errors or custom_form.errors %} -
-
- {% 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 %} -
+
+ {% if not form and not custom_form %} + + {% else %} +
{% partial service-form %}
{% endif %} - - {% if plan_form %} -
-
-
{% translate "Compute Plan" %}
-
-
- {% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %} -
-
- {% endif %} - -
- {% if not form and not custom_form %} - - {% else %} -
{% partial service-form %}
- {% endif %} -
- +
{% endblock content %} diff --git a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html index 39b69a8..927c6e3 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -124,61 +124,12 @@ {% endif %} -
- {% csrf_token %} - {% if plan_form.errors or service_form.errors or custom_service_form.errors %} -
-
- {% 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 %} -
-
- {% endif %} - - {% if context_object %} - {% if not has_available_plans %} -
-
- -
-
- {% else %} -
-
-
-
-
{% translate "Select Compute Plan" %}
-
-
- {% include "includes/plan_selection.html" with plan_form=plan_form storage_plan=storage_plan %} -
-
-
-
- {% endif %} - {% endif %} - -
-
-
-
{% partial service-form %}
-
-
+ +
+
+
{% partial service-form %}
- +
{% endblock content %} {% block extra_js %} diff --git a/src/servala/frontend/templates/includes/plan_selection.html b/src/servala/frontend/templates/includes/plan_selection.html deleted file mode 100644 index 5ca113a..0000000 --- a/src/servala/frontend/templates/includes/plan_selection.html +++ /dev/null @@ -1,126 +0,0 @@ -{% load i18n static %} - -
- {% if plan_form %} - -
- {% for assignment in plan_form.fields.compute_plan_assignment.queryset %} - - {% endfor %} -
-
- -
- {% for assignment in plan_form.fields.compute_plan_assignment.queryset %} -
-
-
-
{{ assignment.compute_plan.name }}
- - {{ assignment.get_sla_display }} - -
-
-
CHF {{ assignment.price }}
-
{% trans "per" %} {{ assignment.get_unit_display }}
-
-
-
- {{ assignment.compute_plan.cpu_limits }} {% trans "vCPU" %} - - {{ assignment.compute_plan.memory_limits }} {% trans "RAM" %} -
-
- {% endfor %} -
-
-
-
-
-
-
- -
-
-
{% trans "Storage" %}
- {% trans "Billed separately based on disk usage" %} -
-
- {% if storage_plan %} -
-
CHF {{ storage_plan.price_per_gib }}
-
{% trans "per GiB / hour" %}
-
- {% else %} -
- {% trans "Included" %} -
- {% endif %} -
- {% if storage_plan %}
{% endif %} -
-
- {% else %} -
{% trans "No compute plans available for this service offering." %}
- {% endif %} -
- diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index f54b1ae..5f289b7 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -1,64 +1,26 @@ {% load i18n %} {% load get_field %} {% load static %} -{% include "frontend/forms/errors.html" %} -{% if form and expert_form and not hide_expert_mode %} - -{% endif %} -
- {% if form and form.context %}{{ form.context }}{% endif %} - {% if form and form.get_fieldsets|length == 1 %} - {# Single fieldset - render without tabs #} - {% for fieldset in form.get_fieldsets %} -
- {% for field in fieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} - {% for subfieldset in fieldset.fieldsets %} - {% if subfieldset.fields %} -
-

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} -
- {% endif %} - {% endfor %} -
- {% endfor %} - {% elif form %} - {# Multiple fieldsets or auto-generated form - render with tabs #} - -
- {% for fieldset in form.get_fieldsets %} -
+
{% for field in fieldset.fields %} {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} {% endfor %} @@ -74,70 +36,113 @@ {% endfor %}
{% endfor %} + {% elif form %} + {# Multiple fieldsets or auto-generated form - render with tabs #} + +
+ {% for fieldset in form.get_fieldsets %} +
+ {% for field in fieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% for subfieldset in fieldset.fieldsets %} + {% if subfieldset.fields %} +
+

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
+ {% endif %} +
+ {% if expert_form and not hide_expert_mode %} +
+ {% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %} + +
+ {% for fieldset in expert_form.get_fieldsets %} +
+ {% for field in fieldset.fields %} + {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} + {% for subfieldset in fieldset.fieldsets %} + {% if subfieldset.fields %} +
+

{{ subfieldset.title }}

+ {% for field in subfieldset.fields %} + {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} + {% endfor %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
{% endif %} -
-{% if expert_form and not hide_expert_mode %} -
- {% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %} - -
- {% for fieldset in expert_form.get_fieldsets %} -
- {% for field in fieldset.fields %} - {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} - {% for subfieldset in fieldset.fieldsets %} - {% if subfieldset.fields %} -
-

{{ subfieldset.title }}

- {% for field in subfieldset.fields %} - {% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %} - {% endfor %} -
- {% endif %} - {% endfor %} -
- {% endfor %} -
+ {% if form %} + + {% endif %} +
+ {# browser form validation fails when there are fields missing/invalid that are hidden #} +
-{% endif %} -{% if form %} - -{% endif %} -
- {# browser form validation fails when there are fields missing/invalid that are hidden #} - -
+ {% if form and not hide_expert_mode %} diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index b9f7d56..c26194d 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -14,7 +14,6 @@ from servala.core.models import ( ServiceOffering, ) from servala.frontend.forms.service import ( - ComputePlanSelectionForm, ControlPlaneSelectForm, ServiceFilterForm, ServiceInstanceDeleteForm, @@ -153,13 +152,6 @@ 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": { @@ -213,7 +205,6 @@ 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: @@ -231,17 +222,6 @@ 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): @@ -252,9 +232,6 @@ 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: @@ -268,11 +245,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView ) return self.render_to_response(context) - if form.is_valid() and self.plan_form.is_valid(): - compute_plan_assignment = self.plan_form.cleaned_data[ - "compute_plan_assignment" - ] - + if form.is_valid(): try: service_instance = ServiceInstance.create_instance( organization=self.request.organization, @@ -280,22 +253,16 @@ 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( - _("Error creating instance: {error}.").format(error=str(e)) + _(f"Error creating instance: {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) @@ -365,18 +332,6 @@ 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): @@ -520,17 +475,6 @@ 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 @@ -545,7 +489,7 @@ class ServiceInstanceUpdateView( else: form = self.get_form() - if form.is_valid() and self.plan_form.is_valid(): + if form.is_valid(): return self.form_valid(form) return self.form_invalid(form) @@ -562,29 +506,14 @@ 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: - # Use the form with errors if passed, otherwise create new - context["custom_form"] = form_from_kwargs or self.get_custom_form() + context["custom_form"] = 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): @@ -604,17 +533,7 @@ class ServiceInstanceUpdateView( current_spec = dict(self.object.spec) if self.object.spec else {} spec_data = self._deep_merge(current_spec, spec_data) - 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, - ) + self.object.update_spec(spec_data=spec_data, updated_by=self.request.user) messages.success( self.request, _("Service instance '{name}' updated successfully.").format( @@ -627,7 +546,7 @@ class ServiceInstanceUpdateView( return self.form_invalid(form) except Exception as e: error_message = self.organization.add_support_message( - _("Error updating instance: {error}.").format(error=str(e)) + _(f"Error updating instance: {str(e)}.") ) form.add_error(None, error_message) return self.form_invalid(form) diff --git a/src/servala/static/css/plan-selection.css b/src/servala/static/css/plan-selection.css deleted file mode 100644 index 8ce099c..0000000 --- a/src/servala/static/css/plan-selection.css +++ /dev/null @@ -1,140 +0,0 @@ -.plan-selection .plan-dropdown { - position: relative; -} - -.plan-selection .plan-dropdown-toggle { - cursor: pointer; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - border: 1px solid #dee2e6; - border-radius: 0.375rem; - background: #fff; -} - -.plan-selection .plan-dropdown-toggle:hover { - border-color: #a1afdf; -} - -.plan-selection .plan-dropdown-toggle:focus { - color: #607080; - background-color: #fff; - border-color: #a1afdf; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25); -} - -.plan-selection .plan-dropdown-toggle[aria-expanded="true"] { - border-color: #a1afdf; - box-shadow: 0 0 0 0.25rem rgba(67, 94, 190, 0.25); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - border-bottom-color: transparent; -} - -.plan-selection .plan-dropdown-toggle .dropdown-arrow { - transition: transform 0.2s ease; -} - -.plan-selection .plan-dropdown-toggle[aria-expanded="true"] .dropdown-arrow { - transform: rotate(180deg); -} - -.plan-selection .plan-dropdown-menu { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 1000; - display: none; - margin-top: -1px; - background: #fff; - border: 1px solid #a1afdf; - border-top: none; - border-radius: 0 0 0.375rem 0.375rem; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - max-height: 400px; - overflow-y: auto; -} - -.plan-selection .plan-dropdown-menu.show { - display: block; -} - -.plan-selection .plan-option { - padding: 0.75rem 1rem; - cursor: pointer; - border-bottom: 1px solid #f0f0f0; - transition: background-color 0.15s ease; -} - -.plan-selection .plan-option:last-child { - border-bottom: none; -} - -.plan-selection .plan-option:hover, -.plan-selection .plan-option.focused { - background-color: #f8f9fa; -} - -.plan-selection .plan-option.selected { - background-color: #f0f4ff; -} - -.plan-selection .plan-option.selected.focused { - background-color: #e0e8ff; -} - -.plan-selection .plan-content { - padding: 0.75rem 1rem; -} - -.plan-selection .plan-header 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 .plan-specs { - margin-top: 0.5rem; -} - -.plan-selection .form-check-input { - position: absolute; - opacity: 0; - pointer-events: none; -} - -.plan-selection .storage-card { - margin-top: 0.75rem; - border: 1px solid #dee2e6; - border-radius: 0.375rem; - background: #fff; -} - -.plan-selection .storage-card .plan-content { - background-color: #f8f9fa; - border-radius: 0.375rem; -} - -.plan-selection .storage-card .storage-icon { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: #e9ecef; - border-radius: 0.25rem; - color: #6c757d; -} - -.plan-selection .storage-card .storage-icon .bi::before { - vertical-align: top; -} diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js index 43805bf..0996bda 100644 --- a/src/servala/static/js/fqdn.js +++ b/src/servala/static/js/fqdn.js @@ -9,12 +9,7 @@ const initializeFqdnGeneration = (prefix) => { let isArrayField = true; if (fqdnFieldContainer) { - 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 - } + let fqdnField = fqdnFieldContainer.querySelector('input.array-item-input'); } else { fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`); isArrayField = false; @@ -58,14 +53,10 @@ const initializeFqdnGeneration = (prefix) => { } } -const runFqdnInit = () => { - initializeFqdnGeneration("custom"); - initializeFqdnGeneration("expert"); -} - -document.addEventListener('DOMContentLoaded', () => { - runFqdnInit() -}); +document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")}); document.body.addEventListener('htmx:afterSwap', function(event) { - if (event.detail.target.id === 'service-form') runFqdnInit() + if (event.detail.target.id === 'service-form') { + initializeFqdnGeneration("custom"); + initializeFqdnGeneration("expert"); + } }); diff --git a/src/servala/static/js/plan-selection.js b/src/servala/static/js/plan-selection.js deleted file mode 100644 index 4b4c014..0000000 --- a/src/servala/static/js/plan-selection.js +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Plan Selection Dropdown - * A custom dropdown component for selecting compute plans with keyboard navigation. - */ -(function() { - function initPlanSelection(container) { - const planDropdownToggle = container.querySelector('#plan-dropdown-toggle'); - const planDropdownMenu = container.querySelector('#plan-dropdown-menu'); - const planOptions = Array.from(container.querySelectorAll('.plan-option')); - const radioInputName = container.dataset.radioName; - const radioInputs = container.querySelectorAll('input[name="' + radioInputName + '"]'); - let focusedIndex = -1; - - if (!planDropdownToggle || planOptions.length === 0) { - return; - } - - // Update the display in the toggle button based on selected plan - function updateSelectedDisplay(data) { - const nameEl = container.querySelector('#selected-plan-name'); - const priceEl = container.querySelector('#selected-plan-price'); - const unitEl = container.querySelector('#selected-plan-unit'); - const cpuEl = container.querySelector('#selected-plan-cpu'); - const memoryEl = container.querySelector('#selected-plan-memory'); - const slaBadge = container.querySelector('#selected-plan-sla'); - - if (nameEl) nameEl.textContent = data.planName; - if (priceEl) priceEl.textContent = 'CHF ' + data.planPrice; - if (unitEl) unitEl.textContent = data.planUnit; - if (cpuEl) cpuEl.textContent = data.cpuLimits; - if (memoryEl) memoryEl.textContent = data.memoryLimits; - - if (slaBadge) { - slaBadge.textContent = data.planSlaDisplay; - slaBadge.className = 'badge bg-' + (data.planSla === 'guaranteed' ? 'success' : 'secondary'); - } - } - - // Update CPU/memory fields in the form - function updateFormFields(data) { - 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 = data.cpuLimits; - if (memoryLimit) memoryLimit.value = data.memoryLimits; - if (cpuRequest) cpuRequest.value = data.cpuRequests; - if (memoryRequest) memoryRequest.value = data.memoryRequests; - } - - // Select a plan by value - function selectPlan(value) { - // Update the hidden radio input - radioInputs.forEach(radio => { - radio.checked = (radio.value === value); - }); - - // Update visual selection - planOptions.forEach(option => { - option.classList.toggle('selected', option.dataset.value === value); - }); - - // Find selected option and update display - const selectedOption = container.querySelector('.plan-option[data-value="' + value + '"]'); - if (selectedOption) { - const data = { - planName: selectedOption.dataset.planName, - planSla: selectedOption.dataset.planSla, - planSlaDisplay: selectedOption.dataset.planSlaDisplay, - planPrice: selectedOption.dataset.planPrice, - planUnit: selectedOption.dataset.planUnit, - cpuLimits: selectedOption.dataset.cpuLimits, - memoryLimits: selectedOption.dataset.memoryLimits, - cpuRequests: selectedOption.dataset.cpuRequests, - memoryRequests: selectedOption.dataset.memoryRequests - }; - updateSelectedDisplay(data); - updateFormFields(data); - } - } - - // Update focused option visually - function updateFocusedOption(newIndex) { - planOptions.forEach(opt => opt.classList.remove('focused')); - if (newIndex >= 0 && newIndex < planOptions.length) { - focusedIndex = newIndex; - planOptions[focusedIndex].classList.add('focused'); - // Scroll into view if needed - planOptions[focusedIndex].scrollIntoView({ block: 'nearest' }); - } - } - - // Get index of currently selected option - function getSelectedIndex() { - return planOptions.findIndex(opt => opt.classList.contains('selected')); - } - - // Open dropdown - function openDropdown() { - planDropdownToggle.setAttribute('aria-expanded', 'true'); - planDropdownMenu.classList.add('show'); - // Set focus to selected option or first option - const selectedIdx = getSelectedIndex(); - updateFocusedOption(selectedIdx >= 0 ? selectedIdx : 0); - } - - // Close dropdown - function closeDropdown() { - planDropdownToggle.setAttribute('aria-expanded', 'false'); - planDropdownMenu.classList.remove('show'); - planOptions.forEach(opt => opt.classList.remove('focused')); - focusedIndex = -1; - } - - // Check if dropdown is open - function isOpen() { - return planDropdownToggle.getAttribute('aria-expanded') === 'true'; - } - - // Toggle dropdown visibility - function toggleDropdown() { - if (isOpen()) { - closeDropdown(); - } else { - openDropdown(); - } - } - - // Event listeners - planDropdownToggle.addEventListener('click', toggleDropdown); - planDropdownToggle.addEventListener('keydown', function(e) { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - if (isOpen() && focusedIndex >= 0) { - selectPlan(planOptions[focusedIndex].dataset.value); - closeDropdown(); - } else { - toggleDropdown(); - } - } else if (e.key === 'Escape') { - closeDropdown(); - } else if (e.key === 'ArrowDown') { - e.preventDefault(); - if (!isOpen()) { - openDropdown(); - } else { - const newIndex = focusedIndex < planOptions.length - 1 ? focusedIndex + 1 : 0; - updateFocusedOption(newIndex); - } - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - if (!isOpen()) { - openDropdown(); - } else { - const newIndex = focusedIndex > 0 ? focusedIndex - 1 : planOptions.length - 1; - updateFocusedOption(newIndex); - } - } - }); - - planOptions.forEach((option, index) => { - option.addEventListener('click', function() { - selectPlan(this.dataset.value); - closeDropdown(); - planDropdownToggle.focus(); - }); - option.addEventListener('mouseenter', function() { - updateFocusedOption(index); - }); - }); - - // Close dropdown when clicking outside - document.addEventListener('click', function(e) { - if (!planDropdownToggle.contains(e.target) && !planDropdownMenu.contains(e.target)) { - closeDropdown(); - } - }); - - // Initialize with currently checked radio - const checkedRadio = container.querySelector('input[name="' + radioInputName + '"]:checked'); - if (checkedRadio) { - selectPlan(checkedRadio.value); - } - } - - function initStorageCostCalculator(container) { - const pricePerGiB = parseFloat(container.dataset.storagePricePerGib) || 0; - const perHourText = container.dataset.perHourText || 'per hour'; - - if (pricePerGiB === 0) { - return; - } - - function setup() { - 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 totalCost = (sizeGiB * pricePerGiB).toFixed(2); - const display = document.getElementById('storage-cost-display'); - if (display && sizeGiB > 0) { - display.innerHTML = ' ' + sizeGiB + ' GiB × CHF ' + pricePerGiB + ' = CHF ' + totalCost + ' ' + perHourText; - } 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) - setup(); - - // 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') { - setup(); - } - }); - } - - // Initialize all plan selection components on the page - function init() { - document.querySelectorAll('.plan-selection').forEach(function(container) { - if (container.dataset.radioName) { - initPlanSelection(container); - initStorageCostCalculator(container); - } - }); - } - - // Run on DOM ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); diff --git a/src/tests/test_compute_plans.py b/src/tests/test_compute_plans.py deleted file mode 100644 index 0317229..0000000 --- a/src/tests/test_compute_plans.py +++ /dev/null @@ -1,199 +0,0 @@ -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" diff --git a/src/tests/test_form_config.py b/src/tests/test_form_config.py index c93f3fb..7188d61 100644 --- a/src/tests/test_form_config.py +++ b/src/tests/test_form_config.py @@ -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,9 +60,18 @@ def test_custom_model_form_class_returns_class_when_form_config_exists(): app_label = "test" crd.django_model = TestModel - result = generate_custom_form_class( - crd.service_definition.form_config, crd.django_model - ) + + if not ( + crd.django_model + and crd.service_definition + and crd.service_definition.form_config + and crd.service_definition.form_config.get("fieldsets") + ): + result = None + else: + result = generate_custom_form_class( + crd.service_definition.form_config, crd.django_model + ) assert result is not None assert hasattr(result, "form_config") @@ -1053,43 +1084,3 @@ def test_empty_values_dont_override_default_configs(): assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"] assert name_field.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" diff --git a/uv.lock b/uv.lock index 0fb8d75..5ff3cfa 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.14.1" +requires-python = ">=3.14.0" [[package]] name = "argon2-cffi" @@ -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]] @@ -226,37 +226,37 @@ wheels = [ [[package]] name = "coverage" -version = "7.12.0" +version = "7.11.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload-time = "2025-11-10T00:13:17.18Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" }, - { url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" }, - { url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" }, - { url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" }, - { url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" }, - { url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" }, - { url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" }, - { url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" }, - { url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" }, - { url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" }, - { url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" }, - { url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" }, - { url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" }, - { url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" }, - { url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" }, - { url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" }, - { url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" }, - { url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload-time = "2025-11-10T00:12:23.089Z" }, + { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload-time = "2025-11-10T00:12:24.863Z" }, + { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload-time = "2025-11-10T00:12:26.553Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638, upload-time = "2025-11-10T00:12:28.555Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972, upload-time = "2025-11-10T00:12:30.246Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147, upload-time = "2025-11-10T00:12:32.195Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995, upload-time = "2025-11-10T00:12:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948, upload-time = "2025-11-10T00:12:36.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770, upload-time = "2025-11-10T00:12:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431, upload-time = "2025-11-10T00:12:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508, upload-time = "2025-11-10T00:12:42.231Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325, upload-time = "2025-11-10T00:12:44.065Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899, upload-time = "2025-11-10T00:12:46.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471, upload-time = "2025-11-10T00:12:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742, upload-time = "2025-11-10T00:12:50.182Z" }, + { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120, upload-time = "2025-11-10T00:12:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229, upload-time = "2025-11-10T00:12:54.667Z" }, + { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642, upload-time = "2025-11-10T00:12:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193, upload-time = "2025-11-10T00:12:58.687Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107, upload-time = "2025-11-10T00:13:00.502Z" }, + { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717, upload-time = "2025-11-10T00:13:02.747Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541, upload-time = "2025-11-10T00:13:04.689Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872, upload-time = "2025-11-10T00:13:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289, upload-time = "2025-11-10T00:13:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398, upload-time = "2025-11-10T00:13:10.734Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435, upload-time = "2025-11-10T00:13:12.712Z" }, + { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload-time = "2025-11-10T00:13:14.908Z" }, ] [[package]] @@ -331,29 +331,29 @@ wheels = [ [[package]] name = "django" -version = "5.2.9" +version = "5.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/a2/933dbbb3dd9990494960f6e64aca2af4c0745b63b7113f59a822df92329e/django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", size = 10849032, upload-time = "2025-11-05T14:07:32.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3d/a035a4ee9b1d4d4beee2ae6e8e12fe6dee5514b21f62504e22efcbd9fb46/django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f", size = 8289692, upload-time = "2025-11-05T14:07:28.761Z" }, ] [[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.47.0" +version = "2.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/2a/d225cbf87b6c8ecce5664db7bcecb82c317e448e3b24a2dcdaacb18ca9a7/sentry_sdk-2.47.0.tar.gz", hash = "sha256:8218891d5e41b4ea8d61d2aed62ed10c80e39d9f2959d6f939efbf056857e050", size = 381895, upload-time = "2025-12-03T14:06:36.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/26/ff7d93a14a0ec309021dca2fb7c62669d4f6f5654aa1baf60797a16681e0/sentry_sdk-2.44.0.tar.gz", hash = "sha256:5b1fe54dfafa332e900b07dd8f4dfe35753b64e78e7d9b1655a28fd3065e2493", size = 371464, upload-time = "2025-11-11T09:35:56.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/ac/d6286ea0d49e7b58847faf67b00e56bb4ba3d525281e2ac306e1f1f353da/sentry_sdk-2.47.0-py2.py3-none-any.whl", hash = "sha256:d72f8c61025b7d1d9e52510d03a6247b280094a327dd900d987717a4fce93412", size = 411088, upload-time = "2025-12-03T14:06:35.374Z" }, + { url = "https://files.pythonhosted.org/packages/a8/56/c16bda4d53012c71fa1b588edde603c6b455bc8206bf6de7b83388fcce75/sentry_sdk-2.44.0-py2.py3-none-any.whl", hash = "sha256:9e36a0372b881e8f92fdbff4564764ce6cec4b7f25424d0a3a8d609c9e4651a7", size = 402352, upload-time = "2025-11-11T09:35:54.1Z" }, ] [package.optional-dependencies] @@ -1133,8 +1133,8 @@ dev = [ requires-dist = [ { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "cryptography", specifier = ">=46.0.3" }, - { name = "django", specifier = "==5.2.9" }, - { name = "django-allauth", specifier = ">=65.13.1" }, + { name = "django", specifier = "==5.2.8" }, + { 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.47.0" }, + { name = "sentry-sdk", extras = ["django"], specifier = ">=2.44.0" }, { name = "urlman", specifier = ">=2.0.2" }, ] @@ -1156,11 +1156,11 @@ requires-dist = [ dev = [ { name = "black", specifier = ">=25.11.0" }, { name = "bumpver", specifier = ">=2025.1131" }, - { name = "coverage", specifier = ">=7.12.0" }, + { name = "coverage", specifier = ">=7.11.3" }, { name = "djlint", specifier = ">=1.36.4" }, { name = "flake8", specifier = ">=7.3.0" }, - { name = "flake8-bugbear", specifier = ">=25.11.29" }, - { name = "flake8-pyproject", specifier = ">=1.2.4" }, + { name = "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" }, { name = "pytest-cov", specifier = ">=7.0.0" }, @@ -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]]