Compare commits
55 commits
a004904042
...
111cf3f03a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
111cf3f03a | ||
| 21102f30c7 | |||
| ffed6139cd | |||
| 45f17cabaa | |||
| f3e14b4c85 | |||
| 9ac9f5e1c9 | |||
| 7a8dc91afe | |||
| c7c22aa265 | |||
| 561abc5f76 | |||
| 93b5a2a366 | |||
| 228ab9bc0d | |||
| 821e150239 | |||
|
|
0806523f18 | ||
|
|
eb8176d446 | ||
|
|
14f813fe25 | ||
|
|
ec72ab88c4 | ||
| 985e4b47c0 | |||
| 6182b36daf | |||
| 1ed261d4b2 | |||
| 14d60f80ca | |||
| fa7a170871 | |||
| ece60ad3b1 | |||
| bab9d636ee | |||
| 089dbb663a | |||
| b3bb41b322 | |||
| 29edfd2d3a | |||
| 68dce4c5fb | |||
| 7fbd57d1b1 | |||
| 16d8ac0c6d | |||
| 9e1804a141 | |||
| 5b496ec5b2 | |||
|
|
7d04e20bc0 | ||
| bbc1d735b6 | |||
| be00fb75cb | |||
| 7aa1040f16 | |||
| 6ef18d415f | |||
| 948ff5b8d8 | |||
| ca485978b9 | |||
| 59e7a75c51 | |||
| a5d46b696f | |||
| 5cc582b638 | |||
| aa77a10de2 | |||
| 7f99c78084 | |||
| 2931315b96 | |||
| 63039171c1 | |||
| 652e0798f4 | |||
| 9e7330e24d | |||
| cedcab85c4 | |||
| 0045e532ee | |||
| 357e39b543 | |||
| 1cf1947539 | |||
| 880d10c5e5 | |||
|
|
9eb6d71212 | ||
|
|
078f5aa90f | ||
|
|
ac8d38eee1 |
32 changed files with 2873 additions and 882 deletions
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
||||||
- name: Renovate
|
- name: Renovate
|
||||||
uses: https://github.com/renovatebot/github-action@v43.0.19
|
uses: https://github.com/renovatebot/github-action@v44.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ requires-python = ">=3.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"cryptography>=46.0.3",
|
"cryptography>=46.0.3",
|
||||||
"django==5.2.7",
|
"django==5.2.8",
|
||||||
"django-allauth>=65.12.1",
|
"django-allauth>=65.13.0",
|
||||||
"django-auditlog>=3.3.0",
|
"django-auditlog>=3.3.0",
|
||||||
"django-fernet-encrypted-fields>=0.3.0",
|
"django-fernet-encrypted-fields>=0.3.0",
|
||||||
"django-jsonform>=2.23.2",
|
"django-jsonform>=2.23.2",
|
||||||
|
|
@ -28,15 +28,15 @@ dependencies = [
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"black>=25.9.0",
|
"black>=25.11.0",
|
||||||
"bumpver>=2025.1131",
|
"bumpver>=2025.1131",
|
||||||
"coverage>=7.11.0",
|
"coverage>=7.11.3",
|
||||||
"djlint>=1.36.4",
|
"djlint>=1.36.4",
|
||||||
"flake8>=7.3.0",
|
"flake8>=7.3.0",
|
||||||
"flake8-bugbear>=25.10.21",
|
"flake8-bugbear>=25.10.21",
|
||||||
"flake8-pyproject>=1.2.3",
|
"flake8-pyproject>=1.2.3",
|
||||||
"isort>=7.0.0",
|
"isort>=7.0.0",
|
||||||
"pytest>=8.4.2",
|
"pytest>=9.0.0",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
"pytest-django>=4.11.1",
|
"pytest-django>=4.11.1",
|
||||||
"pytest-mock>=3.15.1",
|
"pytest-mock>=3.15.1",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_jsonform.widgets import JSONFormWidget
|
from django_jsonform.widgets import JSONFormWidget
|
||||||
|
|
@ -313,9 +316,9 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
|
||||||
(
|
(
|
||||||
_("Form Configuration"),
|
_("Form Configuration"),
|
||||||
{
|
{
|
||||||
"fields": ("advanced_fields",),
|
"fields": ("form_config",),
|
||||||
"description": _(
|
"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):
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
form = super().get_form(request, obj, **kwargs)
|
form = super().get_form(request, obj, **kwargs)
|
||||||
# JSON schema for advanced_fields field
|
schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json"
|
||||||
advanced_fields_schema = {
|
with open(schema_path) as f:
|
||||||
"type": "array",
|
form_config_schema = json.load(f)
|
||||||
"title": "Advanced Fields",
|
|
||||||
"items": {
|
if "form_config" in form.base_fields:
|
||||||
"type": "string",
|
form.base_fields["form_config"].widget = JSONFormWidget(
|
||||||
"title": "Field Name",
|
schema=form_config_schema
|
||||||
"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
|
|
||||||
)
|
)
|
||||||
return form
|
return form
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
31
src/servala/core/crd/__init__.py
Normal file
31
src/servala/core/crd/__init__.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
454
src/servala/core/crd/forms.py
Normal file
454
src/servala/core/crd/forms.py
Normal file
|
|
@ -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)
|
||||||
167
src/servala/core/crd/models.py
Normal file
167
src/servala/core/crd/models.py
Normal file
|
|
@ -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
|
||||||
115
src/servala/core/crd/utils.py
Normal file
115
src/servala/core/crd/utils.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jsonschema
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_jsonform.widgets import JSONFormWidget
|
from django_jsonform.widgets import JSONFormWidget
|
||||||
|
|
||||||
|
from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS
|
||||||
from servala.core.models import ControlPlane, ServiceDefinition
|
from servala.core.models import ControlPlane, ServiceDefinition
|
||||||
|
|
||||||
CONTROL_PLANE_USER_INFO_SCHEMA = {
|
CONTROL_PLANE_USER_INFO_SCHEMA = {
|
||||||
|
|
@ -96,6 +101,12 @@ class ControlPlaneAdminForm(forms.ModelForm):
|
||||||
return super().save(*args, **kwargs)
|
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):
|
class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
api_group = forms.CharField(
|
api_group = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
|
|
@ -124,6 +135,10 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
self.fields["api_version"].initial = api_def.get("version", "")
|
self.fields["api_version"].initial = api_def.get("version", "")
|
||||||
self.fields["api_kind"].initial = api_def.get("kind", "")
|
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):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
|
|
@ -151,8 +166,250 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
api_def["kind"] = api_kind
|
api_def["kind"] = api_kind
|
||||||
cleaned_data["api_definition"] = api_def
|
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
|
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):
|
def save(self, *args, **kwargs):
|
||||||
self.instance.api_definition = self.cleaned_data["api_definition"]
|
self.instance.api_definition = self.cleaned_data["api_definition"]
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
|
||||||
32
src/servala/core/migrations/0012_remove_advanced_fields.py
Normal file
32
src/servala/core/migrations/0012_remove_advanced_fields.py
Normal file
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
27
src/servala/core/migrations/0013_add_form_config.py
Normal file
27
src/servala/core/migrations/0013_add_form_config.py
Normal file
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -360,15 +360,15 @@ class ServiceDefinition(ServalaModelMixin, models.Model):
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
advanced_fields = models.JSONField(
|
form_config = models.JSONField(
|
||||||
verbose_name=_("Advanced fields"),
|
verbose_name=_("Form Configuration"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"Array of field names that should be hidden behind an 'Advanced' toggle. "
|
"Optional custom form configuration. When provided, this configuration will be used "
|
||||||
"Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])"
|
"to render the service form instead of auto-generating it from the OpenAPI spec. "
|
||||||
|
'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}'
|
||||||
),
|
),
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
default=list,
|
|
||||||
)
|
)
|
||||||
service = models.ForeignKey(
|
service = models.ForeignKey(
|
||||||
to="Service",
|
to="Service",
|
||||||
|
|
@ -510,9 +510,22 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
if not self.django_model:
|
if not self.django_model:
|
||||||
return
|
return
|
||||||
advanced_fields = self.service_definition.advanced_fields or []
|
return generate_model_form_class(self.django_model)
|
||||||
return generate_model_form_class(
|
|
||||||
self.django_model, advanced_fields=advanced_fields
|
@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
|
||||||
return self.context.django_model(
|
return self.context.django_model(
|
||||||
name=self.name,
|
name=self.name,
|
||||||
organization=self.organization,
|
|
||||||
context=self.context,
|
context=self.context,
|
||||||
spec=self.spec,
|
spec=self.spec,
|
||||||
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
||||||
|
|
|
||||||
107
src/servala/core/schemas/form_config_schema.json
Normal file
107
src/servala/core/schemas/form_config_schema.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.core.odoo import get_invoice_addresses, get_odoo_countries
|
||||||
from servala.frontend.forms.mixins import HtmxMixin
|
from servala.frontend.forms.mixins import HtmxMixin
|
||||||
|
|
||||||
|
|
||||||
ORG_NAME_PATTERN = r"[\w\s\-.,&'()+]+"
|
ORG_NAME_PATTERN = r"[\w\s\-.,&'()+]+"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,14 @@
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5>
|
<h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5>
|
||||||
<p class="text-muted mb-0">
|
<p class="text-muted mb-0">
|
||||||
{% 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" %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% for provider in socialaccount_providers %}
|
{% for provider in socialaccount_providers %}
|
||||||
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
|
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
|
||||||
<form method="post" action="{{ href }}" class="d-flex justify-content-center">
|
<form method="post"
|
||||||
|
action="{{ href }}"
|
||||||
|
class="d-flex justify-content-center">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ redirect_field }}
|
{{ redirect_field }}
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
|
|
@ -40,7 +42,7 @@
|
||||||
style="border-radius: 12px;
|
style="border-radius: 12px;
|
||||||
box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2);
|
box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2);
|
||||||
background: linear-gradient(135deg, var(--bs-primary), #8B5CF6)">
|
background: linear-gradient(135deg, var(--bs-primary), #8B5CF6)">
|
||||||
<span>{% translate "Sign in with VSHN Account" %}</span>
|
<span>{% translate "Sign in or Register" %}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="dynamic-array-widget"
|
<div class="dynamic-array-widget"
|
||||||
id="{{ widget.attrs.id|default:'id_'|add:widget.name }}_container"
|
id="{{ widget.name }}_container"
|
||||||
data-name="{{ widget.name }}"
|
data-name="{{ widget.name }}"
|
||||||
{% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}
|
{% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if field.use_fieldset %}</fieldset>{% endif %}
|
{% if field.use_fieldset %}</fieldset>{% endif %}
|
||||||
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
|
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
|
||||||
{% if field.help_text %}
|
{% if field.help_text and not field.is_hidden and not field.field.widget.input_type == "hidden" %}
|
||||||
<small class="form-text text-muted"
|
<small class="form-text text-muted"
|
||||||
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
|
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include "includes/tabbed_fieldset_form.html" with form=form %}
|
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endpartialdef %}
|
{% endpartialdef %}
|
||||||
{% partialdef service-form %}
|
{% partialdef service-form %}
|
||||||
{% if service_form %}
|
{% if service_form or custom_service_form %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex align-items-center"></div>
|
<div class="card-header d-flex align-items-center"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -42,7 +42,9 @@
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
<div>
|
<div>
|
||||||
<strong>{% translate "Service Unavailable" %}</strong>
|
<strong>{% translate "Service Unavailable" %}</strong>
|
||||||
<p class="mb-0">{% translate "We currently cannot offer this service. Please check back later or contact support for more information." %}</p>
|
<p class="mb-0">
|
||||||
|
{% translate "We currently cannot offer this service. Please check back later or contact support for more information." %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@
|
||||||
{% for info in control_plane.user_info %}
|
{% for info in control_plane.user_info %}
|
||||||
<div class="info-item mb-3">
|
<div class="info-item mb-3">
|
||||||
<div class="d-flex align-items-center mb-1">
|
<div class="d-flex align-items-center mb-1">
|
||||||
<small class="text-muted fw-semibold">
|
<small class="text-muted fw-semibold">{{ info.title }}</small>
|
||||||
{{ info.title }}
|
|
||||||
</small>
|
|
||||||
{% if info.help_text %}
|
{% if info.help_text %}
|
||||||
<i class="bi bi-info-circle ms-1 text-muted"
|
<i class="bi bi-info-circle ms-1 text-muted"
|
||||||
data-bs-toggle="popover"
|
data-bs-toggle="popover"
|
||||||
|
|
@ -14,7 +12,7 @@
|
||||||
data-bs-placement="top"
|
data-bs-placement="top"
|
||||||
data-bs-content="{{ info.help_text }}"
|
data-bs-content="{{ info.help_text }}"
|
||||||
style="cursor: help;
|
style="cursor: help;
|
||||||
font-size: 0.875rem;"></i>
|
font-size: 0.875rem"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-light-subtle p-2 rounded">
|
<div class="bg-light-subtle p-2 rounded">
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'account_login' %}" class="sidebar-link">
|
<a href="{% url 'account_login' %}" class="sidebar-link">
|
||||||
<i class="bi bi-person-badge-fill"></i>
|
<i class="bi bi-person-badge-fill"></i>
|
||||||
<span>{% translate 'Login' %}</span>
|
<span>{% translate 'Sign in' %}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="#" class="burger-btn d-block d-xl-none">
|
<a href="#" class="burger-btn d-block d-xl-none">
|
||||||
|
|
|
||||||
|
|
@ -6,68 +6,144 @@
|
||||||
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% include "frontend/forms/errors.html" %}
|
{% include "frontend/forms/errors.html" %}
|
||||||
{% if form.ADVANCED_FIELDS %}
|
{% if form %}
|
||||||
<div class="mb-3">
|
<div class="mb-3 text-end">
|
||||||
<button type="button"
|
<a href="#"
|
||||||
class="btn btn-sm btn-outline-secondary ml-auto d-block"
|
class="text-muted small"
|
||||||
id="advanced-toggle"
|
id="expert-mode-toggle"
|
||||||
data-bs-toggle="collapse"
|
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
|
||||||
data-bs-target=".advanced-field-group"
|
|
||||||
aria-expanded="false">
|
|
||||||
<i class="bi bi-gear"></i> {% translate "Show Advanced Options" %}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
<div id="custom-form-container"
|
||||||
{% for fieldset in form.get_fieldsets %}
|
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}">
|
||||||
{% if not fieldset.hidden %}
|
{% if form and form.context %}{{ form.context }}{% endif %}
|
||||||
<li class="nav-item{% if fieldset.is_advanced %} advanced-field-group collapse{% endif %}"
|
{% if form and form.get_fieldsets|length == 1 %}
|
||||||
role="presentation">
|
{# Single fieldset - render without tabs #}
|
||||||
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
{% for fieldset in form.get_fieldsets %}
|
||||||
id="{{ fieldset.title|slugify }}-tab"
|
<div class="my-2">
|
||||||
data-bs-toggle="tab"
|
{% for field in fieldset.fields %}
|
||||||
data-bs-target="#{{ fieldset.title|slugify }}"
|
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
type="button"
|
{% endfor %}
|
||||||
role="tab"
|
{% for subfieldset in fieldset.fieldsets %}
|
||||||
aria-controls="{{ fieldset.title|slugify }}"
|
{% if subfieldset.fields %}
|
||||||
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
<div>
|
||||||
{{ fieldset.title }}
|
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
||||||
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
{% for field in subfieldset.fields %}
|
||||||
</button>
|
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
</li>
|
{% endfor %}
|
||||||
{% endif %}
|
</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</ul>
|
{% endfor %}
|
||||||
<div class="tab-content" id="myTabContent">
|
</div>
|
||||||
{% for fieldset in form.get_fieldsets %}
|
{% endfor %}
|
||||||
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
{% elif form %}
|
||||||
id="{{ fieldset.title|slugify }}"
|
{# Multiple fieldsets or auto-generated form - render with tabs #}
|
||||||
role="tabpanel"
|
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||||
aria-labelledby="{{ fieldset.title|slugify }}-tab">
|
{% for fieldset in form.get_fieldsets %}
|
||||||
{% for field in fieldset.fields %}
|
{% if not fieldset.hidden %}
|
||||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
<li class="nav-item" role="presentation">
|
||||||
{% endfor %}
|
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
||||||
{% for subfieldset in fieldset.fieldsets %}
|
id="{{ fieldset.title|slugify }}-tab"
|
||||||
{% if subfieldset.fields %}
|
data-bs-toggle="tab"
|
||||||
<div {% if subfieldset.is_advanced %}class="advanced-field-group collapse"{% endif %}>
|
data-bs-target="#custom-{{ fieldset.title|slugify }}"
|
||||||
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
type="button"
|
||||||
{% for field in subfieldset.fields %}
|
role="tab"
|
||||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
aria-controls="custom-{{ fieldset.title|slugify }}"
|
||||||
{% endfor %}
|
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||||
</div>
|
{{ fieldset.title }}
|
||||||
|
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content" id="myTabContent">
|
||||||
|
{% for fieldset in form.get_fieldsets %}
|
||||||
|
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
||||||
|
id="custom-{{ fieldset.title|slugify }}"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="custom-{{ fieldset.title|slugify }}-tab">
|
||||||
|
{% for field in fieldset.fields %}
|
||||||
|
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for subfieldset in fieldset.fieldsets %}
|
||||||
|
{% if subfieldset.fields %}
|
||||||
|
<div>
|
||||||
|
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
||||||
|
{% for field in subfieldset.fields %}
|
||||||
|
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if expert_form %}
|
||||||
|
<div id="expert-form-container"
|
||||||
|
class="expert-crd-form"
|
||||||
|
style="{% if form %}display:none{% endif %}">
|
||||||
|
{% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %}
|
||||||
|
<ul class="nav nav-tabs" id="expertTab" role="tablist">
|
||||||
|
{% for fieldset in expert_form.get_fieldsets %}
|
||||||
|
{% if not fieldset.hidden %}
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
||||||
|
id="expert-{{ fieldset.title|slugify }}-tab"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
data-bs-target="#expert-{{ fieldset.title|slugify }}"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="expert-{{ fieldset.title|slugify }}"
|
||||||
|
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||||
|
{{ fieldset.title }}
|
||||||
|
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content" id="expertTabContent">
|
||||||
|
{% for fieldset in expert_form.get_fieldsets %}
|
||||||
|
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
||||||
|
id="expert-{{ fieldset.title|slugify }}"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="expert-{{ fieldset.title|slugify }}-tab">
|
||||||
|
{% for field in fieldset.fields %}
|
||||||
|
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for subfieldset in fieldset.fieldsets %}
|
||||||
|
{% if subfieldset.fields %}
|
||||||
|
<div>
|
||||||
|
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
||||||
|
{% for field in subfieldset.fields %}
|
||||||
|
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form %}
|
||||||
|
<input type="hidden"
|
||||||
|
name="active_form"
|
||||||
|
id="active-form-input"
|
||||||
|
value="custom">
|
||||||
|
{% endif %}
|
||||||
<div class="col-sm-12 d-flex justify-content-end">
|
<div class="col-sm-12 d-flex justify-content-end">
|
||||||
<button class="btn btn-primary me-1 mb-1" type="submit">
|
{# browser form validation fails when there are fields missing/invalid that are hidden #}
|
||||||
{% if form_submit_label %}
|
<input class="btn btn-primary me-1 mb-1"
|
||||||
{{ form_submit_label }}
|
type="submit"
|
||||||
{% else %}
|
{% if form and expert_form %}formnovalidate{% endif %}
|
||||||
{% translate "Save" %}
|
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
|
||||||
{% endif %}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<script defer src="{% static 'js/advanced-fields.js' %}"></script>
|
<script defer src="{% static 'js/bootstrap-tabs.js' %}"></script>
|
||||||
|
{% if form %}
|
||||||
|
<script defer src="{% static 'js/expert-mode.js' %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
@ -5,4 +7,5 @@ register = template.Library()
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def get_field(form, field_name):
|
def get_field(form, field_name):
|
||||||
return form[field_name]
|
with suppress(KeyError):
|
||||||
|
return form[field_name]
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,33 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.organization.get_visible_services()
|
return self.request.organization.get_visible_services()
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
@cached_property
|
||||||
context = super().get_context_data(**kwargs)
|
def visible_offerings(self):
|
||||||
offerings = context["service"].offerings.all()
|
offerings = self.object.offerings.all()
|
||||||
if self.request.organization.limit_cloudproviders.exists():
|
if self.request.organization.limit_cloudproviders.exists():
|
||||||
offerings = offerings.filter(
|
offerings = offerings.filter(
|
||||||
provider__in=self.request.organization.limit_cloudproviders.all()
|
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
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -123,7 +142,9 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
def context_object(self):
|
def context_object(self):
|
||||||
if self.request.method == "POST":
|
if self.request.method == "POST":
|
||||||
return ControlPlaneCRD.objects.filter(
|
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
|
# Make sure we don’t use a malicious ID
|
||||||
control_plane__in=self.planes,
|
control_plane__in=self.planes,
|
||||||
).first()
|
).first()
|
||||||
|
|
@ -131,37 +152,59 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
control_plane=self.selected_plane, service_offering=self.object
|
control_plane=self.selected_plane, service_offering=self.object
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
def get_instance_form(self):
|
def get_instance_form_kwargs(self, ignore_data=False):
|
||||||
if not self.context_object or not self.context_object.model_form_class:
|
return {
|
||||||
return None
|
"initial": {
|
||||||
|
"organization": self.request.organization,
|
||||||
initial = {
|
"context": self.context_object,
|
||||||
"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
|
def get_instance_form(self, ignore_data=False):
|
||||||
form_class = self.context_object.model_form_class
|
if not self.context_object or not self.context_object.model_form_class:
|
||||||
if (
|
return
|
||||||
"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"] = ""
|
|
||||||
|
|
||||||
return form_class(
|
return self.context_object.model_form_class(
|
||||||
data=self.request.POST if self.request.method == "POST" else None,
|
**self.get_instance_form_kwargs(ignore_data=ignore_data)
|
||||||
initial=initial,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["select_form"] = self.select_form
|
context["select_form"] = self.select_form
|
||||||
context["has_control_planes"] = self.planes.exists()
|
context["has_control_planes"] = self.planes.exists()
|
||||||
context["selected_plane"] = self.selected_plane
|
context["selected_plane"] = self.selected_plane
|
||||||
context["service_form"] = self.get_instance_form()
|
if self.request.method == "POST":
|
||||||
# Pass data for dynamic FQDN generation
|
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:
|
if self.selected_plane and self.selected_plane.wildcard_dns:
|
||||||
context["wildcard_dns"] = self.selected_plane.wildcard_dns
|
context["wildcard_dns"] = self.selected_plane.wildcard_dns
|
||||||
context["organization_namespace"] = self.request.organization.namespace
|
context["organization_namespace"] = self.request.organization.namespace
|
||||||
|
|
@ -175,7 +218,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
context["form_error"] = True
|
context["form_error"] = True
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
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
|
if not form: # Should not happen if context_object is valid, but as a safeguard
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request,
|
||||||
|
|
@ -203,8 +249,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
)
|
)
|
||||||
form.add_error(None, error_message)
|
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)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -393,11 +437,75 @@ class ServiceInstanceUpdateView(
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
kwargs["instance"] = self.object.spec_object
|
kwargs["instance"] = self.object.spec_object
|
||||||
|
kwargs["prefix"] = "expert"
|
||||||
return kwargs
|
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):
|
def form_valid(self, form):
|
||||||
try:
|
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)
|
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
|
|
|
||||||
|
|
@ -237,42 +237,42 @@ a.btn-keycloak {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CRD Form mandatory field styling */
|
/* Expert CRD Form mandatory field styling */
|
||||||
.crd-form .form-group.mandatory .form-label {
|
.expert-crd-form .form-group.mandatory .form-label {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crd-form .form-group.mandatory .form-label::after {
|
.expert-crd-form .form-group.mandatory .form-label::after {
|
||||||
content: " *";
|
content: " *";
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crd-form .form-group.mandatory {
|
.expert-crd-form .form-group.mandatory {
|
||||||
border-left: 3px solid #dc3545;
|
border-left: 3px solid #dc3545;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
background-color: rgba(220, 53, 69, 0.05);
|
background-color: rgba(220, 53, 69, 0.05);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crd-form .nav-tabs .nav-link .mandatory-indicator {
|
.expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
margin-left: 4px;
|
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);
|
background-color: rgba(220, 53, 69, 0.1);
|
||||||
border-left-color: #ff6b6b;
|
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;
|
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;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,33 +306,6 @@ html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator {
|
||||||
margin-left: auto !important
|
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 {
|
.beta-banner {
|
||||||
background: linear-gradient(135deg, var(--bs-primary) 0%, var(--brand-mid) 100%);
|
background: linear-gradient(135deg, var(--bs-primary) 0%, var(--brand-mid) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
|
|
|
||||||
|
|
@ -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 = '<i class="bi bi-gear-fill"></i> 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 = '<i class="bi bi-gear"></i> 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
30
src/servala/static/js/bootstrap-tabs.js
vendored
Normal file
30
src/servala/static/js/bootstrap-tabs.js
vendored
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
49
src/servala/static/js/expert-mode.js
Normal file
49
src/servala/static/js/expert-mode.js
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
|
||||||
const initializeFqdnGeneration = () => {
|
const initializeFqdnGeneration = (prefix) => {
|
||||||
const nameField = document.querySelector('input#id_name');
|
const nameField = document.querySelector(`input#id_${prefix}-name`);
|
||||||
const fqdnField = document.querySelector('label[for="id_spec.parameters.service.fqdn"] + div input.array-item-input');
|
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) {
|
if (nameField && fqdnField) {
|
||||||
const generateFqdn = (instanceName) => {
|
const generateFqdn = (instanceName) => {
|
||||||
|
|
@ -9,28 +11,23 @@ const initializeFqdnGeneration = () => {
|
||||||
return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
|
return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNameField = nameField.cloneNode(true);
|
nameField.addEventListener('input', function() {
|
||||||
nameField.parentNode.replaceChild(newNameField, nameField);
|
if (!fqdnField.dataset.manuallyEdited) {
|
||||||
const newFqdnField = fqdnField.cloneNode(true);
|
fqdnField.value = generateFqdn(this.value);
|
||||||
fqdnField.parentNode.replaceChild(newFqdnField, fqdnField);
|
const container = fqdnField.closest('.dynamic-array-widget');
|
||||||
|
|
||||||
newNameField.addEventListener('input', function() {
|
|
||||||
if (!newFqdnField.dataset.manuallyEdited) {
|
|
||||||
newFqdnField.value = generateFqdn(this.value);
|
|
||||||
const container = newFqdnField.closest('.dynamic-array-widget');
|
|
||||||
if (container && window.updateHiddenInput) {
|
if (container && window.updateHiddenInput) {
|
||||||
window.updateHiddenInput(container);
|
window.updateHiddenInput(container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
newFqdnField.addEventListener('input', function() {
|
fqdnField.addEventListener('input', function() {
|
||||||
this.dataset.manuallyEdited = 'true';
|
this.dataset.manuallyEdited = 'true';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (newNameField.value && !newFqdnField.value) {
|
if (nameField.value && !fqdnField.value) {
|
||||||
newFqdnField.value = generateFqdn(newNameField.value);
|
fqdnField.value = generateFqdn(nameField.value);
|
||||||
const container = newFqdnField.closest('.dynamic-array-widget');
|
const container = fqdnField.closest('.dynamic-array-widget');
|
||||||
if (container && window.updateHiddenInput) {
|
if (container && window.updateHiddenInput) {
|
||||||
window.updateHiddenInput(container);
|
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) {
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
if (event.detail.target.id === 'service-form') {
|
if (event.detail.target.id === 'service-form') {
|
||||||
initializeFqdnGeneration();
|
initializeFqdnGeneration("custom");
|
||||||
|
initializeFqdnGeneration("expert");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1086
src/tests/test_form_config.py
Normal file
1086
src/tests/test_form_config.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from servala.core.models.service import CloudProvider, ServiceOffering
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"url,redirect",
|
"url,redirect",
|
||||||
|
|
@ -45,3 +46,103 @@ def test_organization_linked_in_sidebar(
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert organization.name in response.content.decode()
|
assert organization.name in response.content.decode()
|
||||||
assert other_organization.name not 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
|
||||||
|
|
|
||||||
121
uv.lock
generated
121
uv.lock
generated
|
|
@ -65,7 +65,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "25.9.0"
|
version = "25.11.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|
@ -75,37 +75,41 @@ dependencies = [
|
||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "pytokens" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
name = "boto3"
|
name = "boto3"
|
||||||
version = "1.40.59"
|
version = "1.40.64"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "botocore" },
|
{ name = "botocore" },
|
||||||
{ name = "jmespath" },
|
{ name = "jmespath" },
|
||||||
{ name = "s3transfer" },
|
{ name = "s3transfer" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/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 = [
|
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]]
|
[[package]]
|
||||||
name = "botocore"
|
name = "botocore"
|
||||||
version = "1.40.59"
|
version = "1.40.64"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "jmespath" },
|
{ name = "jmespath" },
|
||||||
{ name = "python-dateutil" },
|
{ name = "python-dateutil" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -222,37 +226,37 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.11.0"
|
version = "7.11.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
|
|
@ -327,27 +331,30 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django"
|
name = "django"
|
||||||
version = "5.2.7"
|
version = "5.2.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "sqlparse" },
|
{ name = "sqlparse" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
name = "django-allauth"
|
name = "django-allauth"
|
||||||
version = "65.12.1"
|
version = "65.13.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/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]]
|
[[package]]
|
||||||
name = "django-auditlog"
|
name = "django-auditlog"
|
||||||
|
|
@ -506,16 +513,16 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "google-auth"
|
name = "google-auth"
|
||||||
version = "2.41.1"
|
version = "2.42.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cachetools" },
|
{ name = "cachetools" },
|
||||||
{ name = "pyasn1-modules" },
|
{ name = "pyasn1-modules" },
|
||||||
{ name = "rsa" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -816,7 +823,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "8.4.2"
|
version = "9.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
|
@ -825,9 +832,9 @@ dependencies = [
|
||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -882,11 +889,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytokens"
|
name = "pytokens"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
|
|
@ -1126,8 +1133,8 @@ dev = [
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
{ name = "argon2-cffi", specifier = ">=25.1.0" },
|
||||||
{ name = "cryptography", specifier = ">=46.0.3" },
|
{ name = "cryptography", specifier = ">=46.0.3" },
|
||||||
{ name = "django", specifier = "==5.2.7" },
|
{ name = "django", specifier = "==5.2.8" },
|
||||||
{ name = "django-allauth", specifier = ">=65.12.1" },
|
{ name = "django-allauth", specifier = ">=65.13.0" },
|
||||||
{ name = "django-auditlog", specifier = ">=3.3.0" },
|
{ name = "django-auditlog", specifier = ">=3.3.0" },
|
||||||
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
|
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
|
||||||
{ name = "django-jsonform", specifier = ">=2.23.2" },
|
{ name = "django-jsonform", specifier = ">=2.23.2" },
|
||||||
|
|
@ -1147,15 +1154,15 @@ requires-dist = [
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "black", specifier = ">=25.9.0" },
|
{ name = "black", specifier = ">=25.11.0" },
|
||||||
{ name = "bumpver", specifier = ">=2025.1131" },
|
{ name = "bumpver", specifier = ">=2025.1131" },
|
||||||
{ name = "coverage", specifier = ">=7.11.0" },
|
{ name = "coverage", specifier = ">=7.11.3" },
|
||||||
{ name = "djlint", specifier = ">=1.36.4" },
|
{ name = "djlint", specifier = ">=1.36.4" },
|
||||||
{ name = "flake8", specifier = ">=7.3.0" },
|
{ name = "flake8", specifier = ">=7.3.0" },
|
||||||
{ name = "flake8-bugbear", specifier = ">=25.10.21" },
|
{ name = "flake8-bugbear", specifier = ">=25.10.21" },
|
||||||
{ name = "flake8-pyproject", specifier = ">=1.2.3" },
|
{ name = "flake8-pyproject", specifier = ">=1.2.3" },
|
||||||
{ name = "isort", specifier = ">=7.0.0" },
|
{ name = "isort", specifier = ">=7.0.0" },
|
||||||
{ name = "pytest", specifier = ">=8.4.2" },
|
{ name = "pytest", specifier = ">=9.0.0" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
||||||
{ name = "pytest-django", specifier = ">=4.11.1" },
|
{ name = "pytest-django", specifier = ">=4.11.1" },
|
||||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue