diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml index 19e5ce8..1a0bbe9 100644 --- a/.forgejo/workflows/renovate.yaml +++ b/.forgejo/workflows/renovate.yaml @@ -19,7 +19,7 @@ jobs: node-version: "24" - name: Renovate - uses: https://github.com/renovatebot/github-action@v43.0.19 + uses: https://github.com/renovatebot/github-action@v44.0.1 with: token: ${{ secrets.RENOVATE_TOKEN }} env: diff --git a/pyproject.toml b/pyproject.toml index d77e198..b7ad2a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,8 @@ requires-python = ">=3.14.0" dependencies = [ "argon2-cffi>=25.1.0", "cryptography>=46.0.3", - "django==5.2.7", - "django-allauth>=65.12.1", + "django==5.2.8", + "django-allauth>=65.13.0", "django-auditlog>=3.3.0", "django-fernet-encrypted-fields>=0.3.0", "django-jsonform>=2.23.2", @@ -28,15 +28,15 @@ dependencies = [ [dependency-groups] dev = [ - "black>=25.9.0", + "black>=25.11.0", "bumpver>=2025.1131", - "coverage>=7.11.0", + "coverage>=7.11.3", "djlint>=1.36.4", "flake8>=7.3.0", "flake8-bugbear>=25.10.21", "flake8-pyproject>=1.2.3", "isort>=7.0.0", - "pytest>=8.4.2", + "pytest>=9.0.0", "pytest-cov>=7.0.0", "pytest-django>=4.11.1", "pytest-mock>=3.15.1", diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 87da376..fb64a2b 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -1,3 +1,6 @@ +import json +from pathlib import Path + from django.contrib import admin, messages from django.utils.translation import gettext_lazy as _ from django_jsonform.widgets import JSONFormWidget @@ -313,9 +316,9 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): ( _("Form Configuration"), { - "fields": ("advanced_fields",), + "fields": ("form_config",), "description": _( - "Configure which fields should be hidden behind an 'Advanced' toggle in the form" + "Optional custom form configuration. When provided, this will be used instead of auto-generating the form from the OpenAPI spec." ), }, ), @@ -323,19 +326,13 @@ class ServiceDefinitionAdmin(admin.ModelAdmin): def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) - # JSON schema for advanced_fields field - advanced_fields_schema = { - "type": "array", - "title": "Advanced Fields", - "items": { - "type": "string", - "title": "Field Name", - "description": "Field name in dot notation (e.g., spec.parameters.monitoring.enabled)", - }, - } - if "advanced_fields" in form.base_fields: - form.base_fields["advanced_fields"].widget = JSONFormWidget( - schema=advanced_fields_schema + schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json" + with open(schema_path) as f: + form_config_schema = json.load(f) + + if "form_config" in form.base_fields: + form.base_fields["form_config"].widget = JSONFormWidget( + schema=form_config_schema ) return form diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py deleted file mode 100644 index fe8edbb..0000000 --- a/src/servala/core/crd.py +++ /dev/null @@ -1,557 +0,0 @@ -import re - -from django import forms -from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator -from django.db import models -from django.forms.models import ModelForm, ModelFormMetaclass -from django.utils.translation import gettext_lazy as _ - -from servala.core.models import ServiceInstance - - -class CRDModel(models.Model): - """Base class for all virtual CRD models""" - - def __init__(self, **kwargs): - if spec := kwargs.pop("spec", None): - kwargs.update(unnest_data({"spec": spec})) - super().__init__(**kwargs) - - class Meta: - abstract = True - - -def duplicate_field(field_name, model): - # Get the field from the model - field = model._meta.get_field(field_name) - - # Create a new field with the same attributes - new_field = type(field).__new__(type(field)) - new_field.__dict__.update(field.__dict__) - - # Ensure the field is not linked to the original model - new_field.model = None - new_field.auto_created = False - - return new_field - - -def generate_django_model(schema, group, version, kind): - """ - Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. - """ - # We always need these three fields to know our own name and our full namespace - model_fields = {"__module__": "crd_models"} - for field_name in ("name", "organization", "context"): - model_fields[field_name] = duplicate_field(field_name, ServiceInstance) - - # All other fields are generated from the schema, except for the - # resourceRef object - spec = schema["properties"].get("spec") or {} - spec["properties"].pop("resourceRef", None) - model_fields.update(build_object_fields(spec, "spec", parent_required=False)) - - # Store the original schema on the model class - model_fields["SCHEMA"] = schema - - meta_class = type("Meta", (), {"app_label": "crd_models"}) - model_fields["Meta"] = meta_class - - # create the model class - model_name = kind - model_class = type(model_name, (CRDModel,), model_fields) - return model_class - - -def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=False): - required_fields = schema.get("required") or [] - properties = schema.get("properties") or {} - fields = {} - - for field_name, field_schema in properties.items(): - is_required = field_name in required_fields or parent_required - full_name = f"{name}.{field_name}" - result = get_django_field( - field_schema, - is_required, - field_name, - full_name, - verbose_name_prefix=verbose_name_prefix, - ) - if isinstance(result, dict): - fields.update(result) - else: - fields[full_name] = result - return fields - - -def deslugify(title): - """ - Convert camelCase, PascalCase, or snake_case to human-readable title. - Handles known acronyms (e.g., postgreSQLParameters -> PostgreSQL Parameters). - """ - ACRONYMS = { - # Database systems - "SQL": "SQL", - "MYSQL": "MySQL", - "POSTGRESQL": "PostgreSQL", - "MARIADB": "MariaDB", - "MSSQL": "MSSQL", - "MONGODB": "MongoDB", - "REDIS": "Redis", - # Protocols - "HTTP": "HTTP", - "HTTPS": "HTTPS", - "FTP": "FTP", - "SFTP": "SFTP", - "SSH": "SSH", - "TLS": "TLS", - "SSL": "SSL", - # APIs - "API": "API", - "REST": "REST", - "GRPC": "gRPC", - "GRAPHQL": "GraphQL", - # Networking - "URL": "URL", - "URI": "URI", - "FQDN": "FQDN", - "DNS": "DNS", - "IP": "IP", - "TCP": "TCP", - "UDP": "UDP", - # Data formats - "JSON": "JSON", - "XML": "XML", - "YAML": "YAML", - "CSV": "CSV", - "HTML": "HTML", - "CSS": "CSS", - # Hardware - "CPU": "CPU", - "RAM": "RAM", - "GPU": "GPU", - "SSD": "SSD", - "HDD": "HDD", - # Identifiers - "ID": "ID", - "UUID": "UUID", - "GUID": "GUID", - "ARN": "ARN", - # Cloud providers - "AWS": "AWS", - "GCP": "GCP", - "AZURE": "Azure", - "IBM": "IBM", - # Kubernetes/Cloud - "DB": "DB", - "PVC": "PVC", - "PV": "PV", - "VPN": "VPN", - # Auth - "OS": "OS", - "LDAP": "LDAP", - "SAML": "SAML", - "OAUTH": "OAuth", - "JWT": "JWT", - # AWS Services - "S3": "S3", - "EC2": "EC2", - "RDS": "RDS", - "EBS": "EBS", - "IAM": "IAM", - } - - if "_" in title: - # Handle snake_case - title = title.replace("_", " ") - words = title.split() - else: - # Handle camelCase/PascalCase with smart splitting - # This regex splits on: - # - Transition from lowercase to uppercase (camelCase) - # - Transition from multiple uppercase to an uppercase followed by lowercase (SQLParameters -> SQL Parameters) - words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[a-z]+|[0-9]+", title) - - # Merge adjacent words if they form a known compound acronym (e.g., postgre + SQL = PostgreSQL) - merged_words = [] - i = 0 - while i < len(words): - if i < len(words) - 1: - # Check if current word + next word form a known acronym - combined = (words[i] + words[i + 1]).upper() - if combined in ACRONYMS: - merged_words.append(combined) - i += 2 - continue - merged_words.append(words[i]) - i += 1 - - # Capitalize each word, using proper casing for known acronyms - result = [] - for word in merged_words: - word_upper = word.upper() - if word_upper in ACRONYMS: - result.append(ACRONYMS[word_upper]) - else: - result.append(word.capitalize()) - - return " ".join(result) - - -def get_django_field( - field_schema, is_required, field_name, full_name, verbose_name_prefix=None -): - field_type = field_schema.get("type") or "string" - format = field_schema.get("format") - verbose_name_prefix = verbose_name_prefix or "" - verbose_name = f"{verbose_name_prefix} {deslugify(field_name)}".strip() - - # Pass down the requirement status from parent to child fields - kwargs = { - "blank": not is_required, # All fields are optional by default - "null": not is_required, - "help_text": field_schema.get("description"), - "validators": [], - "verbose_name": verbose_name, - "default": field_schema.get("default"), - } - - if minimum := field_schema.get("minimum"): - kwargs["validators"].append(MinValueValidator(minimum)) - if maximum := field_schema.get("maximum"): - kwargs["validators"].append(MaxValueValidator(maximum)) - - if field_type == "string": - if format == "date-time": - return models.DateTimeField(**kwargs) - elif format == "date": - return models.DateField(**kwargs) - else: - max_length = field_schema.get("max_length") or 255 - if pattern := field_schema.get("pattern"): - kwargs["validators"].append(RegexValidator(regex=pattern)) - if choices := field_schema.get("enum"): - kwargs["choices"] = ((choice, choice) for choice in choices) - return models.CharField(max_length=max_length, **kwargs) - elif field_type == "integer": - return models.IntegerField(**kwargs) - elif field_type == "number": - return models.FloatField(**kwargs) - elif field_type == "boolean": - return models.BooleanField(**kwargs) - elif field_type == "object": - # Here we pass down the requirement status to nested objects - return build_object_fields( - field_schema, - full_name, - verbose_name_prefix=f"{verbose_name}:", - parent_required=is_required, - ) - elif field_type == "array": - kwargs["help_text"] = field_schema.get("description") or _("List of values") - from servala.frontend.forms.widgets import DynamicArrayField - - field = models.JSONField(**kwargs) - formfield_kwargs = { - "label": field.verbose_name, - "required": not field.blank, - } - - array_validation = {} - if min_items := field_schema.get("min_items"): - array_validation["min_items"] = min_items - if max_items := field_schema.get("max_items"): - array_validation["max_items"] = max_items - if unique_items := field_schema.get("unique_items"): - array_validation["unique_items"] = unique_items - if items_schema := field_schema.get("items"): - array_validation["items_schema"] = items_schema - if array_validation: - formfield_kwargs["array_validation"] = array_validation - - field.formfield = lambda: DynamicArrayField(**formfield_kwargs) - - return field - return models.CharField(max_length=255, **kwargs) - - -def unnest_data(data): - result = {} - - def _flatten_dict(d, parent_key=""): - for key, value in d.items(): - new_key = f"{parent_key}.{key}" if parent_key else key - if isinstance(value, dict): - _flatten_dict(value, new_key) - else: - result[new_key] = value - - _flatten_dict(data) - return result - - -class CrdModelFormMixin: - HIDDEN_FIELDS = [ - "spec.compositeDeletePolicy", - "spec.compositionRef", - "spec.compositionRevisionRef", - "spec.compositionRevisionSelector", - "spec.compositionSelector", - "spec.compositionUpdatePolicy", - "spec.parameters.monitoring.alertmanagerConfigRef", - "spec.parameters.monitoring.alertmanagerConfigSecretRef", - "spec.parameters.network.serviceType", - "spec.parameters.scheduling", - "spec.parameters.security", - "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): - super().__init__(*args, **kwargs) - self.schema = self._meta.model.SCHEMA - - for field in ("organization", "context"): - self.fields[field].widget = forms.HiddenInput() - - for name, field in self.fields.items(): - if name in self.HIDDEN_FIELDS or any( - name.startswith(f) for f in self.HIDDEN_FIELDS - ): - field.widget = forms.HiddenInput() - field.required = False - - # Mark advanced fields with a CSS class and data attribute - for name, field in self.fields.items(): - if self.is_field_advanced(name): - field.widget.attrs.update( - { - "class": ( - field.widget.attrs.get("class", "") + " advanced-field" - ).strip(), - "data-advanced": "true", - } - ) - - if self.instance and self.instance.pk: - self.fields["name"].disabled = True - self.fields["name"].help_text = _("Name cannot be changed after creation.") - self.fields["name"].widget = forms.HiddenInput() - - def strip_title(self, field_name, label): - field = self.fields[field_name] - if field and field.label and (position := field.label.find(label)) != -1: - field.label = field.label[position + len(label) :] - - def has_mandatory_fields(self, field_list): - for field_name in field_list: - if field_name in self.fields and self.fields[field_name].required: - return True - return False - - def is_field_advanced(self, field_name): - advanced_fields = getattr(self, "ADVANCED_FIELDS", []) - return field_name in advanced_fields or any( - field_name.startswith(f"{af}.") for af in advanced_fields - ) - - def are_all_fields_advanced(self, field_list): - if not field_list: - return False - return all(self.is_field_advanced(field_name) for field_name in field_list) - - def get_fieldsets(self): - fieldsets = [] - - # General fieldset for non-spec fields - general_fields = [ - field_name - for field_name in self.fields.keys() - if not field_name.startswith("spec.") - ] - if general_fields: - fieldset = { - "title": "General", - "fields": general_fields, - "fieldsets": [], - "has_mandatory": self.has_mandatory_fields(general_fields), - "is_advanced": self.are_all_fields_advanced(general_fields), - } - if all( - [ - isinstance(self.fields[field].widget, forms.HiddenInput) - for field in general_fields - ] - ): - fieldset["hidden"] = True - fieldsets.append(fieldset) - - # Process spec fields - others = [] - top_level_fieldsets = {} - hidden_spec_fields = [] - - for field_name in self.fields: - if field_name.startswith("spec."): - if isinstance(self.fields[field_name].widget, forms.HiddenInput): - hidden_spec_fields.append(field_name) - continue - - parts = field_name.split(".") - if len(parts) == 2: - # Top-level spec field - others.append(field_name) - elif len(parts) == 3: - # Second-level field - promote to top-level fieldset - fieldset_key = f"{parts[1]}.{parts[2]}" - if not top_level_fieldsets.get(fieldset_key): - top_level_fieldsets[fieldset_key] = { - "fields": [], - "fieldsets": {}, - "title": f"{deslugify(parts[2])}", - } - top_level_fieldsets[fieldset_key]["fields"].append(field_name) - else: - # Third-level and deeper - create nested fieldsets - fieldset_key = f"{parts[1]}.{parts[2]}" - if not top_level_fieldsets.get(fieldset_key): - top_level_fieldsets[fieldset_key] = { - "fields": [], - "fieldsets": {}, - "title": f"{deslugify(parts[2])}", - } - - sub_key = parts[3] - if not top_level_fieldsets[fieldset_key]["fieldsets"].get(sub_key): - top_level_fieldsets[fieldset_key]["fieldsets"][sub_key] = { - "title": deslugify(sub_key), - "fields": [], - } - top_level_fieldsets[fieldset_key]["fieldsets"][sub_key][ - "fields" - ].append(field_name) - - for fieldset in top_level_fieldsets.values(): - nested_fieldsets_list = [] - for sub_fieldset in fieldset["fieldsets"].values(): - if len(sub_fieldset["fields"]) == 1: - # If nested fieldset has only one field, move it to parent - fieldset["fields"].append(sub_fieldset["fields"][0]) - else: - # Keep as nested fieldset with proper title stripping - title = f"{fieldset['title']}: {sub_fieldset['title']}: " - for field in sub_fieldset["fields"]: - self.strip_title(field, title) - sub_fieldset["is_advanced"] = self.are_all_fields_advanced( - sub_fieldset["fields"] - ) - nested_fieldsets_list.append(sub_fieldset) - - fieldset["fieldsets"] = nested_fieldsets_list - total_fields = len(fieldset["fields"]) + len(nested_fieldsets_list) - if total_fields == 1 and len(fieldset["fields"]) == 1: - others.append(fieldset["fields"][0]) - else: - title = f"{fieldset['title']}: " - for field in fieldset["fields"]: - self.strip_title(field, title) - - all_fields = fieldset["fields"][:] - for sub_fieldset in nested_fieldsets_list: - all_fields.extend(sub_fieldset["fields"]) - fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields) - - fieldset["is_advanced"] = self.are_all_fields_advanced(all_fields) - - fieldsets.append(fieldset) - - # Add 'others' tab if there are any fields - if others: - fieldsets.append( - { - "title": "Others", - "fields": others, - "fieldsets": [], - "has_mandatory": self.has_mandatory_fields(others), - "is_advanced": self.are_all_fields_advanced(others), - } - ) - - if hidden_spec_fields: - fieldsets.append( - { - "title": "Advanced", - "fields": hidden_spec_fields, - "fieldsets": [], - "hidden": True, - "has_mandatory": self.has_mandatory_fields(hidden_spec_fields), - } - ) - - fieldsets.sort(key=lambda f: f.get("hidden", False)) - - return fieldsets - - def get_nested_data(self): - """ - Builds the original nested JSON structure from flat form data. - Form fields are named with dot notation (e.g., 'spec.replicas') - """ - result = {} - - for field_name, value in self.cleaned_data.items(): - if value is None or value == "": - continue - - parts = field_name.split(".") - current = result - - # Navigate through the nested structure - for i, part in enumerate(parts): - if i == len(parts) - 1: - # Last part, set the value - current[part] = value - else: - # Create nested dict if it doesn't exist - if part not in current: - current[part] = {} - current = current[part] - return result - - def clean(self): - cleaned_data = super().clean() - self.validate_nested_data( - self.get_nested_data().get("spec", {}), self.schema["properties"]["spec"] - ) - return cleaned_data - - def validate_nested_data(self, data, schema): - """Validate data against the provided OpenAPI v3 schema""" - # TODO: actually validate the nested data. - # TODO: get jsonschema to give us a path to the failing field rather than just an error message, - # then add the validation error to that field (self.add_error()) - # try: - # validate(instance=data, schema=schema) - # except Exception as e: - # raise forms.ValidationError(f"Validation error: {e.message}") - pass - - -def generate_model_form_class(model, advanced_fields=None): - meta_attrs = { - "model": model, - "fields": "__all__", - } - fields = { - "Meta": type("Meta", (object,), meta_attrs), - "__module__": "crd_models", - "ADVANCED_FIELDS": advanced_fields or [], - } - class_name = f"{model.__name__}ModelForm" - return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields) diff --git a/src/servala/core/crd/__init__.py b/src/servala/core/crd/__init__.py new file mode 100644 index 0000000..3f10abb --- /dev/null +++ b/src/servala/core/crd/__init__.py @@ -0,0 +1,31 @@ +from servala.core.crd.forms import ( + CrdModelFormMixin, + CustomFormMixin, + FormGeneratorMixin, + generate_custom_form_class, + generate_model_form_class, +) +from servala.core.crd.models import ( + CRDModel, + build_object_fields, + duplicate_field, + generate_django_model, + get_django_field, + unnest_data, +) +from servala.core.crd.utils import deslugify + +__all__ = [ + "CrdModelFormMixin", + "CustomFormMixin", + "FormGeneratorMixin", + "generate_django_model", + "generate_model_form_class", + "generate_custom_form_class", + "CRDModel", + "build_object_fields", + "duplicate_field", + "get_django_field", + "unnest_data", + "deslugify", +] diff --git a/src/servala/core/crd/forms.py b/src/servala/core/crd/forms.py new file mode 100644 index 0000000..659684e --- /dev/null +++ b/src/servala/core/crd/forms.py @@ -0,0 +1,454 @@ +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 + +# Fields that must be present in every form +MANDATORY_FIELDS = ["name"] + +# Default field configurations - fields that can be included with just a mapping +# to avoid administrators having to duplicate common information +DEFAULT_FIELD_CONFIGS = { + "name": { + "type": "text", + "label": "Instance Name", + "help_text": "Unique name for the new instance", + "required": True, + "max_length": 63, + }, + "spec.parameters.service.fqdn": { + "type": "array", + "label": "FQDNs", + "help_text": "Domain names for accessing this service", + "required": False, + }, +} + + +class FormGeneratorMixin: + """Shared base class for ModelForm classes based on our generated CRD models. + There are two relevant child classes: + - CrdModelFormMixin: For fully auto-generated forms from the spec + - CustomFormMixin: For forms built from form_config settings. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if "context" in self.fields: + self.fields["context"].widget = forms.HiddenInput() + if crd := self.initial.get("context"): + crd = getattr(crd, "pk", crd) # can be int or object + self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd) + + if self.instance and hasattr(self.instance, "name") and self.instance.name: + if "name" in self.fields: + self.fields["name"].disabled = True + self.fields["name"].widget = forms.HiddenInput() + + def has_mandatory_fields(self, field_list): + for field_name in field_list: + if field_name in self.fields and self.fields[field_name].required: + return True + return False + + +class CrdModelFormMixin(FormGeneratorMixin): + HIDDEN_FIELDS = [ + "spec.compositeDeletePolicy", + "spec.compositionRef", + "spec.compositionRevisionRef", + "spec.compositionRevisionSelector", + "spec.compositionSelector", + "spec.compositionUpdatePolicy", + "spec.parameters.monitoring.alertmanagerConfigRef", + "spec.parameters.monitoring.alertmanagerConfigSecretRef", + "spec.parameters.network.serviceType", + "spec.parameters.scheduling", + "spec.parameters.security", + "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): + super().__init__(*args, **kwargs) + self.schema = self._meta.model.SCHEMA + + for name, field in self.fields.items(): + if name in self.HIDDEN_FIELDS or any( + name.startswith(f) for f in self.HIDDEN_FIELDS + ): + field.widget = forms.HiddenInput() + field.required = False + + def strip_title(self, field_name, label): + field = self.fields[field_name] + if field and field.label and (position := field.label.find(label)) != -1: + field.label = field.label[position + len(label) :] + + def get_fieldsets(self): + fieldsets = [] + + # General fieldset for non-spec fields + general_fields = [ + field_name + for field_name in self.fields.keys() + if not field_name.startswith("spec.") + ] + if general_fields: + fieldset = { + "title": "General", + "fields": general_fields, + "fieldsets": [], + "has_mandatory": self.has_mandatory_fields(general_fields), + } + if all( + [ + isinstance(self.fields[field].widget, forms.HiddenInput) + for field in general_fields + ] + ): + fieldset["hidden"] = True + fieldsets.append(fieldset) + + # Process spec fields + others = [] + top_level_fieldsets = {} + hidden_spec_fields = [] + + for field_name in self.fields: + if field_name.startswith("spec."): + if isinstance(self.fields[field_name].widget, forms.HiddenInput): + hidden_spec_fields.append(field_name) + continue + + parts = field_name.split(".") + if len(parts) == 2: + # Top-level spec field + others.append(field_name) + elif len(parts) == 3: + # Second-level field - promote to top-level fieldset + fieldset_key = f"{parts[1]}.{parts[2]}" + if not top_level_fieldsets.get(fieldset_key): + top_level_fieldsets[fieldset_key] = { + "fields": [], + "fieldsets": {}, + "title": f"{deslugify(parts[2])}", + } + top_level_fieldsets[fieldset_key]["fields"].append(field_name) + else: + # Third-level and deeper - create nested fieldsets + fieldset_key = f"{parts[1]}.{parts[2]}" + if not top_level_fieldsets.get(fieldset_key): + top_level_fieldsets[fieldset_key] = { + "fields": [], + "fieldsets": {}, + "title": f"{deslugify(parts[2])}", + } + + sub_key = parts[3] + if not top_level_fieldsets[fieldset_key]["fieldsets"].get(sub_key): + top_level_fieldsets[fieldset_key]["fieldsets"][sub_key] = { + "title": deslugify(sub_key), + "fields": [], + } + top_level_fieldsets[fieldset_key]["fieldsets"][sub_key][ + "fields" + ].append(field_name) + + for fieldset in top_level_fieldsets.values(): + nested_fieldsets_list = [] + for sub_fieldset in fieldset["fieldsets"].values(): + if len(sub_fieldset["fields"]) == 1: + # If nested fieldset has only one field, move it to parent + fieldset["fields"].append(sub_fieldset["fields"][0]) + else: + # Keep as nested fieldset with proper title stripping + title = f"{fieldset['title']}: {sub_fieldset['title']}: " + for field in sub_fieldset["fields"]: + self.strip_title(field, title) + nested_fieldsets_list.append(sub_fieldset) + + fieldset["fieldsets"] = nested_fieldsets_list + total_fields = len(fieldset["fields"]) + len(nested_fieldsets_list) + if total_fields == 1 and len(fieldset["fields"]) == 1: + others.append(fieldset["fields"][0]) + else: + title = f"{fieldset['title']}: " + for field in fieldset["fields"]: + self.strip_title(field, title) + + all_fields = fieldset["fields"][:] + for sub_fieldset in nested_fieldsets_list: + all_fields.extend(sub_fieldset["fields"]) + fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields) + + fieldsets.append(fieldset) + + # Add 'others' tab if there are any fields + if others: + fieldsets.append( + { + "title": "Others", + "fields": others, + "fieldsets": [], + "has_mandatory": self.has_mandatory_fields(others), + } + ) + + if hidden_spec_fields: + fieldsets.append( + { + "title": "Advanced", + "fields": hidden_spec_fields, + "fieldsets": [], + "hidden": True, + "has_mandatory": self.has_mandatory_fields(hidden_spec_fields), + } + ) + + fieldsets.sort(key=lambda f: f.get("hidden", False)) + + return fieldsets + + def get_nested_data(self): + """ + Builds the original nested JSON structure from flat form data. + Form fields are named with dot notation (e.g., 'spec.replicas') + """ + result = {} + + for field_name, value in self.cleaned_data.items(): + if value is None or value == "": + continue + + parts = field_name.split(".") + current = result + + # Navigate through the nested structure + for i, part in enumerate(parts): + if i == len(parts) - 1: + # Last part, set the value + current[part] = value + else: + # Create nested dict if it doesn't exist + if part not in current: + current[part] = {} + current = current[part] + return result + + def clean(self): + cleaned_data = super().clean() + self.validate_nested_data( + self.get_nested_data().get("spec", {}), self.schema["properties"]["spec"] + ) + return cleaned_data + + def validate_nested_data(self, data, schema): + """Validate data against the provided OpenAPI v3 schema""" + # TODO: actually validate the nested data. + # TODO: get jsonschema to give us a path to the failing field rather than just an error message, + # then add the validation error to that field (self.add_error()) + # try: + # validate(instance=data, schema=schema) + # except Exception as e: + # raise forms.ValidationError(f"Validation error: {e.message}") + pass + + +def generate_model_form_class(model): + meta_attrs = { + "model": model, + "fields": "__all__", + } + fields = { + "Meta": type("Meta", (object,), meta_attrs), + "__module__": "crd_models", + } + class_name = f"{model.__name__}ModelForm" + return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields) + + +class CustomFormMixin(FormGeneratorMixin): + """ + Base for custom (user-friendly) forms generated from ServiceDefinition.form_config. + """ + + IS_CUSTOM_FORM = True + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._apply_field_config() + if ( + self.instance + and hasattr(self.instance, "name") + and self.instance.name + and "name" in self.fields + ): + self.fields["name"].widget = forms.HiddenInput() + self.fields["name"].disabled = True + self.fields.pop("context", None) + + def _apply_field_config(self): + for fieldset in self.form_config.get("fieldsets", []): + for fc in fieldset.get("fields", []): + field_name = fc.get("controlplane_field_mapping") + + if field_name not in self.fields: + continue + + field_config = fc.copy() + # Merge with defaults if field has default config + if field_name in DEFAULT_FIELD_CONFIGS: + field_config = DEFAULT_FIELD_CONFIGS[field_name].copy() + for key, value in fc.items(): + if value or (value is False): + field_config[key] = value + + field = self.fields[field_name] + field_type = field_config.get("type") + + field.label = field_config.get("label", field_name) + field.help_text = field_config.get("help_text", "") + field.required = field_config.get("required", False) + + if field_type == "textarea": + field.widget = forms.Textarea( + attrs={"rows": field_config.get("rows", 4)} + ) + elif field_type == "array": + field.widget = DynamicArrayWidget() + elif field_type == "choice": + if hasattr(field, "choices") and field.choices: + field._controlplane_choices = list(field.choices) + if custom_choices := field_config.get("choices"): + field.choices = [tuple(choice) for choice in custom_choices] + + if field_type == "number": + min_val = field_config.get("min_value") + max_val = field_config.get("max_value") + + validators = [] + if min_val is not None: + validators.append(MinValueValidator(min_val)) + field.widget.attrs["min"] = min_val + if max_val is not None: + validators.append(MaxValueValidator(max_val)) + field.widget.attrs["max"] = max_val + + if validators: + field.validators.extend(validators) + + if "default_value" in field_config and field.initial is None: + field.initial = field_config["default_value"] + + if field_type in ("text", "textarea") and field_config.get( + "max_length" + ): + field.max_length = field_config.get("max_length") + if hasattr(field.widget, "attrs"): + field.widget.attrs["maxlength"] = field_config.get("max_length") + + field.controlplane_field_mapping = field_name + + def get_fieldsets(self): + fieldsets = [] + for fieldset_config in self.form_config.get("fieldsets", []): + field_names = [ + f["controlplane_field_mapping"] + for f in fieldset_config.get("fields", []) + ] + fieldset = { + "title": fieldset_config.get("title", "General"), + "fields": field_names, + "fieldsets": [], + "has_mandatory": self.has_mandatory_fields(field_names), + } + fieldsets.append(fieldset) + + return fieldsets + + def clean(self): + cleaned_data = super().clean() + + for field_name, field in self.fields.items(): + if hasattr(field, "_controlplane_choices"): + value = cleaned_data.get(field_name) + if value: + valid_values = [choice[0] for choice in field._controlplane_choices] + if value not in valid_values: + self.add_error( + field_name, + forms.ValidationError( + f"'{value}' is not a valid choice. " + f"Must be one of: {valid_values.join(', ')}" + ), + ) + + return cleaned_data + + def get_nested_data(self): + nested = {} + for field_name in self.fields.keys(): + if field_name == "context": + value = self.cleaned_data.get(field_name) + if value is not None: + nested[field_name] = value + continue + + mapping = field_name + value = self.cleaned_data.get(field_name) + parts = mapping.split(".") + current = nested + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + + current[parts[-1]] = value + + return nested + + +def generate_custom_form_class(form_config, model): + """ + Generate a custom (user-friendly) form class from form_config JSON. + """ + field_list = ["context", "name"] + + for fieldset in form_config.get("fieldsets", []): + for field_config in fieldset.get("fields", []): + field_name = field_config.get("controlplane_field_mapping") + if field_name: + field_list.append(field_name) + + fields = { + "context": forms.ModelChoiceField( + queryset=ControlPlaneCRD.objects.none(), + required=True, + widget=forms.HiddenInput(), + ), + } + + meta_attrs = { + "model": model, + "fields": field_list, + } + + form_fields = { + "Meta": type("Meta", (object,), meta_attrs), + "__module__": "crd_models", + "form_config": form_config, + **fields, + } + + class_name = f"{model.__name__}CustomForm" + return ModelFormMetaclass(class_name, (CustomFormMixin, ModelForm), form_fields) diff --git a/src/servala/core/crd/models.py b/src/servala/core/crd/models.py new file mode 100644 index 0000000..86df97f --- /dev/null +++ b/src/servala/core/crd/models.py @@ -0,0 +1,167 @@ +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from servala.core.crd.utils import deslugify +from servala.core.models import ServiceInstance +from servala.frontend.forms.widgets import DynamicArrayField + + +class CRDModel(models.Model): + """Base class for all virtual CRD models""" + + def __init__(self, **kwargs): + if spec := kwargs.pop("spec", None): + kwargs.update(unnest_data({"spec": spec})) + super().__init__(**kwargs) + + class Meta: + abstract = True + + +def generate_django_model(schema, group, version, kind): + """ + Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema. + """ + # We always need these three fields to know our own name and our full namespace + model_fields = {"__module__": "crd_models"} + for field_name in ("name", "context"): + model_fields[field_name] = duplicate_field(field_name, ServiceInstance) + + # All other fields are generated from the schema, except for the + # resourceRef object + spec = schema["properties"].get("spec") or {} + spec["properties"].pop("resourceRef", None) + model_fields.update(build_object_fields(spec, "spec", parent_required=False)) + + # Store the original schema on the model class + model_fields["SCHEMA"] = schema + + meta_class = type("Meta", (), {"app_label": "crd_models"}) + model_fields["Meta"] = meta_class + + # create the model class + model_name = kind + model_class = type(model_name, (CRDModel,), model_fields) + return model_class + + +def duplicate_field(field_name, model): + field = model._meta.get_field(field_name) + new_field = type(field).__new__(type(field)) + new_field.__dict__.update(field.__dict__) + new_field.model = None + new_field.auto_created = False + return new_field + + +def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=False): + required_fields = schema.get("required") or [] + properties = schema.get("properties") or {} + fields = {} + + for field_name, field_schema in properties.items(): + is_required = field_name in required_fields or parent_required + full_name = f"{name}.{field_name}" + result = get_django_field( + field_schema, + is_required, + field_name, + full_name, + verbose_name_prefix=verbose_name_prefix, + ) + if isinstance(result, dict): + fields.update(result) + else: + fields[full_name] = result + return fields + + +def get_django_field( + field_schema, is_required, field_name, full_name, verbose_name_prefix=None +): + field_type = field_schema.get("type") or "string" + format = field_schema.get("format") + verbose_name_prefix = verbose_name_prefix or "" + verbose_name = f"{verbose_name_prefix} {deslugify(field_name)}".strip() + + # Pass down the requirement status from parent to child fields + kwargs = { + "blank": not is_required, # All fields are optional by default + "null": not is_required, + "help_text": field_schema.get("description"), + "validators": [], + "verbose_name": verbose_name, + "default": field_schema.get("default"), + } + + if minimum := field_schema.get("minimum"): + kwargs["validators"].append(MinValueValidator(minimum)) + if maximum := field_schema.get("maximum"): + kwargs["validators"].append(MaxValueValidator(maximum)) + + if field_type == "string": + if format == "date-time": + return models.DateTimeField(**kwargs) + elif format == "date": + return models.DateField(**kwargs) + else: + max_length = field_schema.get("max_length") or 255 + if pattern := field_schema.get("pattern"): + kwargs["validators"].append(RegexValidator(regex=pattern)) + if choices := field_schema.get("enum"): + kwargs["choices"] = ((choice, choice) for choice in choices) + return models.CharField(max_length=max_length, **kwargs) + elif field_type == "integer": + return models.IntegerField(**kwargs) + elif field_type == "number": + return models.FloatField(**kwargs) + elif field_type == "boolean": + return models.BooleanField(**kwargs) + elif field_type == "object": + # Here we pass down the requirement status to nested objects + return build_object_fields( + field_schema, + full_name, + verbose_name_prefix=f"{verbose_name}:", + parent_required=is_required, + ) + elif field_type == "array": + kwargs["help_text"] = field_schema.get("description") or _("List of values") + field = models.JSONField(**kwargs) + formfield_kwargs = { + "label": field.verbose_name, + "required": not field.blank, + } + + array_validation = {} + if min_items := field_schema.get("min_items"): + array_validation["min_items"] = min_items + if max_items := field_schema.get("max_items"): + array_validation["max_items"] = max_items + if unique_items := field_schema.get("unique_items"): + array_validation["unique_items"] = unique_items + if items_schema := field_schema.get("items"): + array_validation["items_schema"] = items_schema + if array_validation: + formfield_kwargs["array_validation"] = array_validation + + field.formfield = lambda: DynamicArrayField(**formfield_kwargs) + + return field + return models.CharField(max_length=255, **kwargs) + + +def unnest_data(data): + result = {} + + def _flatten_dict(d, parent_key=""): + for key, value in d.items(): + new_key = f"{parent_key}.{key}" if parent_key else key + if isinstance(value, dict): + _flatten_dict(value, new_key) + else: + result[new_key] = value + + _flatten_dict(data) + return result diff --git a/src/servala/core/crd/utils.py b/src/servala/core/crd/utils.py new file mode 100644 index 0000000..a537fd9 --- /dev/null +++ b/src/servala/core/crd/utils.py @@ -0,0 +1,115 @@ +import re + + +def deslugify(title): + """ + Convert camelCase, PascalCase, or snake_case to human-readable title. + Handles known acronyms (e.g., postgreSQLParameters -> PostgreSQL Parameters). + """ + ACRONYMS = { + # Database systems + "SQL": "SQL", + "MYSQL": "MySQL", + "POSTGRESQL": "PostgreSQL", + "MARIADB": "MariaDB", + "MSSQL": "MSSQL", + "MONGODB": "MongoDB", + "REDIS": "Redis", + # Protocols + "HTTP": "HTTP", + "HTTPS": "HTTPS", + "FTP": "FTP", + "SFTP": "SFTP", + "SSH": "SSH", + "TLS": "TLS", + "SSL": "SSL", + # APIs + "API": "API", + "REST": "REST", + "GRPC": "gRPC", + "GRAPHQL": "GraphQL", + # Networking + "URL": "URL", + "URI": "URI", + "FQDN": "FQDN", + "DNS": "DNS", + "IP": "IP", + "TCP": "TCP", + "UDP": "UDP", + # Data formats + "JSON": "JSON", + "XML": "XML", + "YAML": "YAML", + "CSV": "CSV", + "HTML": "HTML", + "CSS": "CSS", + # Hardware + "CPU": "CPU", + "RAM": "RAM", + "GPU": "GPU", + "SSD": "SSD", + "HDD": "HDD", + # Identifiers + "ID": "ID", + "UUID": "UUID", + "GUID": "GUID", + "ARN": "ARN", + # Cloud providers + "AWS": "AWS", + "GCP": "GCP", + "AZURE": "Azure", + "IBM": "IBM", + # Kubernetes/Cloud + "DB": "DB", + "PVC": "PVC", + "PV": "PV", + "VPN": "VPN", + # Auth + "OS": "OS", + "LDAP": "LDAP", + "SAML": "SAML", + "OAUTH": "OAuth", + "JWT": "JWT", + # AWS Services + "S3": "S3", + "EC2": "EC2", + "RDS": "RDS", + "EBS": "EBS", + "IAM": "IAM", + } + + if "_" in title: + # Handle snake_case + title = title.replace("_", " ") + words = title.split() + else: + # Handle camelCase/PascalCase with smart splitting + # This regex splits on: + # - Transition from lowercase to uppercase (camelCase) + # - Transition from multiple uppercase to an uppercase followed by lowercase (SQLParameters -> SQL Parameters) + words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[a-z]+|[0-9]+", title) + + # Merge adjacent words if they form a known compound acronym (e.g., postgre + SQL = PostgreSQL) + merged_words = [] + i = 0 + while i < len(words): + if i < len(words) - 1: + # Check if current word + next word form a known acronym + combined = (words[i] + words[i + 1]).upper() + if combined in ACRONYMS: + merged_words.append(combined) + i += 2 + continue + merged_words.append(words[i]) + i += 1 + + # Capitalize each word, using proper casing for known acronyms + result = [] + for word in merged_words: + word_upper = word.upper() + if word_upper in ACRONYMS: + result.append(ACRONYMS[word_upper]) + else: + result.append(word.capitalize()) + + return " ".join(result) diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py index 034d1c9..090abba 100644 --- a/src/servala/core/forms.py +++ b/src/servala/core/forms.py @@ -1,7 +1,12 @@ +import json +from pathlib import Path + +import jsonschema from django import forms from django.utils.translation import gettext_lazy as _ from django_jsonform.widgets import JSONFormWidget +from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS from servala.core.models import ControlPlane, ServiceDefinition CONTROL_PLANE_USER_INFO_SCHEMA = { @@ -96,6 +101,12 @@ class ControlPlaneAdminForm(forms.ModelForm): return super().save(*args, **kwargs) +def fields_empty(fields): + if not fields: + return True + return all(not field.get("controlplane_field_mapping") for field in fields) + + class ServiceDefinitionAdminForm(forms.ModelForm): api_group = forms.CharField( required=False, @@ -124,6 +135,10 @@ class ServiceDefinitionAdminForm(forms.ModelForm): self.fields["api_version"].initial = api_def.get("version", "") self.fields["api_kind"].initial = api_def.get("kind", "") + schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json" + with open(schema_path) as f: + self.form_config_schema = json.load(f) + def clean(self): cleaned_data = super().clean() @@ -151,8 +166,250 @@ class ServiceDefinitionAdminForm(forms.ModelForm): api_def["kind"] = api_kind cleaned_data["api_definition"] = api_def + form_config = cleaned_data.get("form_config") + + # Convert empty form_config to None (no custom form) + if form_config: + if not form_config.get("fieldsets") or all( + fields_empty(fieldset.get("fields")) + for fieldset in form_config.get("fieldsets") + ): + form_config = None + cleaned_data["form_config"] = None + + if form_config: + form_config = self._normalize_form_config_types(form_config) + cleaned_data["form_config"] = form_config + + try: + jsonschema.validate( + instance=form_config, schema=self.form_config_schema + ) + except jsonschema.ValidationError as e: + raise forms.ValidationError( + { + "form_config": _("Invalid form configuration: {}").format( + e.message + ) + } + ) + except jsonschema.SchemaError as e: + raise forms.ValidationError( + {"form_config": _("Schema error: {}").format(e.message)} + ) + + self._validate_field_mappings(form_config, cleaned_data) + return cleaned_data + def _normalize_form_config_types(self, form_config): + """ + Normalize form_config by converting string representations of numbers + to actual integers/floats. The JSON form widget sends all values + as strings, but the schema expects proper types. + """ + if not isinstance(form_config, dict): + return form_config + + integer_fields = ["max_length", "rows", "min_values", "max_values"] + number_fields = ["min_value", "max_value"] + + for fieldset in form_config.get("fieldsets", []): + for field in fieldset.get("fields", []): + for field_name in integer_fields: + if field_name in field and field[field_name] is not None: + value = field[field_name] + if isinstance(value, str): + try: + field[field_name] = int(value) if value else None + except (ValueError, TypeError): + pass + + for field_name in number_fields: + if field_name in field and field[field_name] is not None: + value = field[field_name] + if isinstance(value, str): + try: + field[field_name] = ( + int(value) if "." not in value else float(value) + ) + except (ValueError, TypeError): + pass + + return form_config + + def _validate_field_mappings(self, form_config, cleaned_data): + if not self.instance.pk: + return + crd = self.instance.offering_control_planes.all().first() + if not crd: + return + + schema = None + try: + schema = crd.resource_schema + except Exception: + pass + + if not schema or not (spec_schema := schema.get("properties", {}).get("spec")): + return + + valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"} + included_mappings = set() + errors = [] + for fieldset in form_config.get("fieldsets", []): + for field in fieldset.get("fields", []): + mapping = field.get("controlplane_field_mapping") + included_mappings.add(mapping) + + # Validate that fields without defaults have required properties + if mapping not in DEFAULT_FIELD_CONFIGS: + if not field.get("label"): + errors.append( + _( + "Field with mapping '{}' must have a 'label' property " + "(or use a mapping with default config)" + ).format(mapping) + ) + if not field.get("type"): + errors.append( + _( + "Field with mapping '{}' must have a 'type' property " + "(or use a mapping with default config)" + ).format(mapping) + ) + + if mapping and mapping not in valid_paths: + field_name = field.get("label", field.get("name", mapping)) + errors.append( + _( + "Field '{}' has invalid mapping '{}'. Valid paths are: {}" + ).format( + field_name, + mapping, + ", ".join(sorted(valid_paths)[:10]) + + ("..." if len(valid_paths) > 10 else ""), + ) + ) + + if field.get("type") == "choice" and field.get("choices"): + self._validate_choice_field( + field, mapping, spec_schema, "spec", errors + ) + + for mandatory_field in MANDATORY_FIELDS: + if mandatory_field not in included_mappings: + errors.append( + _( + "Required field '{}' must be included in the form configuration" + ).format(mandatory_field) + ) + + if errors: + raise forms.ValidationError({"form_config": errors}) + + def _validate_choice_field(self, field, mapping, spec_schema, prefix, errors): + if not mapping: + return + + field_name = field.get("label", mapping) + custom_choices = field.get("choices", []) + + # Single-element choices [value] are transformed to [value, value] + for i, choice in enumerate(custom_choices): + if not isinstance(choice, (list, tuple)): + errors.append( + _( + "Field '{}': Choice at index {} must be a list or tuple, " + "but got: {}" + ).format(field_name, i, repr(choice)) + ) + return + + choice_len = len(choice) + if choice_len == 1: + custom_choices[i] = [choice[0], choice[0]] + elif choice_len == 0 or choice_len > 2: + errors.append( + _( + "Field '{}': Choice at index {} must have 1 or 2 elements " + "(got {}): {}" + ).format(field_name, i, choice_len, repr(choice)) + ) + return + + field_schema = self._get_field_schema(spec_schema, mapping, prefix) + if not field_schema: + return + + control_plane_choices = field_schema.get("enum", []) + if not control_plane_choices: + return + + custom_choice_values = [choice[0] for choice in custom_choices] + + invalid_choices = [ + value + for value in custom_choice_values + if value not in control_plane_choices + ] + + if invalid_choices: + errors.append( + _( + "Field '{}' has invalid choice values: {}. " + "Valid choices from control plane are: {}" + ).format( + field_name, + ", ".join(f"'{c}'" for c in invalid_choices), + ", ".join(f"'{c}'" for c in control_plane_choices), + ) + ) + + def _get_field_schema(self, schema, field_path, prefix): + if not field_path or not schema: + return None + + if field_path.startswith(prefix + "."): + field_path = field_path[len(prefix) + 1 :] + + parts = field_path.split(".") + current_schema = schema + + for part in parts: + if not isinstance(current_schema, dict): + return None + + properties = current_schema.get("properties", {}) + if part not in properties: + return None + + current_schema = properties[part] + + return current_schema + + def _extract_field_paths(self, schema, prefix=""): + paths = set() + + if not isinstance(schema, dict): + return paths + + if "type" in schema and schema["type"] != "object": + if prefix: + paths.add(prefix) + + if schema.get("properties"): + for prop_name, prop_schema in schema["properties"].items(): + new_prefix = f"{prefix}.{prop_name}" if prefix else prop_name + paths.add(new_prefix) + paths.update(self._extract_field_paths(prop_schema, new_prefix)) + + if schema.get("type") == "array" and "items" in schema: + if prefix: + paths.add(prefix) + + return paths + def save(self, *args, **kwargs): self.instance.api_definition = self.cleaned_data["api_definition"] return super().save(*args, **kwargs) diff --git a/src/servala/core/migrations/0012_remove_advanced_fields.py b/src/servala/core/migrations/0012_remove_advanced_fields.py new file mode 100644 index 0000000..7d0fecd --- /dev/null +++ b/src/servala/core/migrations/0012_remove_advanced_fields.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.7 on 2025-10-31 10:40 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0012_convert_user_info_to_array"), + ] + + operations = [ + migrations.RemoveField( + model_name="servicedefinition", + name="advanced_fields", + ), + migrations.AlterField( + model_name="organization", + name="name", + field=models.CharField( + max_length=32, + validators=[ + django.core.validators.RegexValidator( + message="Organization name can only contain letters, numbers, and spaces.", + regex="^[A-Za-z0-9\\s]+$", + ) + ], + verbose_name="Name", + ), + ), + ] diff --git a/src/servala/core/migrations/0013_add_form_config.py b/src/servala/core/migrations/0013_add_form_config.py new file mode 100644 index 0000000..2819a6c --- /dev/null +++ b/src/servala/core/migrations/0013_add_form_config.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.7 on 2025-10-31 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0012_remove_advanced_fields"), + ] + + operations = [ + migrations.AddField( + model_name="servicedefinition", + name="form_config", + field=models.JSONField( + blank=True, + help_text=( + "Optional custom form configuration. When provided, this configuration will " + "be used to render the service form instead of auto-generating it from the OpenAPI spec. " + 'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}' + ), + null=True, + verbose_name="Form Configuration", + ), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 3af8c89..1535703 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -360,15 +360,15 @@ class ServiceDefinition(ServalaModelMixin, models.Model): null=True, blank=True, ) - advanced_fields = models.JSONField( - verbose_name=_("Advanced fields"), + form_config = models.JSONField( + verbose_name=_("Form Configuration"), help_text=_( - "Array of field names that should be hidden behind an 'Advanced' toggle. " - "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])" + "Optional custom form configuration. When provided, this configuration will be used " + "to render the service form instead of auto-generating it from the OpenAPI spec. " + 'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}' ), null=True, blank=True, - default=list, ) service = models.ForeignKey( to="Service", @@ -510,9 +510,22 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model): if not self.django_model: return - advanced_fields = self.service_definition.advanced_fields or [] - return generate_model_form_class( - self.django_model, advanced_fields=advanced_fields + return generate_model_form_class(self.django_model) + + @cached_property + def custom_model_form_class(self): + from servala.core.crd import generate_custom_form_class + + if not self.django_model: + return + if not ( + self.service_definition + and self.service_definition.form_config + and self.service_definition.form_config.get("fieldsets") + ): + return + return generate_custom_form_class( + self.service_definition.form_config, self.django_model ) @@ -878,7 +891,6 @@ class ServiceInstance(ServalaModelMixin, models.Model): return return self.context.django_model( name=self.name, - organization=self.organization, context=self.context, spec=self.spec, # We pass -1 as ID in order to make it clear that a) this object exists (remotely), diff --git a/src/servala/core/schemas/form_config_schema.json b/src/servala/core/schemas/form_config_schema.json new file mode 100644 index 0000000..3b01b3f --- /dev/null +++ b/src/servala/core/schemas/form_config_schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Service Definition Form Configuration Schema", + "description": "Schema for custom form configuration in ServiceDefinition", + "type": "object", + "required": ["fieldsets"], + "properties": { + "fieldsets": { + "type": "array", + "description": "Array of fieldset objects defining form sections", + "minItems": 1, + "items": { + "type": "object", + "required": ["fields"], + "properties": { + "title": { + "type": "string", + "description": "Optional title for the fieldset/tab" + }, + "fields": { + "type": "array", + "description": "Array of field definitions in this fieldset", + "minItems": 1, + "items": { + "type": "object", + "required": ["controlplane_field_mapping"], + "properties": { + "type": { + "type": "string", + "description": "Field type", + "enum": ["", "text", "email", "textarea", "number", "choice", "checkbox", "array"] + }, + "label": { + "type": "string", + "description": "Human-readable field label" + }, + "help_text": { + "type": "string", + "description": "Optional help text displayed below the field" + }, + "required": { + "type": "boolean", + "description": "Whether the field is required", + "default": false + }, + "controlplane_field_mapping": { + "type": "string", + "description": "Dot-notation path mapping to Kubernetes spec field (e.g., 'spec.parameters.service.fqdn')" + }, + "max_length": { + "type": ["integer", "null"], + "description": "Maximum length for text/textarea fields", + "minimum": 1 + }, + "rows": { + "type": ["integer", "null"], + "description": "Number of rows for textarea fields", + "minimum": 1 + }, + "min_value": { + "type": ["number", "null"], + "description": "Minimum value for number fields" + }, + "max_value": { + "type": ["number", "null"], + "description": "Maximum value for number fields" + }, + "choices": { + "type": "array", + "description": "Array of [value, label] pairs for choice fields", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "min_values": { + "type": ["integer", "null"], + "description": "Minimum number of values for array fields", + "minimum": 0 + }, + "max_values": { + "type": ["integer", "null"], + "description": "Maximum number of values for array fields", + "minimum": 1 + }, + "validators": { + "type": "array", + "description": "Array of validator names (for future use)", + "items": { + "type": "string", + "enum": ["email", "fqdn", "url", "ipv4", "ipv6"] + } + }, + "default_value": { + "type": "string", + "description": "Default value for the field when creating new instances" + } + } + } + } + } + } + } + } +} diff --git a/src/servala/frontend/forms/organization.py b/src/servala/frontend/forms/organization.py index 86ba0ab..45e7b11 100644 --- a/src/servala/frontend/forms/organization.py +++ b/src/servala/frontend/forms/organization.py @@ -8,7 +8,6 @@ from servala.core.models import Organization, OrganizationInvitation, Organizati from servala.core.odoo import get_invoice_addresses, get_odoo_countries from servala.frontend.forms.mixins import HtmxMixin - ORG_NAME_PATTERN = r"[\w\s\-.,&'()+]+" diff --git a/src/servala/frontend/templates/account/login.html b/src/servala/frontend/templates/account/login.html index f4ca590..e7576b0 100644 --- a/src/servala/frontend/templates/account/login.html +++ b/src/servala/frontend/templates/account/login.html @@ -26,12 +26,14 @@
{% translate "Ready to get started?" %}

- {% translate "Sign in to access your managed service instances and the Servala service catalog" %} + {% translate "Sign in to your account or create a new one to access your managed service instances and the Servala service catalog" %}

{% for provider in socialaccount_providers %} {% provider_login_url provider process=process scope=scope auth_params=auth_params as href %} -
+ {% csrf_token %} {{ redirect_field }}
{% endfor %} diff --git a/src/servala/frontend/templates/frontend/forms/dynamic_array.html b/src/servala/frontend/templates/frontend/forms/dynamic_array.html index 9d61825..00c23b0 100644 --- a/src/servala/frontend/templates/frontend/forms/dynamic_array.html +++ b/src/servala/frontend/templates/frontend/forms/dynamic_array.html @@ -1,5 +1,5 @@
{% endif %} {% for text in field.errors %}
{{ text }}
{% endfor %} - {% if field.help_text %} + {% if field.help_text and not field.is_hidden and not field.field.widget.input_type == "hidden" %} {{ field.help_text|safe }} {% endif %} 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 51a9213..74259e6 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_instance_update.html +++ b/src/servala/frontend/templates/frontend/organizations/service_instance_update.html @@ -22,7 +22,7 @@ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
{% else %} - {% include "includes/tabbed_fieldset_form.html" with form=form %} + {% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %} {% endif %} 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 3305328..927c6e3 100644 --- a/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html +++ b/src/servala/frontend/templates/frontend/organizations/service_offering_detail.html @@ -17,7 +17,7 @@ {% endif %} {% endpartialdef %} {% partialdef service-form %} -{% if service_form %} +{% if service_form or custom_service_form %}
@@ -26,7 +26,7 @@ {% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
{% else %} - {% include "includes/tabbed_fieldset_form.html" with form=service_form %} + {% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %} {% endif %}
@@ -42,7 +42,9 @@
{% translate "Service Unavailable" %} -

{% translate "We currently cannot offer this service. Please check back later or contact support for more information." %}

+

+ {% translate "We currently cannot offer this service. Please check back later or contact support for more information." %} +

diff --git a/src/servala/frontend/templates/includes/control_plane_user_info.html b/src/servala/frontend/templates/includes/control_plane_user_info.html index a3a27f5..fdcc995 100644 --- a/src/servala/frontend/templates/includes/control_plane_user_info.html +++ b/src/servala/frontend/templates/includes/control_plane_user_info.html @@ -4,9 +4,7 @@ {% for info in control_plane.user_info %}
- - {{ info.title }} - + {{ info.title }} {% if info.help_text %} + font-size: 0.875rem"> {% endif %}
diff --git a/src/servala/frontend/templates/includes/header.html b/src/servala/frontend/templates/includes/header.html index 9176a98..7db28b4 100644 --- a/src/servala/frontend/templates/includes/header.html +++ b/src/servala/frontend/templates/includes/header.html @@ -130,7 +130,7 @@ {% else %} - {% translate 'Login' %} + {% translate 'Sign in' %} {% endif %} diff --git a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html index 74fa22a..009ad94 100644 --- a/src/servala/frontend/templates/includes/tabbed_fieldset_form.html +++ b/src/servala/frontend/templates/includes/tabbed_fieldset_form.html @@ -6,68 +6,144 @@ {% if form_action %}action="{{ form_action }}"{% endif %}> {% csrf_token %} {% include "frontend/forms/errors.html" %} - {% if form.ADVANCED_FIELDS %} -
- + {% if form %} + {% endif %} - -
- {% 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 %} -
+
+ {% 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 %} + {% 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 %}
- {% endfor %} + {% endif %}
+ {% if expert_form %} +
+ {% 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 form %} + + {% endif %}
- + {# browser form validation fails when there are fields missing/invalid that are hidden #} +
- + +{% if form %} + +{% endif %} diff --git a/src/servala/frontend/templatetags/get_field.py b/src/servala/frontend/templatetags/get_field.py index 3214beb..2141178 100644 --- a/src/servala/frontend/templatetags/get_field.py +++ b/src/servala/frontend/templatetags/get_field.py @@ -1,3 +1,5 @@ +from contextlib import suppress + from django import template register = template.Library() @@ -5,4 +7,5 @@ register = template.Library() @register.filter def get_field(form, field_name): - return form[field_name] + with suppress(KeyError): + return form[field_name] diff --git a/src/servala/frontend/views/service.py b/src/servala/frontend/views/service.py index 689f381..1aeac59 100644 --- a/src/servala/frontend/views/service.py +++ b/src/servala/frontend/views/service.py @@ -66,14 +66,33 @@ class ServiceDetailView(OrganizationViewMixin, DetailView): def get_queryset(self): return self.request.organization.get_visible_services() - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - offerings = context["service"].offerings.all() + @cached_property + def visible_offerings(self): + offerings = self.object.offerings.all() if self.request.organization.limit_cloudproviders.exists(): offerings = offerings.filter( provider__in=self.request.organization.limit_cloudproviders.all() ) - context["visible_offerings"] = offerings.select_related("provider") + return offerings + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + + # If there's exactly one offering, skip provider selection and go directly to it + if self.visible_offerings.count() == 1: + offering = self.visible_offerings.first() + return redirect( + "frontend:organization.offering", + organization=self.request.organization.slug, + slug=self.object.slug, + pk=offering.pk, + ) + + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["visible_offerings"] = self.visible_offerings.select_related("provider") return context @@ -123,7 +142,9 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView def context_object(self): if self.request.method == "POST": return ControlPlaneCRD.objects.filter( - pk=self.request.POST.get("context"), + pk=self.request.POST.get( + "expert-context", self.request.POST.get("custom-context") + ), # Make sure we don’t use a malicious ID control_plane__in=self.planes, ).first() @@ -131,37 +152,59 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView control_plane=self.selected_plane, service_offering=self.object ).first() - def get_instance_form(self): - if not self.context_object or not self.context_object.model_form_class: - return None - - initial = { - "organization": self.request.organization, - "context": self.context_object, + def get_instance_form_kwargs(self, ignore_data=False): + return { + "initial": { + "organization": self.request.organization, + "context": self.context_object, + }, + "prefix": "expert", + "data": ( + self.request.POST + if (self.request.method == "POST" and not ignore_data) + else None + ), } - # Pre-populate FQDN field if it exists and control plane has wildcard DNS - form_class = self.context_object.model_form_class - if ( - "spec.parameters.service.fqdn" in form_class.base_fields - and self.context_object.control_plane.wildcard_dns - ): - # Generate initial FQDN: instancename-namespace.wildcard_dns - # We'll set a placeholder that JavaScript will replace dynamically - initial["spec.parameters.service.fqdn"] = "" + def get_instance_form(self, ignore_data=False): + if not self.context_object or not self.context_object.model_form_class: + return - return form_class( - data=self.request.POST if self.request.method == "POST" else None, - initial=initial, + return self.context_object.model_form_class( + **self.get_instance_form_kwargs(ignore_data=ignore_data) ) + def get_custom_instance_form(self, ignore_data=False): + if not self.context_object or not self.context_object.custom_model_form_class: + return + kwargs = self.get_instance_form_kwargs(ignore_data=ignore_data) + kwargs["prefix"] = "custom" + return self.context_object.custom_model_form_class(**kwargs) + + @property + def is_custom_form(self): + # Note: "custom form" = user-friendly, subset of fields + # vs "expert form" = auto-generated (all technical fields) + return self.request.POST.get("active_form", "expert") == "custom" + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["select_form"] = self.select_form context["has_control_planes"] = self.planes.exists() context["selected_plane"] = self.selected_plane - context["service_form"] = self.get_instance_form() - # Pass data for dynamic FQDN generation + if self.request.method == "POST": + if self.is_custom_form: + context["service_form"] = self.get_instance_form(ignore_data=True) + context["custom_service_form"] = self.get_custom_instance_form() + else: + context["service_form"] = self.get_instance_form() + context["custom_service_form"] = self.get_custom_instance_form( + ignore_data=True + ) + else: + context["service_form"] = self.get_instance_form() + context["custom_service_form"] = self.get_custom_instance_form() + 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 @@ -175,7 +218,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView context["form_error"] = True return self.render_to_response(context) - form = self.get_instance_form() + if self.is_custom_form: + form = self.get_custom_instance_form() + else: + form = self.get_instance_form() if not form: # Should not happen if context_object is valid, but as a safeguard messages.error( self.request, @@ -203,8 +249,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView ) form.add_error(None, error_message) - # If the form is not valid or if the service creation failed, we render it again - context["service_form"] = form return self.render_to_response(context) @@ -393,11 +437,75 @@ class ServiceInstanceUpdateView( def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["instance"] = self.object.spec_object + kwargs["prefix"] = "expert" return kwargs + def get_form(self, *args, ignore_data=False, **kwargs): + if not ignore_data: + return super().get_form(*args, **kwargs) + cls = self.get_form_class() + kwargs = self.get_form_kwargs() + if ignore_data: + kwargs.pop("data", None) + return cls(**kwargs) + + def get_custom_form(self, ignore_data=False): + cls = self.object.context.custom_model_form_class + if not cls: + return + kwargs = self.get_form_kwargs() + kwargs["prefix"] = "custom" + if ignore_data: + kwargs.pop("data", None) + return cls(**kwargs) + + @property + def is_custom_form(self): + # Note: "custom form" = user-friendly, subset of fields + # vs "expert form" = auto-generated (all technical fields) + return self.request.POST.get("active_form", "expert") == "custom" + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + if self.is_custom_form: + form = self.get_custom_form() + else: + form = self.get_form() + + if form.is_valid(): + return self.form_valid(form) + return self.form_invalid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.method == "POST": + if self.is_custom_form: + context["custom_form"] = self.get_custom_form() + context["form"] = self.get_form(ignore_data=True) + else: + context["custom_form"] = self.get_custom_form(ignore_data=True) + else: + context["custom_form"] = self.get_custom_form() + return context + + def _deep_merge(self, base, update): + for key, value in update.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + self._deep_merge(base[key], value) + else: + base[key] = value + return base + def form_valid(self, form): try: - spec_data = form.get_nested_data().get("spec") + form_data = form.get_nested_data() + spec_data = form_data.get("spec") + + if self.is_custom_form: + current_spec = dict(self.object.spec) if self.object.spec else {} + spec_data = self._deep_merge(current_spec, spec_data) + self.object.update_spec(spec_data=spec_data, updated_by=self.request.user) messages.success( self.request, diff --git a/src/servala/static/css/servala.css b/src/servala/static/css/servala.css index cb7061a..cc69a4f 100644 --- a/src/servala/static/css/servala.css +++ b/src/servala/static/css/servala.css @@ -237,42 +237,42 @@ a.btn-keycloak { flex-grow: 1; } -/* CRD Form mandatory field styling */ -.crd-form .form-group.mandatory .form-label { +/* Expert CRD Form mandatory field styling */ +.expert-crd-form .form-group.mandatory .form-label { font-weight: bold; position: relative; } -.crd-form .form-group.mandatory .form-label::after { +.expert-crd-form .form-group.mandatory .form-label::after { content: " *"; color: #dc3545; font-weight: bold; } -.crd-form .form-group.mandatory { +.expert-crd-form .form-group.mandatory { border-left: 3px solid #dc3545; padding-left: 10px; background-color: rgba(220, 53, 69, 0.05); border-radius: 3px; } -.crd-form .nav-tabs .nav-link .mandatory-indicator { +.expert-crd-form .nav-tabs .nav-link .mandatory-indicator { color: #dc3545; font-weight: bold; font-size: 1.1em; margin-left: 4px; } -html[data-bs-theme="dark"] .crd-form .form-group.mandatory { +html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory { background-color: rgba(220, 53, 69, 0.1); border-left-color: #ff6b6b; } -html[data-bs-theme="dark"] .crd-form .form-group.mandatory .form-label::after { +html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory .form-label::after { color: #ff6b6b; } -html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator { +html[data-bs-theme="dark"] .expert-crd-form .nav-tabs .nav-link .mandatory-indicator { color: #ff6b6b; } @@ -306,33 +306,6 @@ html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator { margin-left: auto !important } -/* Advanced fields tab flash animation */ -@keyframes tab-pulse { - 0%, 100% { - background-color: transparent; - box-shadow: none; - } - 50% { - background-color: var(--brand-light); - box-shadow: 0 0 10px rgba(154, 99, 236, 0.3); - } -} - -html[data-bs-theme="dark"] @keyframes tab-pulse { - 0%, 100% { - background-color: transparent; - box-shadow: none; - } - 50% { - background-color: rgba(154, 99, 236, 0.2); - box-shadow: 0 0 10px rgba(154, 99, 236, 0.4); - } -} - -.nav-tabs .nav-link.tab-flash { - animation: tab-pulse 1s ease-in-out 2; -} - .beta-banner { background: linear-gradient(135deg, var(--bs-primary) 0%, var(--brand-mid) 100%); color: white; diff --git a/src/servala/static/js/advanced-fields.js b/src/servala/static/js/advanced-fields.js deleted file mode 100644 index 989e61a..0000000 --- a/src/servala/static/js/advanced-fields.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Advanced Fields Toggle - * Handles showing/hiding advanced fields in CRD forms - */ -(function() { - 'use strict'; - - function flashTabsWithAdvancedFields() { - const advancedGroups = document.querySelectorAll('.advanced-field-group'); - const tabsToFlash = new Set(); - advancedGroups.forEach(function(group) { - const tabPane = group.closest('.tab-pane'); - if (tabPane) { - const tabId = tabPane.getAttribute('id'); - if (tabId) { - const tabButton = document.querySelector(`[data-bs-target="#${tabId}"]`); - if (tabButton && !tabButton.classList.contains('active')) { - tabsToFlash.add(tabButton); - } - } - } - }); - - tabsToFlash.forEach(function(tab) { - tab.classList.add('tab-flash'); - setTimeout(function() { - tab.classList.remove('tab-flash'); - }, 2000); - }); - } - - function initializeAdvancedFields() { - const advancedInputs = document.querySelectorAll('[data-advanced="true"]'); - - if (advancedInputs.length === 0) { - return; - } - - advancedInputs.forEach(function(input) { - const formGroup = input.closest('.form-group, .mb-3, .col-12, .col-md-6'); - if (formGroup) { - formGroup.classList.add('advanced-field-group', 'collapse'); - } - }); - - const toggleButton = document.getElementById('advanced-toggle'); - if (toggleButton) { - let isExpanded = false; - - document.querySelectorAll('.advanced-field-group').forEach(function(group) { - group.addEventListener('shown.bs.collapse', function() { - toggleButton.innerHTML = ' Hide Advanced Options'; - if (!isExpanded) { - isExpanded = true; - setTimeout(flashTabsWithAdvancedFields, 100); - } - }); - - group.addEventListener('hidden.bs.collapse', function() { - const anyVisible = Array.from(document.querySelectorAll('.advanced-field-group')).some( - g => g.classList.contains('show') - ); - if (!anyVisible) { - toggleButton.innerHTML = ' Show Advanced Options'; - isExpanded = false; - } - }); - }); - } - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeAdvancedFields); - } else { - initializeAdvancedFields(); - } - - document.body.addEventListener('htmx:afterSwap', function(event) { - if (event.detail.target.id === 'service-form' || event.detail.target.closest('.crd-form')) { - setTimeout(initializeAdvancedFields, 100); - } - }); -})(); diff --git a/src/servala/static/js/bootstrap-tabs.js b/src/servala/static/js/bootstrap-tabs.js new file mode 100644 index 0000000..d382475 --- /dev/null +++ b/src/servala/static/js/bootstrap-tabs.js @@ -0,0 +1,30 @@ +// Bootstrap 5 automatically initializes tabs with data-bs-toggle="tab" +// but we need to ensure they work after HTMX swaps +(function() { + 'use strict'; + + const initBootstrapTabs = () => { + const customTabList = document.querySelectorAll('#myTab button[data-bs-toggle="tab"]'); + customTabList.forEach(function(tabButton) { + new bootstrap.Tab(tabButton); + }); + + const expertTabList = document.querySelectorAll('#expertTab button[data-bs-toggle="tab"]'); + expertTabList.forEach(function(tabButton) { + new bootstrap.Tab(tabButton); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initBootstrapTabs); + } else { + initBootstrapTabs(); + } + + document.addEventListener('htmx:afterSwap', function(event) { + if (event.detail.target.id === 'service-form' || + event.detail.target.classList.contains('crd-form')) { + initBootstrapTabs(); + } + }); +})(); diff --git a/src/servala/static/js/expert-mode.js b/src/servala/static/js/expert-mode.js new file mode 100644 index 0000000..2bfc2fa --- /dev/null +++ b/src/servala/static/js/expert-mode.js @@ -0,0 +1,49 @@ +(function() { + 'use strict'; + + let isExpertMode = false; + + function initExpertMode() { + const toggleButton = document.getElementById('expert-mode-toggle'); + if (!toggleButton) return; + + const customFormContainer = document.getElementById('custom-form-container'); + const expertFormContainer = document.getElementById('expert-form-container'); + + if (!customFormContainer || !expertFormContainer) { + console.warn('Expert mode containers not found'); + return; + } + + toggleButton.addEventListener('click', function(e) { + e.preventDefault(); + isExpertMode = !isExpertMode; + + const activeFormInput = document.getElementById('active-form-input'); + + if (isExpertMode) { + customFormContainer.style.display = 'none'; + expertFormContainer.style.display = 'block'; + toggleButton.textContent = 'Show Simplified Form'; + if (activeFormInput) activeFormInput.value = 'expert'; + } else { + customFormContainer.style.display = 'block'; + expertFormContainer.style.display = 'none'; + toggleButton.textContent = 'Show Expert Mode'; + if (activeFormInput) activeFormInput.value = 'custom'; + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initExpertMode); + } else { + initExpertMode(); + } + + document.addEventListener('htmx:afterSwap', function(event) { + if (event.detail.target.id === 'service-form' || event.detail.target.classList.contains('crd-form')) { + initExpertMode(); + } + }); +})(); diff --git a/src/servala/static/js/fqdn.js b/src/servala/static/js/fqdn.js index a92071d..ec70dad 100644 --- a/src/servala/static/js/fqdn.js +++ b/src/servala/static/js/fqdn.js @@ -1,7 +1,9 @@ -const initializeFqdnGeneration = () => { - const nameField = document.querySelector('input#id_name'); - const fqdnField = document.querySelector('label[for="id_spec.parameters.service.fqdn"] + div input.array-item-input'); +const initializeFqdnGeneration = (prefix) => { + const nameField = document.querySelector(`input#id_${prefix}-name`); + const fqdnFieldContainer = document.getElementById(`${prefix}-spec.parameters.service.fqdn_container`) + if (!nameField || !fqdnFieldContainer) return + const fqdnField = fqdnFieldContainer.querySelector('input.array-item-input'); if (nameField && fqdnField) { const generateFqdn = (instanceName) => { @@ -9,28 +11,23 @@ const initializeFqdnGeneration = () => { return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`; } - const newNameField = nameField.cloneNode(true); - nameField.parentNode.replaceChild(newNameField, nameField); - const newFqdnField = fqdnField.cloneNode(true); - fqdnField.parentNode.replaceChild(newFqdnField, fqdnField); - - newNameField.addEventListener('input', function() { - if (!newFqdnField.dataset.manuallyEdited) { - newFqdnField.value = generateFqdn(this.value); - const container = newFqdnField.closest('.dynamic-array-widget'); + nameField.addEventListener('input', function() { + if (!fqdnField.dataset.manuallyEdited) { + fqdnField.value = generateFqdn(this.value); + const container = fqdnField.closest('.dynamic-array-widget'); if (container && window.updateHiddenInput) { window.updateHiddenInput(container); } } }); - newFqdnField.addEventListener('input', function() { + fqdnField.addEventListener('input', function() { this.dataset.manuallyEdited = 'true'; }); - if (newNameField.value && !newFqdnField.value) { - newFqdnField.value = generateFqdn(newNameField.value); - const container = newFqdnField.closest('.dynamic-array-widget'); + if (nameField.value && !fqdnField.value) { + fqdnField.value = generateFqdn(nameField.value); + const container = fqdnField.closest('.dynamic-array-widget'); if (container && window.updateHiddenInput) { window.updateHiddenInput(container); } @@ -38,9 +35,10 @@ const initializeFqdnGeneration = () => { } } -document.addEventListener('DOMContentLoaded', initializeFqdnGeneration); +document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")}); document.body.addEventListener('htmx:afterSwap', function(event) { if (event.detail.target.id === 'service-form') { - initializeFqdnGeneration(); + initializeFqdnGeneration("custom"); + initializeFqdnGeneration("expert"); } }); diff --git a/src/tests/test_form_config.py b/src/tests/test_form_config.py new file mode 100644 index 0000000..7188d61 --- /dev/null +++ b/src/tests/test_form_config.py @@ -0,0 +1,1086 @@ +from unittest.mock import Mock + +import jsonschema +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + +from servala.core.crd import generate_custom_form_class +from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS +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) + service_def = Mock() + service_def.form_config = { + "fieldsets": [ + { + "title": "General", + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + "required": True, + } + ], + } + ] + } + crd.service_definition = service_def + + class TestModel(models.Model): + name = models.CharField(max_length=100) + + class Meta: + app_label = "test" + + crd.django_model = TestModel + + if not ( + crd.django_model + and crd.service_definition + and crd.service_definition.form_config + and crd.service_definition.form_config.get("fieldsets") + ): + result = None + else: + result = generate_custom_form_class( + crd.service_definition.form_config, crd.django_model + ) + + assert result is not None + assert hasattr(result, "form_config") + + +def test_form_config_schema_validates_minimal_config(): + form = ServiceDefinitionAdminForm() + schema = form.form_config_schema + + minimal_config = { + "fieldsets": [ + { + "fields": [ + { + "type": "text", + "label": "Service Name", + "controlplane_field_mapping": "spec.serviceName", + } + ] + } + ] + } + + jsonschema.validate(instance=minimal_config, schema=schema) + + +def test_form_config_schema_validates_config_with_null_integers(): + form = ServiceDefinitionAdminForm() + schema = form.form_config_schema + + config_with_nulls = { + "fieldsets": [ + { + "fields": [ + { + "type": "text", + "label": "Service Name", + "controlplane_field_mapping": "spec.serviceName", + "max_length": None, + "required": False, + }, + { + "type": "textarea", + "label": "Description", + "controlplane_field_mapping": "spec.description", + "rows": None, + "max_length": None, + }, + { + "type": "number", + "label": "Port", + "controlplane_field_mapping": "spec.port", + "min_value": None, + "max_value": None, + }, + { + "type": "array", + "label": "Tags", + "controlplane_field_mapping": "spec.tags", + "min_values": None, + "max_values": None, + }, + ] + } + ] + } + + jsonschema.validate(instance=config_with_nulls, schema=schema) + + +def test_form_config_schema_validates_full_config(): + form = ServiceDefinitionAdminForm() + schema = form.form_config_schema + + full_config = { + "fieldsets": [ + { + "title": "Service Configuration", + "fields": [ + { + "type": "text", + "label": "Service Name", + "controlplane_field_mapping": "spec.serviceName", + "help_text": "Enter a unique service name", + "required": True, + "max_length": 100, + }, + { + "type": "email", + "label": "Admin Email", + "controlplane_field_mapping": "spec.adminEmail", + "help_text": "Contact email for service administrator", + "required": True, + "max_length": 255, + }, + { + "type": "textarea", + "label": "Description", + "controlplane_field_mapping": "spec.description", + "help_text": "Describe the service purpose", + "required": False, + "rows": 5, + "max_length": 500, + }, + { + "type": "number", + "label": "Port", + "controlplane_field_mapping": "spec.port", + "help_text": "Service port number", + "required": True, + "min_value": 1, + "max_value": 65535, + }, + { + "type": "choice", + "label": "Environment", + "controlplane_field_mapping": "spec.environment", + "help_text": "Deployment environment", + "required": True, + "choices": [ + ["dev", "Development"], + ["staging", "Staging"], + ["prod", "Production"], + ], + }, + { + "type": "checkbox", + "label": "Enable Monitoring", + "controlplane_field_mapping": "spec.monitoring.enabled", + "help_text": "Enable service monitoring", + "required": False, + }, + { + "type": "array", + "label": "Tags", + "controlplane_field_mapping": "spec.tags", + "help_text": "Service tags for organization", + "required": False, + "min_values": 0, + "max_values": 10, + }, + ], + } + ] + } + jsonschema.validate(instance=full_config, schema=schema) + + +def test_choice_field_uses_custom_choices_from_form_config(): + """Test that choice fields use custom choices when provided in form_config""" + + class TestModel(models.Model): + name = models.CharField(max_length=100) + environment = models.CharField( + max_length=20, + choices=[ + ("dev", "Development"), + ("staging", "Staging"), + ("prod", "Production"), + ("test", "Testing"), + ], + ) + + class Meta: + app_label = "test" + + form_config = { + "fieldsets": [ + { + "title": "General", + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + "required": True, + }, + { + "type": "choice", + "label": "Environment", + "controlplane_field_mapping": "environment", + "required": True, + "choices": [["dev", "Development"], ["prod", "Production"]], + }, + ], + } + ] + } + + form_class = generate_custom_form_class(form_config, TestModel) + form = form_class() + + environment_field = form.fields["environment"] + assert list(environment_field.choices) == [ + ("dev", "Development"), + ("prod", "Production"), + ] + + assert hasattr(environment_field, "_controlplane_choices") + assert len(environment_field._controlplane_choices) == 5 # 4 choices + empty choice + + +def test_choice_field_uses_control_plane_choices_when_no_custom_choices(): + + class TestModel(models.Model): + name = models.CharField(max_length=100) + environment = models.CharField( + max_length=20, + choices=[ + ("dev", "Development"), + ("staging", "Staging"), + ("prod", "Production"), + ], + ) + + class Meta: + app_label = "test" + + form_config = { + "fieldsets": [ + { + "title": "General", + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + "required": True, + }, + { + "type": "choice", + "label": "Environment", + "controlplane_field_mapping": "environment", + "required": True, + }, + ], + } + ] + } + + form_class = generate_custom_form_class(form_config, TestModel) + form = form_class() + + environment_field = form.fields["environment"] + choices_list = list(environment_field.choices) + assert len(choices_list) == 4 # 3 choices + empty choice + assert ("dev", "Development") in choices_list + + +def test_choice_field_validates_against_control_plane_choices(): + class TestModel(models.Model): + name = models.CharField(max_length=100) + environment = models.CharField( + max_length=20, + choices=[ + ("dev", "Development"), + ("staging", "Staging"), + ("prod", "Production"), + ], + ) + + class Meta: + app_label = "test" + + form_config = { + "fieldsets": [ + { + "title": "General", + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + "required": True, + }, + { + "type": "choice", + "label": "Environment", + "controlplane_field_mapping": "environment", + "required": True, + "choices": [["dev", "Development"], ["prod", "Production"]], + }, + ], + } + ] + } + + form_class = generate_custom_form_class(form_config, TestModel) + + form = form_class(data={"name": "test-service", "environment": "dev"}) + form.fields["context"].required = False # Skip context validation + assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" + + form = form_class(data={"name": "test-service", "environment": "prod"}) + form.fields["context"].required = False # Skip context validation + assert form.is_valid(), f"Form should be valid but has errors: {form.errors}" + + form = form_class(data={"name": "test-service", "environment": "invalid"}) + form.fields["context"].required = False # Skip context validation + assert not form.is_valid() + assert "environment" in form.errors + + +def test_admin_form_validates_choice_values_against_schema(): + + form = ServiceDefinitionAdminForm() + mock_crd = Mock() + mock_crd.resource_schema = { + "properties": { + "spec": { + "properties": { + "environment": { + "type": "string", + "enum": ["dev", "staging", "prod"], + } + } + } + } + } + + valid_form_config = { + "fieldsets": [ + { + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + }, + { + "type": "choice", + "label": "Environment", + "controlplane_field_mapping": "spec.environment", + "choices": [["dev", "Development"], ["prod", "Production"]], + }, + ] + } + ] + } + + spec_schema = mock_crd.resource_schema["properties"]["spec"] + errors = [] + + for field in valid_form_config["fieldsets"][0]["fields"]: + if field.get("type") == "choice": + form._validate_choice_field( + field, field["controlplane_field_mapping"], spec_schema, "spec", errors + ) + + assert len(errors) == 0, f"Expected no errors but got: {errors}" + + invalid_form_config = { + "fieldsets": [ + { + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + }, + { + "type": "choice", + "label": "Environment", + "controlplane_field_mapping": "spec.environment", + "choices": [ + ["dev", "Development"], + ["invalid", "Invalid Environment"], + ], + }, + ] + } + ] + } + + errors = [] + + for field in invalid_form_config["fieldsets"][0]["fields"]: + if field.get("type") == "choice": + form._validate_choice_field( + field, field["controlplane_field_mapping"], spec_schema, "spec", errors + ) + + assert len(errors) > 0, "Expected validation errors but got none" + error_message = str(errors[0]) + assert "invalid" in error_message.lower() + assert "Environment" in error_message + + +def test_number_field_min_max_sets_widget_attributes(): + class TestModel(models.Model): + name = models.CharField(max_length=100) + port = models.IntegerField() + replica_count = models.IntegerField() + + class Meta: + app_label = "test" + + form_config = { + "fieldsets": [ + { + "title": "General", + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + "required": True, + }, + { + "type": "number", + "label": "Port", + "controlplane_field_mapping": "port", + "required": True, + "min_value": 1, + "max_value": 65535, + }, + { + "type": "number", + "label": "Replicas", + "controlplane_field_mapping": "replica_count", + "required": True, + "min_value": 1, + "max_value": 10, + }, + ], + } + ] + } + + form_class = generate_custom_form_class(form_config, TestModel) + form = form_class() + + port_field = form.fields["port"] + assert port_field.widget.attrs.get("min") == 1 + assert port_field.widget.attrs.get("max") == 65535 + + replica_field = form.fields["replica_count"] + assert replica_field.widget.attrs.get("min") == 1 + assert replica_field.widget.attrs.get("max") == 10 + + port_validators = port_field.validators + assert any( + isinstance(v, MinValueValidator) and v.limit_value == 1 for v in port_validators + ) + assert any( + isinstance(v, MaxValueValidator) and v.limit_value == 65535 + for v in port_validators + ) + + +def test_default_value_for_all_field_types(): + + class TestModel(models.Model): + name = models.CharField(max_length=100) + description = models.TextField() + port = models.IntegerField() + environment = models.CharField( + max_length=20, + choices=[ + ("dev", "Development"), + ("staging", "Staging"), + ("prod", "Production"), + ], + ) + monitoring_enabled = models.BooleanField() + tags = models.JSONField() + + class Meta: + app_label = "test" + + form_config = { + "fieldsets": [ + { + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + "default_value": "default-name", + }, + { + "type": "textarea", + "label": "Description", + "controlplane_field_mapping": "description", + "default_value": "Default description text", + }, + { + "type": "number", + "label": "Port", + "controlplane_field_mapping": "port", + "default_value": "8080", + }, + { + "type": "choice", + "label": "Environment", + "controlplane_field_mapping": "environment", + "default_value": "dev", + }, + { + "type": "checkbox", + "label": "Enable Monitoring", + "controlplane_field_mapping": "monitoring_enabled", + "default_value": "true", + }, + { + "type": "array", + "label": "Tags", + "controlplane_field_mapping": "tags", + "default_value": "tag1,tag2,tag3", + }, + ], + } + ] + } + + form_class = generate_custom_form_class(form_config, TestModel) + form = form_class() + + assert form.fields["name"].initial == "default-name" + assert form.fields["description"].initial == "Default description text" + assert form.fields["port"].initial == "8080" + assert form.fields["environment"].initial == "dev" + assert form.fields["monitoring_enabled"].initial == "true" + assert form.fields["tags"].initial == "tag1,tag2,tag3" + + +def test_default_value_not_override_existing_instance(): + + class TestModel(models.Model): + name = models.CharField(max_length=100) + port = models.IntegerField() + + class Meta: + app_label = "test" + + form_config = { + "fieldsets": [ + { + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + "default_value": "default-name", + }, + { + "type": "number", + "label": "Port", + "controlplane_field_mapping": "port", + "default_value": "8080", + }, + ], + } + ] + } + + instance = TestModel(name="existing-name", port=3000) + form_class = generate_custom_form_class(form_config, TestModel) + form = form_class(instance=instance) + + assert form.initial["name"] == "existing-name" + assert form.initial["port"] == 3000 + + +def test_form_config_coerces_string_numbers_to_integers(): + form = ServiceDefinitionAdminForm() + schema = form.form_config_schema + + config_with_string_numbers = { + "fieldsets": [ + { + "fields": [ + { + "type": "text", + "label": "Service Name", + "controlplane_field_mapping": "spec.serviceName", + "max_length": "64", # String instead of integer + "required": True, + }, + { + "type": "textarea", + "label": "Description", + "controlplane_field_mapping": "spec.description", + "rows": "5", # String instead of integer + "max_length": "500", # String instead of integer + }, + { + "type": "number", + "label": "Port", + "controlplane_field_mapping": "spec.port", + "min_value": "1", # String instead of integer + "max_value": "65535", # String instead of integer + }, + { + "type": "array", + "label": "Tags", + "controlplane_field_mapping": "spec.tags", + "min_values": "0", # String instead of integer + "max_values": "10", # String instead of integer + }, + ] + } + ] + } + + normalized_config = form._normalize_form_config_types(config_with_string_numbers) + fields = normalized_config["fieldsets"][0]["fields"] + + assert fields[0]["max_length"] == 64 + assert isinstance(fields[0]["max_length"], int) + + assert fields[1]["rows"] == 5 + assert isinstance(fields[1]["rows"], int) + assert fields[1]["max_length"] == 500 + assert isinstance(fields[1]["max_length"], int) + + assert fields[2]["min_value"] == 1 + assert isinstance(fields[2]["min_value"], int) + assert fields[2]["max_value"] == 65535 + assert isinstance(fields[2]["max_value"], int) + + assert fields[3]["min_values"] == 0 + assert isinstance(fields[3]["min_values"], int) + assert fields[3]["max_values"] == 10 + assert isinstance(fields[3]["max_values"], int) + + jsonschema.validate(instance=normalized_config, schema=schema) + + +def test_form_config_handles_float_numbers(): + form = ServiceDefinitionAdminForm() + + config_with_floats = { + "fieldsets": [ + { + "fields": [ + { + "type": "number", + "label": "Price", + "controlplane_field_mapping": "spec.price", + "min_value": "0.01", # String float + "max_value": "999.99", # String float + }, + ] + } + ] + } + + normalized_config = form._normalize_form_config_types(config_with_floats) + field = normalized_config["fieldsets"][0]["fields"][0] + + assert field["min_value"] == 0.01 + assert isinstance(field["min_value"], float) + assert field["max_value"] == 999.99 + assert isinstance(field["max_value"], float) + + +def test_form_config_handles_empty_string_as_none(): + form = ServiceDefinitionAdminForm() + + config_with_empty_strings = { + "fieldsets": [ + { + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + "max_length": "", # Empty string + }, + ] + } + ] + } + + normalized_config = form._normalize_form_config_types(config_with_empty_strings) + field = normalized_config["fieldsets"][0]["fields"][0] + assert field["max_length"] is None + + +def test_single_element_choices_are_normalized(): + form = ServiceDefinitionAdminForm() + mock_crd = Mock() + mock_crd.resource_schema = { + "properties": { + "spec": { + "properties": { + "version": { + "type": "string", + "enum": ["6.2", "7.0", "7.2"], + } + } + } + } + } + + config_with_single_choices = { + "fieldsets": [ + { + "fields": [ + { + "type": "text", + "label": "Name", + "controlplane_field_mapping": "name", + }, + { + "type": "choice", + "label": "Version", + "controlplane_field_mapping": "spec.version", + "choices": [["6.2"]], # Single element - should be transformed + }, + ] + } + ] + } + + spec_schema = mock_crd.resource_schema["properties"]["spec"] + errors = [] + + for field in config_with_single_choices["fieldsets"][0]["fields"]: + if field.get("type") == "choice": + form._validate_choice_field( + field, field["controlplane_field_mapping"], spec_schema, "spec", errors + ) + + assert len(errors) == 0, f"Expected no errors but got: {errors}" + version_field = config_with_single_choices["fieldsets"][0]["fields"][1] + assert version_field["choices"] == [["6.2", "6.2"]] + + +def test_two_element_choices_work_correctly(): + form = ServiceDefinitionAdminForm() + mock_crd = Mock() + mock_crd.resource_schema = { + "properties": { + "spec": { + "properties": { + "version": { + "type": "string", + "enum": ["6.2", "7.0"], + } + } + } + } + } + + config_with_proper_choices = { + "fieldsets": [ + { + "fields": [ + { + "type": "choice", + "label": "Version", + "controlplane_field_mapping": "spec.version", + "choices": [["6.2", "Version 6.2"], ["7.0", "Version 7.0"]], + }, + ] + } + ] + } + + spec_schema = mock_crd.resource_schema["properties"]["spec"] + errors = [] + + for field in config_with_proper_choices["fieldsets"][0]["fields"]: + if field.get("type") == "choice": + form._validate_choice_field( + field, field["controlplane_field_mapping"], spec_schema, "spec", errors + ) + + assert len(errors) == 0, f"Expected no errors but got: {errors}" + version_field = config_with_proper_choices["fieldsets"][0]["fields"][0] + assert version_field["choices"] == [["6.2", "Version 6.2"], ["7.0", "Version 7.0"]] + + +def test_empty_choices_fail_validation(): + form = ServiceDefinitionAdminForm() + config_with_empty_choice = { + "fieldsets": [ + { + "fields": [ + { + "type": "choice", + "label": "Version", + "controlplane_field_mapping": "spec.version", + "choices": [[]], # Empty choice - invalid + }, + ] + } + ] + } + + errors = [] + + for field in config_with_empty_choice["fieldsets"][0]["fields"]: + if field.get("type") == "choice": + form._validate_choice_field( + field, field["controlplane_field_mapping"], {}, "spec", errors + ) + + assert len(errors) > 0 + assert "must have 1 or 2 elements" in str(errors[0]) + + +def test_three_plus_element_choices_fail_validation(): + form = ServiceDefinitionAdminForm() + config_with_long_choice = { + "fieldsets": [ + { + "fields": [ + { + "type": "choice", + "label": "Version", + "controlplane_field_mapping": "spec.version", + "choices": [ + ["6.2", "Version 6.2", "Extra"] + ], # 3 elements - invalid + }, + ] + } + ] + } + + errors = [] + + for field in config_with_long_choice["fieldsets"][0]["fields"]: + if field.get("type") == "choice": + form._validate_choice_field( + field, field["controlplane_field_mapping"], {}, "spec", errors + ) + + assert len(errors) > 0 + assert "must have 1 or 2 elements" in str(errors[0]) + + +def test_field_with_default_config_only_needs_mapping(): + + class TestModel(models.Model): + name = models.CharField(max_length=100) + + class Meta: + app_label = "test" + + minimal_config = { + "fieldsets": [ + { + "fields": [ + { + "controlplane_field_mapping": "name", + }, + ] + } + ] + } + + form_class = generate_custom_form_class(minimal_config, TestModel) + form = form_class() + + name_field = form.fields["name"] + assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"] + assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] + assert name_field.required == DEFAULT_FIELD_CONFIGS["name"]["required"] + + +def test_field_with_default_config_can_override_defaults(): + + class TestModel(models.Model): + name = models.CharField(max_length=100) + + class Meta: + app_label = "test" + + override_config = { + "fieldsets": [ + { + "fields": [ + { + "controlplane_field_mapping": "name", + "label": "Custom Name Label", + "required": False, + }, + ] + } + ] + } + + form_class = generate_custom_form_class(override_config, TestModel) + form = form_class() + + name_field = form.fields["name"] + assert name_field.label == "Custom Name Label" + assert name_field.required is False + assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] + + +def test_admin_form_validates_mandatory_fields_present(): + + mock_crd = Mock() + mock_crd.resource_schema = { + "properties": { + "spec": { + "properties": { + "environment": { + "type": "string", + "enum": ["dev", "prod"], + } + } + } + } + } + + config_without_name = { + "fieldsets": [ + { + "fields": [ + { + "type": "choice", + "label": "Environment", + "controlplane_field_mapping": "spec.environment", + "choices": [["dev", "Development"]], + }, + ] + } + ] + } + + errors = [] + included_mappings = set() + for fieldset in config_without_name.get("fieldsets", []): + for field in fieldset.get("fields", []): + mapping = field.get("controlplane_field_mapping") + included_mappings.add(mapping) + + for mandatory_field in MANDATORY_FIELDS: + if mandatory_field not in included_mappings: + errors.append(f"Required field '{mandatory_field}' must be included") + + assert len(errors) > 0 + assert "name" in str(errors[0]).lower() + + +def test_admin_form_validates_fields_without_defaults_need_label_and_type(): + config_with_incomplete_field = { + "fieldsets": [ + { + "fields": [ + {"controlplane_field_mapping": "name"}, # Has defaults - OK + { + "controlplane_field_mapping": "spec.unknown", # No defaults + # Missing label and type + }, + ] + } + ] + } + + errors = [] + + for fieldset in config_with_incomplete_field.get("fieldsets", []): + for field in fieldset.get("fields", []): + mapping = field.get("controlplane_field_mapping") + + if mapping not in DEFAULT_FIELD_CONFIGS: + if not field.get("label"): + errors.append( + f"Field with mapping '{mapping}' must have a 'label' property" + ) + if not field.get("type"): + errors.append( + f"Field with mapping '{mapping}' must have a 'type' property" + ) + + assert len(errors) == 2 + assert any("label" in str(e) for e in errors) + assert any("type" in str(e) for e in errors) + + +def test_empty_values_dont_override_default_configs(): + + class TestModel(models.Model): + name = models.CharField(max_length=100) + + class Meta: + app_label = "test" + + admin_form_config = { + "fieldsets": [ + { + "fields": [ + { + "controlplane_field_mapping": "name", + "type": "", + "label": "", + "help_text": None, + "max_length": None, + "required": False, + }, + ] + } + ] + } + + form_class = generate_custom_form_class(admin_form_config, TestModel) + form = form_class() + + name_field = form.fields["name"] + + assert name_field.label == DEFAULT_FIELD_CONFIGS["name"]["label"] + assert name_field.help_text == DEFAULT_FIELD_CONFIGS["name"]["help_text"] + assert name_field.max_length == DEFAULT_FIELD_CONFIGS["name"]["max_length"] + + assert name_field.required is False # Was overridden by explicit False diff --git a/src/tests/test_views.py b/src/tests/test_views.py index 5ec429e..a97ecd1 100644 --- a/src/tests/test_views.py +++ b/src/tests/test_views.py @@ -1,5 +1,6 @@ import pytest +from servala.core.models.service import CloudProvider, ServiceOffering @pytest.mark.parametrize( "url,redirect", @@ -45,3 +46,103 @@ def test_organization_linked_in_sidebar( assert response.status_code == 200 assert organization.name in response.content.decode() assert other_organization.name not in response.content.decode() + + +@pytest.mark.django_db +def test_service_detail_redirects_with_single_offering( + client, org_owner, organization, test_service, test_service_offering +): + client.force_login(org_owner) + url = f"/org/{organization.slug}/services/{test_service.slug}/" + response = client.get(url) + + assert response.status_code == 302 + expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/" + assert response.url == expected_url + + +@pytest.mark.django_db +def test_service_detail_shows_multiple_offerings( + client, org_owner, organization, test_service, test_service_offering +): + second_provider = CloudProvider.objects.create( + name="AWS", description="Amazon Web Services" + ) + second_offering = ServiceOffering.objects.create( + service=test_service, + provider=second_provider, + description="Redis on AWS", + osb_plan_id="test-plan-456", + ) + + client.force_login(org_owner) + url = f"/org/{organization.slug}/services/{test_service.slug}/" + response = client.get(url) + + assert response.status_code == 200 + content = response.content.decode() + + assert test_service_offering.provider.name in content + assert second_offering.provider.name in content + assert "Create Instance" in content + + +@pytest.mark.django_db +def test_service_detail_respects_cloud_provider_restrictions( + client, org_owner, organization, test_service, test_service_offering +): + second_provider = CloudProvider.objects.create( + name="AWS", description="Amazon Web Services" + ) + ServiceOffering.objects.create( + service=test_service, + provider=second_provider, + description="Redis on AWS", + osb_plan_id="test-plan-456", + ) + organization.origin.limit_cloudproviders.add(test_service_offering.provider) + + client.force_login(org_owner) + url = f"/org/{organization.slug}/services/{test_service.slug}/" + response = client.get(url) + + assert response.status_code == 302 + expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/" + assert response.url == expected_url + + +@pytest.mark.django_db +def test_service_detail_no_redirect_with_restricted_multiple_offerings( + client, org_owner, organization, test_service, test_service_offering +): + second_provider = CloudProvider.objects.create( + name="AWS", description="Amazon Web Services" + ) + second_offering = ServiceOffering.objects.create( + service=test_service, + provider=second_provider, + description="Redis on AWS", + osb_plan_id="test-plan-456", + ) + third_provider = CloudProvider.objects.create( + name="Azure", description="Microsoft Azure" + ) + third_offering = ServiceOffering.objects.create( + service=test_service, + provider=third_provider, + description="Redis on Azure", + osb_plan_id="test-plan-789", + ) + organization.origin.limit_cloudproviders.add( + test_service_offering.provider, second_provider + ) + + client.force_login(org_owner) + url = f"/org/{organization.slug}/services/{test_service.slug}/" + response = client.get(url) + + assert response.status_code == 200 + content = response.content.decode() + assert test_service_offering.provider.name in content + assert second_offering.provider.name in content + assert third_offering.provider.name not in content diff --git a/uv.lock b/uv.lock index f4cccd8..2624d5b 100644 --- a/uv.lock +++ b/uv.lock @@ -65,7 +65,7 @@ wheels = [ [[package]] name = "black" -version = "25.9.0" +version = "25.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -75,37 +75,41 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, + { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, ] [[package]] name = "boto3" -version = "1.40.59" +version = "1.40.64" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/f4/65f3a0a58a42abaa57cb42968535dc7c209232c2614d5ac1d8354b0bc0b7/boto3-1.40.59.tar.gz", hash = "sha256:b1a5a203511e594872b39a129365f02eb5846eea990629e8daf47a3c01e7fd49", size = 111577, upload-time = "2025-10-24T19:23:33.763Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/d2/e508e5f42dc1c8a7412f5170751e626a18ed32c6e95c5df30bde6c5addf1/boto3-1.40.64.tar.gz", hash = "sha256:b92d6961c352f2bb8710c9892557d4b0e11258b70967d4e740e1c97375bcd779", size = 111543, upload-time = "2025-10-31T19:33:24.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/61/25cbd486b03d4f786507584025a35ae74fa52fe2408fbf0318d91e3e96db/boto3-1.40.59-py3-none-any.whl", hash = "sha256:75752e7dc445131700a58926a50ca705794232f0f47d0e21edb59fbf1898db95", size = 139323, upload-time = "2025-10-24T19:23:31.824Z" }, + { url = "https://files.pythonhosted.org/packages/65/c2/27da558ceb90d17b1e4c0cca5dab29f8aea7f63242a1005a8f54230ce5e6/boto3-1.40.64-py3-none-any.whl", hash = "sha256:35ca3dd80dd90d5f4e8ed032440f28790696fdf50f48c0d16a09a75675f9112f", size = 139321, upload-time = "2025-10-31T19:33:22.92Z" }, ] [[package]] name = "botocore" -version = "1.40.59" +version = "1.40.64" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/0a/4abd361449e495bc6f0eb24dc14213c1468253a5be63cfcd3b6f9feca992/botocore-1.40.59.tar.gz", hash = "sha256:842a466d8735272a30fe5b7f97df559d9e211a18e412f62a17ed249fd62f85fe", size = 14472896, upload-time = "2025-10-24T19:23:22.467Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/15/109cb31c156a64bfaf4c809d2638fd95d8ba39b6deb7f1d0526c05257fd7/botocore-1.40.64.tar.gz", hash = "sha256:a13af4009f6912eafe32108f6fa584fb26e24375149836c2bcaaaaec9a7a9e58", size = 14409921, upload-time = "2025-10-31T19:33:12.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/34/72ba24f52b14669384ede828ea08927b444c52311e67e02d9cdc6f00b882/botocore-1.40.59-py3-none-any.whl", hash = "sha256:042dd844ca82155ca1ab9608b9bef36d517515c775d075f57b89257108ae843b", size = 14139459, upload-time = "2025-10-24T19:23:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c5/70bec18aef3fe9af63847d8766f81864b20daacd1dc7bf0c1d1ad90c7e98/botocore-1.40.64-py3-none-any.whl", hash = "sha256:6902b3dadfba1fbacc9648171bef3942530d8f823ff2bdb0e585a332323f89fc", size = 14072939, upload-time = "2025-10-31T19:33:09.081Z" }, ] [[package]] @@ -222,37 +226,37 @@ wheels = [ [[package]] name = "coverage" -version = "7.11.0" +version = "7.11.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } +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/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, - { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, - { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, - { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, - { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, - { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, - { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, - { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, - { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, - { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, - { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, - { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, - { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, - { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, + { 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]] @@ -327,27 +331,30 @@ wheels = [ [[package]] name = "django" -version = "5.2.7" +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/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" } +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/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" }, + { 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.12.1" +version = "65.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/94/75d7f8c59e061d1b66a6d917b287817fe02d2671c9e6376a4ddfb3954989/django_allauth-65.12.1.tar.gz", hash = "sha256:662666ff2d5c71766f66b1629ac7345c30796813221184e13e11ed7460940c6a", size = 1967971, upload-time = "2025-10-16T16:39:58.342Z" } +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/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]] name = "django-auditlog" @@ -506,16 +513,16 @@ wheels = [ [[package]] name = "google-auth" -version = "2.41.1" +version = "2.42.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, { name = "pyasn1-modules" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/af/5129ce5b2f9688d2fa49b463e544972a7c82b0fdb50980dafee92e121d9f/google_auth-2.41.1.tar.gz", hash = "sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2", size = 292284, upload-time = "2025-09-30T22:51:26.363Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/6b/22a77135757c3a7854c9f008ffed6bf4e8851616d77faf13147e9ab5aae6/google_auth-2.42.1.tar.gz", hash = "sha256:30178b7a21aa50bffbdc1ffcb34ff770a2f65c712170ecd5446c4bef4dc2b94e", size = 295541, upload-time = "2025-10-30T16:42:19.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl", hash = "sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d", size = 221302, upload-time = "2025-09-30T22:51:24.212Z" }, + { url = "https://files.pythonhosted.org/packages/92/05/adeb6c495aec4f9d93f9e2fc29eeef6e14d452bba11d15bdb874ce1d5b10/google_auth-2.42.1-py2.py3-none-any.whl", hash = "sha256:eb73d71c91fc95dbd221a2eb87477c278a355e7367a35c0d84e6b0e5f9b4ad11", size = 222550, upload-time = "2025-10-30T16:42:17.878Z" }, ] [[package]] @@ -816,7 +823,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -825,9 +832,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" }, ] [[package]] @@ -882,11 +889,11 @@ wheels = [ [[package]] name = "pytokens" -version = "0.2.0" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/c2/dbadcdddb412a267585459142bfd7cc241e6276db69339353ae6e241ab2b/pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43", size = 15368, upload-time = "2025-10-15T08:02:42.738Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/5a/c269ea6b348b6f2c32686635df89f32dbe05df1088dd4579302a6f8f99af/pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8", size = 12038, upload-time = "2025-10-15T08:02:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, ] [[package]] @@ -1126,8 +1133,8 @@ dev = [ requires-dist = [ { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "cryptography", specifier = ">=46.0.3" }, - { name = "django", specifier = "==5.2.7" }, - { name = "django-allauth", specifier = ">=65.12.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.0" }, { name = "django-jsonform", specifier = ">=2.23.2" }, @@ -1147,15 +1154,15 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "black", specifier = ">=25.9.0" }, + { name = "black", specifier = ">=25.11.0" }, { name = "bumpver", specifier = ">=2025.1131" }, - { name = "coverage", specifier = ">=7.11.0" }, + { name = "coverage", specifier = ">=7.11.3" }, { name = "djlint", specifier = ">=1.36.4" }, { name = "flake8", specifier = ">=7.3.0" }, { name = "flake8-bugbear", specifier = ">=25.10.21" }, { name = "flake8-pyproject", specifier = ">=1.2.3" }, { name = "isort", specifier = ">=7.0.0" }, - { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest", specifier = ">=9.0.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-django", specifier = ">=4.11.1" }, { name = "pytest-mock", specifier = ">=3.15.1" },