Service instantiation #31
32 changed files with 1168 additions and 524 deletions
|
@ -12,6 +12,7 @@ dependencies = [
|
|||
"django-fernet-encrypted-fields>=0.3.0",
|
||||
"django-scopes>=2.0.0",
|
||||
"django-template-partials>=24.4",
|
||||
"jsonschema>=4.23.0",
|
||||
"kubernetes>=32.0.1",
|
||||
"pillow>=11.1.0",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
|
@ -42,6 +43,7 @@ known_first_party = "servala"
|
|||
[tool.flake8]
|
||||
max-line-length = 160
|
||||
exclude = ".venv"
|
||||
ignore = "E203"
|
||||
|
||||
[tool.djlint]
|
||||
extend_exclude = "src/servala/static/mazer"
|
||||
|
|
|
@ -6,6 +6,7 @@ from servala.core.models import (
|
|||
BillingEntity,
|
||||
CloudProvider,
|
||||
ControlPlane,
|
||||
ControlPlaneCRD,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
OrganizationOrigin,
|
||||
|
@ -13,8 +14,8 @@ from servala.core.models import (
|
|||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDefinition,
|
||||
ServiceInstance,
|
||||
ServiceOffering,
|
||||
ServiceOfferingControlPlane,
|
||||
User,
|
||||
)
|
||||
|
||||
|
@ -56,12 +57,18 @@ class OrganizationMembershipInline(admin.TabularInline):
|
|||
|
||||
@admin.register(Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "billing_entity", "origin")
|
||||
list_display = ("name", "namespace", "billing_entity", "origin")
|
||||
list_filter = ("origin",)
|
||||
search_fields = ("name",)
|
||||
search_fields = ("name", "namespace")
|
||||
autocomplete_fields = ("billing_entity", "origin")
|
||||
inlines = (OrganizationMembershipInline,)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
readonly_fields = list(super().get_readonly_fields(request, obj) or [])
|
||||
if obj: # If this is an edit (not a new organization)
|
||||
readonly_fields.append("namespace")
|
||||
return readonly_fields
|
||||
|
||||
|
||||
@admin.register(BillingEntity)
|
||||
class BillingEntityAdmin(admin.ModelAdmin):
|
||||
|
@ -124,7 +131,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
|||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("name", "description", "cloud_provider")},
|
||||
{"fields": ("name", "description", "cloud_provider", "required_label")},
|
||||
),
|
||||
(
|
||||
_("API Credentials"),
|
||||
|
@ -200,20 +207,55 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
|
|||
return ["api_definition"]
|
||||
|
||||
|
||||
class ServiceOfferingControlPlaneInline(admin.TabularInline):
|
||||
model = ServiceOfferingControlPlane
|
||||
class ControlPlaneCRDInline(admin.TabularInline):
|
||||
model = ControlPlaneCRD
|
||||
extra = 1
|
||||
autocomplete_fields = ("control_plane", "service_definition")
|
||||
|
||||
|
||||
@admin.register(ServiceOfferingControlPlane)
|
||||
class ServiceOfferingControlPlaneAdmin(admin.ModelAdmin):
|
||||
@admin.register(ControlPlaneCRD)
|
||||
class ControlPlaneCRDAdmin(admin.ModelAdmin):
|
||||
list_display = ("service_offering", "control_plane", "service_definition")
|
||||
list_filter = ("service_offering", "control_plane", "service_definition")
|
||||
search_fields = ("service_offering__service__name", "control_plane__name")
|
||||
autocomplete_fields = ("service_offering", "control_plane", "service_definition")
|
||||
|
||||
|
||||
@admin.register(ServiceInstance)
|
||||
class ServiceInstanceAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "organization", "context", "created_by", "is_deleted")
|
||||
list_filter = ("organization", "context", "is_deleted")
|
||||
search_fields = (
|
||||
"name",
|
||||
"organization__name",
|
||||
"context__service_offering__service__name",
|
||||
)
|
||||
readonly_fields = ("name", "organization", "context")
|
||||
autocomplete_fields = ("organization", "context")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj: # If this is an edit (not a new instance)
|
||||
return self.readonly_fields
|
||||
return []
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"organization",
|
||||
"context",
|
||||
"created_by",
|
||||
"is_deleted",
|
||||
"deleted_at",
|
||||
"deleted_by",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ServiceOffering)
|
||||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "service", "provider")
|
||||
|
@ -221,6 +263,6 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
|
|||
search_fields = ("description",)
|
||||
autocomplete_fields = ("service", "provider")
|
||||
inlines = (
|
||||
ServiceOfferingControlPlaneInline,
|
||||
ControlPlaneCRDInline,
|
||||
PlanInline,
|
||||
)
|
||||
|
|
|
@ -1,20 +1,46 @@
|
|||
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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
spec = schema["properties"].get("spec") or {}
|
||||
# defaults = {"apiVersion": f"{group}/{version}", "kind": kind}
|
||||
|
||||
# We always need these three fields to know our own name and our full namespace
|
||||
model_fields = {"__module__": "crd_models"}
|
||||
model_fields.update(build_object_fields(spec, "spec"))
|
||||
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=True))
|
||||
|
||||
# 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
|
||||
|
@ -25,13 +51,13 @@ def generate_django_model(schema, group, version, kind):
|
|||
return model_class
|
||||
|
||||
|
||||
def build_object_fields(schema, name, verbose_name_prefix=None):
|
||||
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
|
||||
is_required = field_name in required_fields and parent_required
|
||||
full_name = f"{name}.{field_name}"
|
||||
result = get_django_field(
|
||||
field_schema,
|
||||
|
@ -62,8 +88,9 @@ def get_django_field(
|
|||
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,
|
||||
"blank": not is_required, # All fields are optional by default
|
||||
"null": not is_required,
|
||||
"help_text": field_schema.get("description"),
|
||||
"validators": [],
|
||||
|
@ -95,8 +122,12 @@ def get_django_field(
|
|||
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}:"
|
||||
field_schema,
|
||||
full_name,
|
||||
verbose_name_prefix=f"{verbose_name}:",
|
||||
parent_required=is_required,
|
||||
)
|
||||
elif field_type == "array":
|
||||
# TODO: handle items / validate items, build multi-select input
|
||||
|
@ -110,8 +141,126 @@ def get_django_field(
|
|||
class CrdModelFormMixin:
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# self.fields["apiVersion"].disabled = True
|
||||
# self.fields["kind"].disabled = True
|
||||
self.schema = self._meta.model.SCHEMA
|
||||
|
||||
for field in ("organization", "context"):
|
||||
self.fields[field].widget = forms.HiddenInput()
|
||||
|
||||
def strip_title(self, field_name, label):
|
||||
field = self.fields[field_name]
|
||||
if field and field.label.startswith(label):
|
||||
field.label = field.label[len(label) :]
|
||||
|
||||
def get_fieldsets(self):
|
||||
fieldsets = []
|
||||
|
||||
# General fieldset for non-spec fields
|
||||
general_fields = [
|
||||
field for field in self.fields if not field.startswith("spec.")
|
||||
]
|
||||
if general_fields:
|
||||
fieldsets.append(
|
||||
{"title": "General", "fields": general_fields, "fieldsets": []}
|
||||
)
|
||||
|
||||
# Process spec fields
|
||||
others = []
|
||||
nested_fieldsets = {}
|
||||
|
||||
for field_name in self.fields:
|
||||
if field_name.startswith("spec."):
|
||||
parts = field_name.split(".")
|
||||
if len(parts) == 2: # Top-level spec field
|
||||
others.append(field_name)
|
||||
else:
|
||||
parent_key = parts[1]
|
||||
if not nested_fieldsets.get(parent_key):
|
||||
nested_fieldsets[parent_key] = {
|
||||
"fields": [],
|
||||
"fieldsets": {},
|
||||
"title": deslugify(parent_key),
|
||||
}
|
||||
parent = nested_fieldsets[parent_key]
|
||||
if len(parts) == 3: # Top-level within fieldset
|
||||
parent["fields"].append(field_name)
|
||||
else:
|
||||
sub_key = parts[2]
|
||||
if not parent["fieldsets"].get(sub_key):
|
||||
parent["fieldsets"][sub_key] = {
|
||||
"title": deslugify(sub_key),
|
||||
"fields": [],
|
||||
}
|
||||
parent["fieldsets"][sub_key]["fields"].append(field_name)
|
||||
|
||||
# Add nested fieldsets to fieldsets
|
||||
for group in nested_fieldsets.values():
|
||||
total_fields = 0
|
||||
for fieldset in group["fieldsets"].values():
|
||||
if (field_count := len(fieldset["fields"])) == 1:
|
||||
group["fields"].append(fieldset["fields"][0])
|
||||
fieldset["fields"] = []
|
||||
else:
|
||||
title = f"{group['title']}: {fieldset['title']}: "
|
||||
for field in fieldset["fields"]:
|
||||
self.strip_title(field, title)
|
||||
total_fields += field_count
|
||||
if (total_fields + len(group["fields"])) == 1:
|
||||
others.append(group["fields"][0])
|
||||
else:
|
||||
title = f"{group['title']}: "
|
||||
for field in group["fields"]:
|
||||
self.strip_title(field, title)
|
||||
fieldsets.append(group)
|
||||
|
||||
# Add 'others' tab if there are any fields
|
||||
if others:
|
||||
fieldsets.append({"title": "Others", "fields": others, "fieldsets": []})
|
||||
|
||||
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):
|
||||
|
|
|
@ -22,5 +22,6 @@ class Command(BaseCommand):
|
|||
return
|
||||
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"{user} is now a superuser."))
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
# Generated by Django 5.2b1 on 2025-03-16 08:44
|
||||
# Generated by Django 5.2b1 on 2025-03-31 09:20
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import encrypted_fields.fields
|
||||
import rules.contrib.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import servala.core.models.service
|
||||
import servala.core.models.user
|
||||
|
||||
|
||||
|
@ -11,9 +15,128 @@ class Migration(migrations.Migration):
|
|||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BillingEntity",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"erp_reference",
|
||||
models.CharField(
|
||||
blank=True, max_length=100, verbose_name="ERP reference"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Billing entity",
|
||||
"verbose_name_plural": "Billing entities",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CloudProvider",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"logo",
|
||||
models.ImageField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to="public/service_providers",
|
||||
verbose_name="Logo",
|
||||
),
|
||||
),
|
||||
(
|
||||
"external_links",
|
||||
models.JSONField(
|
||||
blank=True, null=True, verbose_name="External links"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Cloud provider",
|
||||
"verbose_name_plural": "Cloud providers",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="OrganizationOrigin",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Organization origin",
|
||||
"verbose_name_plural": "Organization origins",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
|
@ -33,6 +156,14 @@ class Migration(migrations.Migration):
|
|||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
|
@ -73,105 +204,38 @@ class Migration(migrations.Migration):
|
|||
verbose_name="Is superuser",
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "User",
|
||||
"verbose_name_plural": "Users",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
managers=[
|
||||
("objects", servala.core.models.user.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BillingEntity",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"erp_reference",
|
||||
models.CharField(
|
||||
blank=True, max_length=100, verbose_name="ERP reference"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Billing entity",
|
||||
"verbose_name_plural": "Billing entities",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CloudProvider",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"logo",
|
||||
models.ImageField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to="public/service_providers",
|
||||
verbose_name="Logo",
|
||||
),
|
||||
),
|
||||
(
|
||||
"external_links",
|
||||
models.JSONField(
|
||||
blank=True, null=True, verbose_name="External links"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Cloud provider",
|
||||
"verbose_name_plural": "Cloud providers",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="OrganizationOrigin",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Organization origin",
|
||||
"verbose_name_plural": "Organization origins",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ControlPlane",
|
||||
fields=[
|
||||
|
@ -184,16 +248,29 @@ class Migration(migrations.Migration):
|
|||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"k8s_api_endpoint",
|
||||
models.URLField(verbose_name="Kubernetes API endpoint"),
|
||||
"api_credentials",
|
||||
encrypted_fields.fields.EncryptedJSONField(
|
||||
help_text="Required fields: certificate-authority-data, server (URL), token",
|
||||
validators=[
|
||||
servala.core.models.service.validate_api_credentials
|
||||
],
|
||||
verbose_name="API credentials",
|
||||
),
|
||||
),
|
||||
("api_credentials", models.JSONField(verbose_name="API credentials")),
|
||||
(
|
||||
"cloud_provider",
|
||||
models.ForeignKey(
|
||||
|
@ -208,6 +285,7 @@ class Migration(migrations.Migration):
|
|||
"verbose_name": "Control plane",
|
||||
"verbose_name_plural": "Control planes",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Organization",
|
||||
|
@ -221,10 +299,35 @@ class Migration(migrations.Migration):
|
|||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"namespace",
|
||||
models.CharField(
|
||||
help_text="This namespace will be used for all Kubernetes resources. Cannot be changed after creation.",
|
||||
max_length=63,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
code="invalid_kubernetes_name",
|
||||
message='Name must consist of lowercase alphanumeric characters or "-", must start and end with an alphanumeric character.',
|
||||
regex="^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||
)
|
||||
],
|
||||
verbose_name="Kubernetes Namespace",
|
||||
),
|
||||
),
|
||||
(
|
||||
"billing_entity",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="organizations",
|
||||
to="core.billingentity",
|
||||
|
@ -236,6 +339,7 @@ class Migration(migrations.Migration):
|
|||
"verbose_name": "Organization",
|
||||
"verbose_name_plural": "Organizations",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="OrganizationMembership",
|
||||
|
@ -249,6 +353,14 @@ class Migration(migrations.Migration):
|
|||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Date joined"),
|
||||
|
@ -289,6 +401,7 @@ class Migration(migrations.Migration):
|
|||
"verbose_name": "Organization membership",
|
||||
"verbose_name_plural": "Organization memberships",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organization",
|
||||
|
@ -322,6 +435,14 @@ class Migration(migrations.Migration):
|
|||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
|
@ -352,6 +473,7 @@ class Migration(migrations.Migration):
|
|||
"verbose_name": "Service category",
|
||||
"verbose_name_plural": "Service categories",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Service",
|
||||
|
@ -365,7 +487,21 @@ class Migration(migrations.Migration):
|
|||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"slug",
|
||||
models.SlugField(
|
||||
max_length=100, unique=True, verbose_name="URL slug"
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
|
@ -399,6 +535,57 @@ class Migration(migrations.Migration):
|
|||
"verbose_name": "Service",
|
||||
"verbose_name_plural": "Services",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ServiceDefinition",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"api_definition",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="Contains group, version, and kind information",
|
||||
null=True,
|
||||
verbose_name="API Definition",
|
||||
),
|
||||
),
|
||||
(
|
||||
"service",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="service_definitions",
|
||||
to="core.service",
|
||||
verbose_name="Service",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Service definition",
|
||||
"verbose_name_plural": "Service definitions",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ServiceOffering",
|
||||
|
@ -413,16 +600,16 @@ class Migration(migrations.Migration):
|
|||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"control_plane",
|
||||
models.ManyToManyField(
|
||||
related_name="offerings",
|
||||
to="core.controlplane",
|
||||
verbose_name="Control planes",
|
||||
),
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
|
@ -447,6 +634,7 @@ class Migration(migrations.Migration):
|
|||
"verbose_name": "Service offering",
|
||||
"verbose_name_plural": "Service offerings",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Plan",
|
||||
|
@ -460,6 +648,14 @@ class Migration(migrations.Migration):
|
|||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
|
@ -493,5 +689,142 @@ class Migration(migrations.Migration):
|
|||
"verbose_name": "Plan",
|
||||
"verbose_name_plural": "Plans",
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ControlPlaneCRD",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
(
|
||||
"control_plane",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="offering_connections",
|
||||
to="core.controlplane",
|
||||
verbose_name="Control plane",
|
||||
),
|
||||
),
|
||||
(
|
||||
"service_definition",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="offering_control_planes",
|
||||
to="core.servicedefinition",
|
||||
verbose_name="Service definition",
|
||||
),
|
||||
),
|
||||
(
|
||||
"service_offering",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="control_plane_connections",
|
||||
to="core.serviceoffering",
|
||||
verbose_name="Service offering",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Service offering control plane connection",
|
||||
"verbose_name_plural": "Service offering control planes connections",
|
||||
"unique_together": {("service_offering", "control_plane")},
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ServiceInstance",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
max_length=63,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
code="invalid_kubernetes_name",
|
||||
message='Name must consist of lowercase alphanumeric characters or "-", must start and end with an alphanumeric character.',
|
||||
regex="^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||
)
|
||||
],
|
||||
verbose_name="Name",
|
||||
),
|
||||
),
|
||||
("is_deleted", models.BooleanField(default=False)),
|
||||
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"context",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="service_instances",
|
||||
to="core.controlplanecrd",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"organization",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="service_instances",
|
||||
to="core.organization",
|
||||
verbose_name="Organization",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Service instance",
|
||||
"verbose_name_plural": "Service instances",
|
||||
"unique_together": {("name", "organization", "context")},
|
||||
},
|
||||
bases=(rules.contrib.models.RulesModelMixin, models.Model),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 5.2b1 on 2025-04-03 15:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="controlplanecrd",
|
||||
options={
|
||||
"verbose_name": "ControlPlane CRD",
|
||||
"verbose_name_plural": "ControlPlane CRDs",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="controlplane",
|
||||
name="required_label",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Label value for the 'appcat.vshn.io/provider-config' added to every instance on this plane.",
|
||||
max_length=100,
|
||||
null=True,
|
||||
verbose_name="Required Label",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,89 +0,0 @@
|
|||
# Generated by Django 5.2b1 on 2025-03-17 06:19
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="billingentity",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="Created",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="billingentity",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organization",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="Created",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organization",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organizationmembership",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="Created",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organizationmembership",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organizationorigin",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="Created",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="organizationorigin",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="Created",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="Last updated"),
|
||||
),
|
||||
]
|
|
@ -1,25 +0,0 @@
|
|||
# Generated by Django 5.2b1 on 2025-03-20 08:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0002_billingentity_created_at_billingentity_updated_at_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="organization",
|
||||
name="billing_entity",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="organizations",
|
||||
to="core.billingentity",
|
||||
verbose_name="Billing entity",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,46 +0,0 @@
|
|||
# Generated by Django 5.2b1 on 2025-03-24 06:33
|
||||
|
||||
import encrypted_fields.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("core", "0003_billing_entity_nullable"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="groups",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="user_permissions",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="controlplane",
|
||||
name="api_credentials",
|
||||
field=encrypted_fields.fields.EncryptedJSONField(
|
||||
verbose_name="API credentials"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,29 +0,0 @@
|
|||
# Generated by Django 5.2b1 on 2025-03-24 10:27
|
||||
|
||||
import encrypted_fields.fields
|
||||
from django.db import migrations
|
||||
|
||||
import servala.core.models.service
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0004_encrypt_api_credentials"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="controlplane",
|
||||
name="k8s_api_endpoint",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="controlplane",
|
||||
name="api_credentials",
|
||||
field=encrypted_fields.fields.EncryptedJSONField(
|
||||
help_text="Required fields: certificate-authority-data, server (URL), token",
|
||||
validators=[servala.core.models.service.validate_api_credentials],
|
||||
verbose_name="API credentials",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,21 +0,0 @@
|
|||
# Generated by Django 5.2b1 on 2025-03-24 14:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0005_remove_controlplane_k8s_api_endpoint"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="service",
|
||||
name="slug",
|
||||
field=models.SlugField(
|
||||
default="slug", max_length=100, unique=True, verbose_name="URL slug"
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
|
@ -1,115 +0,0 @@
|
|||
# Generated by Django 5.2b1 on 2025-03-25 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0006_service_slug"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="serviceoffering",
|
||||
name="control_plane",
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ServiceDefinition",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="Name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"api_definition",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
help_text="Contains group, version, and kind information",
|
||||
null=True,
|
||||
verbose_name="API Definition",
|
||||
),
|
||||
),
|
||||
(
|
||||
"service",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="service_definitions",
|
||||
to="core.service",
|
||||
verbose_name="Service",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Service definition",
|
||||
"verbose_name_plural": "Service definitions",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ServiceOfferingControlPlane",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"control_plane",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="offering_connections",
|
||||
to="core.controlplane",
|
||||
verbose_name="Control plane",
|
||||
),
|
||||
),
|
||||
(
|
||||
"service_definition",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="offering_control_planes",
|
||||
to="core.servicedefinition",
|
||||
verbose_name="Service definition",
|
||||
),
|
||||
),
|
||||
(
|
||||
"service_offering",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="control_plane_connections",
|
||||
to="core.serviceoffering",
|
||||
verbose_name="Service offering",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Service offering control plane connection",
|
||||
"verbose_name_plural": "Service offering control planes connections",
|
||||
"unique_together": {("service_offering", "control_plane")},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="serviceoffering",
|
||||
name="control_planes",
|
||||
field=models.ManyToManyField(
|
||||
related_name="offerings",
|
||||
through="core.ServiceOfferingControlPlane",
|
||||
to="core.controlplane",
|
||||
verbose_name="Control planes",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -8,12 +8,13 @@ from .organization import (
|
|||
from .service import (
|
||||
CloudProvider,
|
||||
ControlPlane,
|
||||
ControlPlaneCRD,
|
||||
Plan,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDefinition,
|
||||
ServiceInstance,
|
||||
ServiceOffering,
|
||||
ServiceOfferingControlPlane,
|
||||
)
|
||||
from .user import User
|
||||
|
||||
|
@ -21,6 +22,7 @@ __all__ = [
|
|||
"BillingEntity",
|
||||
"CloudProvider",
|
||||
"ControlPlane",
|
||||
"ControlPlaneCRD",
|
||||
"Organization",
|
||||
"OrganizationMembership",
|
||||
"OrganizationOrigin",
|
||||
|
@ -28,8 +30,8 @@ __all__ = [
|
|||
"Plan",
|
||||
"Service",
|
||||
"ServiceCategory",
|
||||
"ServiceInstance",
|
||||
"ServiceDefinition",
|
||||
"ServiceOffering",
|
||||
"ServiceOfferingControlPlane",
|
||||
"User",
|
||||
]
|
||||
|
|
|
@ -9,10 +9,20 @@ from django_scopes import ScopedManager, scopes_disabled
|
|||
|
||||
from servala.core import rules as perms
|
||||
from servala.core.models.mixins import ServalaModelMixin
|
||||
from servala.core.validators import kubernetes_name_validator
|
||||
|
||||
|
||||
class Organization(ServalaModelMixin, models.Model):
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
namespace = models.CharField(
|
||||
max_length=63,
|
||||
verbose_name=_("Kubernetes Namespace"),
|
||||
unique=True,
|
||||
help_text=_(
|
||||
"This namespace will be used for all Kubernetes resources. Cannot be changed after creation."
|
||||
),
|
||||
validators=[kubernetes_name_validator],
|
||||
)
|
||||
|
||||
billing_entity = models.ForeignKey(
|
||||
to="BillingEntity",
|
||||
|
@ -39,6 +49,7 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
base = "/org/{self.slug}/"
|
||||
details = "{base}details/"
|
||||
services = "{base}services/"
|
||||
instances = "{base}instances/"
|
||||
|
||||
@cached_property
|
||||
def slug(self):
|
||||
|
|
|
@ -1,15 +1,22 @@
|
|||
import json
|
||||
|
||||
import kubernetes
|
||||
import urlman
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db import IntegrityError, models
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from encrypted_fields.fields import EncryptedJSONField
|
||||
from kubernetes import config
|
||||
from kubernetes import client, config
|
||||
from kubernetes.client.rest import ApiException
|
||||
|
||||
from servala.core.models.mixins import ServalaModelMixin
|
||||
from servala.core.validators import kubernetes_name_validator
|
||||
|
||||
class ServiceCategory(models.Model):
|
||||
|
||||
class ServiceCategory(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
Categories for services, e.g. "Databases", "Storage", "Compute".
|
||||
"""
|
||||
|
@ -40,7 +47,7 @@ class ServiceCategory(models.Model):
|
|||
return self.name
|
||||
|
||||
|
||||
class Service(models.Model):
|
||||
class Service(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
A service that can be offered, e.g. "PostgreSQL", "MinIO", "Kubernetes".
|
||||
"""
|
||||
|
@ -89,7 +96,11 @@ def validate_api_credentials(value):
|
|||
return validate_dict(value, required_fields)
|
||||
|
||||
|
||||
class ControlPlane(models.Model):
|
||||
class ControlPlane(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
Note: ControlPlanes are called "Service Provider Zone" in the user-facing frontend.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||
# Either contains the fields "certificate_authority_data", "server" and "token", or is empty
|
||||
|
@ -98,6 +109,15 @@ class ControlPlane(models.Model):
|
|||
help_text="Required fields: certificate-authority-data, server (URL), token",
|
||||
validators=[validate_api_credentials],
|
||||
)
|
||||
required_label = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Required Label"),
|
||||
help_text=_(
|
||||
"Label value for the 'appcat.vshn.io/provider-config' added to every instance on this plane."
|
||||
),
|
||||
)
|
||||
|
||||
cloud_provider = models.ForeignKey(
|
||||
to="CloudProvider",
|
||||
|
@ -113,6 +133,12 @@ class ControlPlane(models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def service_definitions(self):
|
||||
return ServiceDefinition.objects.filter(
|
||||
offering_control_planes__control_plane=self
|
||||
).distinct()
|
||||
|
||||
@property
|
||||
def kubernetes_config(self):
|
||||
conf = kubernetes.client.Configuration()
|
||||
|
@ -176,8 +202,23 @@ class ControlPlane(models.Model):
|
|||
except Exception as e:
|
||||
return False, _("Connection error: {}").format(str(e))
|
||||
|
||||
def get_or_create_namespace(self, name):
|
||||
api_instance = kubernetes.client.CoreV1Api(self.get_kubernetes_client())
|
||||
try:
|
||||
api_instance.read_namespace(name=name)
|
||||
except kubernetes.client.ApiException as e:
|
||||
if e.status == 404:
|
||||
# Namespace does not exist, create it
|
||||
body = kubernetes.client.V1Namespace(
|
||||
metadata=kubernetes.client.V1ObjectMeta(name=name)
|
||||
)
|
||||
api_instance.create_namespace(body=body)
|
||||
else:
|
||||
# If there's another error, raise it
|
||||
raise
|
||||
|
||||
class CloudProvider(models.Model):
|
||||
|
||||
class CloudProvider(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
A cloud provider, e.g. "Exoscale".
|
||||
"""
|
||||
|
@ -202,7 +243,7 @@ class CloudProvider(models.Model):
|
|||
return self.name
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
class Plan(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
Each service offering can have multiple plans, e.g. for different tiers.
|
||||
"""
|
||||
|
@ -236,7 +277,7 @@ def validate_api_definition(value):
|
|||
return validate_dict(value, required_fields)
|
||||
|
||||
|
||||
class ServiceDefinition(models.Model):
|
||||
class ServiceDefinition(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
Configuration/service implementation: contains information on which
|
||||
CompositeResourceDefinition (aka XRD) implements a service on a ControlPlane.
|
||||
|
@ -279,10 +320,10 @@ class ServiceDefinition(models.Model):
|
|||
return self.name
|
||||
|
||||
|
||||
class ServiceOfferingControlPlane(models.Model):
|
||||
class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
Each combination of ServiceOffering and ControlPlane can have a different
|
||||
ServiceDefinition, which is here modeled as the "through" model.
|
||||
ServiceDefinition, which is here modeled as basically a "through" model.
|
||||
"""
|
||||
|
||||
service_offering = models.ForeignKey(
|
||||
|
@ -305,8 +346,8 @@ class ServiceOfferingControlPlane(models.Model):
|
|||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Service offering control plane connection")
|
||||
verbose_name_plural = _("Service offering control planes connections")
|
||||
verbose_name = _("ControlPlane CRD")
|
||||
verbose_name_plural = _("ControlPlane CRDs")
|
||||
unique_together = [("service_offering", "control_plane")]
|
||||
|
||||
def __str__(self):
|
||||
|
@ -365,7 +406,7 @@ class ServiceOfferingControlPlane(models.Model):
|
|||
return generate_model_form_class(self.django_model)
|
||||
|
||||
|
||||
class ServiceOffering(models.Model):
|
||||
class ServiceOffering(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP".
|
||||
"""
|
||||
|
@ -382,12 +423,6 @@ class ServiceOffering(models.Model):
|
|||
related_name="offerings",
|
||||
verbose_name=_("Provider"),
|
||||
)
|
||||
control_planes = models.ManyToManyField(
|
||||
to="ControlPlane",
|
||||
through="ServiceOfferingControlPlane",
|
||||
related_name="offerings",
|
||||
verbose_name=_("Control planes"),
|
||||
)
|
||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||
|
||||
class Meta:
|
||||
|
@ -398,3 +433,120 @@ class ServiceOffering(models.Model):
|
|||
return _("{service_name} at {provider_name}").format(
|
||||
service_name=self.service.name, provider_name=self.provider.name
|
||||
)
|
||||
|
||||
@property
|
||||
def control_planes(self):
|
||||
return ControlPlane.objects.filter(
|
||||
offering_connections__service_offering=self
|
||||
).distinct()
|
||||
|
||||
|
||||
class ServiceInstance(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
The source of truth for service instances is Kubernetes.
|
||||
The Django model only contains metadata, all other information is queried
|
||||
on the fly.
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=63, verbose_name=_("Name"), validators=[kubernetes_name_validator]
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
to="core.Organization",
|
||||
on_delete=models.PROTECT,
|
||||
verbose_name=_("Organization"),
|
||||
related_name="service_instances",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
to="core.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
)
|
||||
context = models.ForeignKey(
|
||||
to="core.ControlPlaneCRD",
|
||||
related_name="service_instances",
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
deleted_by = models.ForeignKey(
|
||||
to="core.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="+",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Service instance")
|
||||
verbose_name_plural = _("Service instances")
|
||||
# Names are unique per de-facto namespace, which is defined by the
|
||||
# Organization + ServiceDefinition (group, version) + the ControlPlane.
|
||||
unique_together = [("name", "organization", "context")]
|
||||
|
||||
class urls(urlman.Urls):
|
||||
base = "{self.organization.urls.instances}{self.name}/"
|
||||
|
||||
@classmethod
|
||||
def create_instance(cls, name, organization, context, created_by, spec_data):
|
||||
# Ensure the namespace exists
|
||||
context.control_plane.get_or_create_namespace(organization.namespace)
|
||||
try:
|
||||
instance = cls.objects.create(
|
||||
name=name,
|
||||
organization=organization,
|
||||
created_by=created_by,
|
||||
context=context,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"An instance with this name already exists in this organization. Please choose a different name."
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
group = context.service_definition.api_definition["group"]
|
||||
version = context.service_definition.api_definition["version"]
|
||||
kind = context.service_definition.api_definition["kind"]
|
||||
create_data = {
|
||||
"apiVersion": f"{group}/{version}",
|
||||
"kind": kind,
|
||||
"metadata": {
|
||||
"name": name,
|
||||
"namespace": organization.namespace,
|
||||
},
|
||||
"spec": spec_data or {},
|
||||
}
|
||||
if label := context.control_plane.required_label:
|
||||
create_data["metadata"]["labels"] = [
|
||||
{settings.DEFAULT_LABEL_KEY: label}
|
||||
]
|
||||
api_instance = client.CustomObjectsApi(
|
||||
context.control_plane.get_kubernetes_client()
|
||||
)
|
||||
plural = kind.lower()
|
||||
if not plural.endswith("s"):
|
||||
plural = f"{plural}s"
|
||||
|
||||
api_instance.create_namespaced_custom_object(
|
||||
group=group,
|
||||
version=version,
|
||||
namespace=organization.namespace,
|
||||
plural=plural,
|
||||
body=create_data,
|
||||
)
|
||||
except Exception as e:
|
||||
instance.delete()
|
||||
if isinstance(e, ApiException):
|
||||
try:
|
||||
error_body = json.loads(e.body)
|
||||
reason = error_body.get("message", str(e))
|
||||
raise ValidationError(_("Kubernetes API error: {}").format(reason))
|
||||
except (ValueError, TypeError):
|
||||
raise ValidationError(_("Kubernetes API error: {}").format(str(e)))
|
||||
raise ValidationError(_("Error creating instance: {}").format(str(e)))
|
||||
return instance
|
||||
|
|
12
src/servala/core/validators.py
Normal file
12
src/servala/core/validators.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
from django.core.validators import RegexValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Kubernetes resource name validator - follows the same rules as namespace names
|
||||
kubernetes_name_validator = RegexValidator(
|
||||
regex=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
|
||||
message=_(
|
||||
'Name must consist of lowercase alphanumeric characters or "-", '
|
||||
"must start and end with an alphanumeric character."
|
||||
),
|
||||
code="invalid_kubernetes_name",
|
||||
)
|
|
@ -1,4 +1,6 @@
|
|||
from django import forms
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models import Organization
|
||||
from servala.frontend.forms.mixins import HtmxMixin
|
||||
|
@ -7,4 +9,19 @@ from servala.frontend.forms.mixins import HtmxMixin
|
|||
class OrganizationForm(HtmxMixin, ModelForm):
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = ("name",)
|
||||
fields = ("name", "namespace")
|
||||
widgets = {
|
||||
"namespace": forms.TextInput(
|
||||
attrs={
|
||||
"pattern": "[a-z0-9]([-a-z0-9]*[a-z0-9])?",
|
||||
"title": _(
|
||||
'Lowercase alphanumeric characters or "-", must start and end with alphanumeric'
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
help_texts = {
|
||||
"namespace": _(
|
||||
"This namespace will be used for all resources and cannot be changed later."
|
||||
)
|
||||
}
|
||||
|
|
|
@ -17,15 +17,17 @@ class VerticalFormRenderer(TemplatesSetting):
|
|||
form_template_name = "frontend/forms/form.html"
|
||||
field_template_name = "frontend/forms/vertical_field.html"
|
||||
|
||||
def get_class_names(self, input_type):
|
||||
def get_class_names(self, field):
|
||||
input_type = self.get_field_input_type(field)
|
||||
errors = "is-invalid " if field.errors else ""
|
||||
if input_type == "checkbox":
|
||||
return "form-check-input"
|
||||
return "form-control"
|
||||
return f"{errors}form-check-input"
|
||||
return f"{errors}form-control"
|
||||
|
||||
def get_widget_input_type(self, widget):
|
||||
if isinstance(widget, Textarea):
|
||||
return "textarea"
|
||||
return widget.input_type
|
||||
return getattr(widget, "input_type", None)
|
||||
|
||||
def get_field_input_type(self, field):
|
||||
widget = field.field.widget
|
||||
|
@ -35,9 +37,8 @@ class VerticalFormRenderer(TemplatesSetting):
|
|||
|
||||
def render(self, template_name, context, request=None):
|
||||
if field := context.get("field"):
|
||||
input_type = self.get_field_input_type(field)
|
||||
field.build_widget_attrs = inject_class(
|
||||
field.build_widget_attrs, self.get_class_names(input_type)
|
||||
field.build_widget_attrs, self.get_class_names(field)
|
||||
)
|
||||
return super().render(template_name, context, request)
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models import CloudProvider, ControlPlane, ServiceCategory
|
||||
|
||||
|
@ -23,7 +24,11 @@ class ServiceFilterForm(forms.Form):
|
|||
|
||||
|
||||
class ControlPlaneSelectForm(forms.Form):
|
||||
control_plane = forms.ModelChoiceField(queryset=ControlPlane.objects.none())
|
||||
control_plane = forms.ModelChoiceField(
|
||||
queryset=ControlPlane.objects.none(),
|
||||
label=_("Service Provider Zone"),
|
||||
empty_label=None,
|
||||
)
|
||||
|
||||
def __init__(self, *args, planes=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% load i18n %}
|
||||
<div class="form-group{% if field.field.required %} mandatory{% endif %}{% if errors %} is-invalid{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}">
|
||||
{% if not hide_label %}
|
||||
<div class="form-group{% if field.field.required %} mandatory{% endif %}{% if field.errors %} is-invalid{% endif %}{% if extra_class %} {{ extra_class }}{% endif %}">
|
||||
{% if not hide_label and not field.is_hidden %}
|
||||
{% if field.field.widget.input_type != "checkbox" or field.field.widget.allow_multiple_selected %}
|
||||
<label for="{{ field.auto_id }}" class="form-label">{{ field.label }}</label>
|
||||
{% endif %}
|
||||
|
@ -9,7 +9,7 @@
|
|||
<fieldset {% if field.help_text and field.auto_id and "aria-describedby" not in field.field.widget.attrs %} aria-describedby="{{ field.auto_id }}_helptext"{% endif %}>
|
||||
{% endif %}
|
||||
{{ field }}
|
||||
{% if field.field.widget.input_type == "checkbox" and not field.field.widget.allow_multiple_selected %}
|
||||
{% if field.field.widget.input_type == "checkbox" and not field.field.widget.allow_multiple_selected and not field.is_hidden %}
|
||||
<label for="{{ field.auto_id }}" class="form-check-label form-label">{{ field.label }}</label>
|
||||
{% endif %}
|
||||
{% if field.use_fieldset %}</fieldset>{% endif %}
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
{% if errors %}
|
||||
{% load i18n %}
|
||||
{% if form.non_field_errors or form.errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<div>
|
||||
{% if errors|length > 1 %}
|
||||
<ul>
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% if form.non_field_errors %}
|
||||
{% if form.non_field_errors|length > 1 %}
|
||||
<ul>
|
||||
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{{ form.non_field_errors.0 }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ errors.0 }}
|
||||
{% translate "We could not save your changes." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "includes/form.html" with form=service_form %}
|
||||
{% include "includes/tabbed_fieldset_form.html" with form=service_form %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -46,6 +46,15 @@
|
|||
</th>
|
||||
{% partial org-name %}
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-25">
|
||||
<span class="d-flex mt-2">{% translate "Namespace" %}</span>
|
||||
</th>
|
||||
<td>
|
||||
<div>{{ form.instance.namespace }}</div>
|
||||
<small class="text-muted">{% translate "The namespace cannot be changed after creation." %}</small>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
<form class="form form-vertical"
|
||||
method="post"
|
||||
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
||||
{% include "includes/form_errors.html" %}
|
||||
{% csrf_token %}
|
||||
{{ form }}
|
||||
{% if extra_field %}{{ extra_field }}{% endif %}
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
{% load i18n %}
|
||||
{% if form.non_field_errors or form.errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<div>
|
||||
{% if form.non_field_errors %}
|
||||
{% if form.non_field_errors|length > 1 %}
|
||||
<ul>
|
||||
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{{ form.non_field_errors.0 }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% translate "We could not save your changes." %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
|
@ -0,0 +1,52 @@
|
|||
{% load i18n %}
|
||||
{% load get_field %}
|
||||
<form class="form form-vertical"
|
||||
method="post"
|
||||
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
||||
{% csrf_token %}
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||
{% for fieldset in form.get_fieldsets %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if forloop.first %}active{% endif %}"
|
||||
id="{{ fieldset.title|slugify }}-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#{{ fieldset.title|slugify }}"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="{{ fieldset.title|slugify }}"
|
||||
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||
{{ fieldset.title }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="tab-content" id="myTabContent">
|
||||
{% for fieldset in form.get_fieldsets %}
|
||||
<div class="tab-pane fade my-2 {% if forloop.first %}show active{% endif %}"
|
||||
id="{{ fieldset.title|slugify }}"
|
||||
role="tabpanel"
|
||||
aria-labelledby="{{ 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.values %}
|
||||
{% if subfieldset.fields %}
|
||||
<h4>{{ subfieldset.title }}</h4>
|
||||
{% for field in subfieldset.fields %}
|
||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-sm-12 d-flex justify-content-end">
|
||||
<button class="btn btn-primary me-1 mb-1" type="submit">
|
||||
{% if form_submit_label %}
|
||||
{{ form_submit_label }}
|
||||
{% else %}
|
||||
{% translate "Save" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
0
src/servala/frontend/templatetags/__init__.py
Normal file
0
src/servala/frontend/templatetags/__init__.py
Normal file
8
src/servala/frontend/templatetags/get_field.py
Normal file
8
src/servala/frontend/templatetags/get_field.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def get_field(form, field_name):
|
||||
return form[field_name]
|
|
@ -1,7 +1,15 @@
|
|||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.functional import cached_property
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
from servala.core.models import Service, ServiceOffering, ServiceOfferingControlPlane
|
||||
from servala.core.models import (
|
||||
ControlPlaneCRD,
|
||||
Service,
|
||||
ServiceInstance,
|
||||
ServiceOffering,
|
||||
)
|
||||
from servala.frontend.forms.service import ControlPlaneSelectForm, ServiceFilterForm
|
||||
from servala.frontend.views.mixins import HtmxViewMixin, OrganizationViewMixin
|
||||
|
||||
|
@ -68,26 +76,70 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
data = None
|
||||
if "control_plane" in self.request.GET:
|
||||
data = self.request.GET
|
||||
elif self.request.method == "POST" and self.context_object:
|
||||
data = {"control_plane": self.context_object.control_plane_id}
|
||||
return ControlPlaneSelectForm(data=data, planes=self.planes)
|
||||
|
||||
@cached_property
|
||||
def selected_plane(self):
|
||||
if self.select_form.data and self.select_form.is_valid():
|
||||
return self.select_form.cleaned_data["control_plane"]
|
||||
field = self.select_form.fields["control_plane"]
|
||||
return field.initial or field.queryset.first()
|
||||
|
||||
@cached_property
|
||||
def context_object(self):
|
||||
if self.request.method == "POST":
|
||||
return ControlPlaneCRD.objects.filter(
|
||||
pk=self.request.POST.get("context"),
|
||||
# Make sure we don’t use a malicious ID
|
||||
control_plane__in=self.planes,
|
||||
).first()
|
||||
return ControlPlaneCRD.objects.filter(
|
||||
control_plane=self.selected_plane, service_offering=self.object
|
||||
).first()
|
||||
|
||||
def get_instance_form(self):
|
||||
return self.context_object.model_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
initial={
|
||||
"organization": self.request.organization,
|
||||
"context": self.context_object,
|
||||
},
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["select_form"] = self.select_form
|
||||
context["has_control_planes"] = self.planes.exists()
|
||||
if "control_plane" in self.request.GET:
|
||||
if self.select_form.is_valid():
|
||||
context["selected_plane"] = self.select_form.cleaned_data[
|
||||
"control_plane"
|
||||
]
|
||||
try:
|
||||
so_cp = ServiceOfferingControlPlane.objects.filter(
|
||||
control_plane=self.select_form.cleaned_data["control_plane"],
|
||||
service_offering=self.object,
|
||||
).first()
|
||||
if not so_cp:
|
||||
context["form_error"] = True
|
||||
except Exception:
|
||||
context["form_error"] = True
|
||||
else:
|
||||
context["service_form"] = so_cp.model_form_class()
|
||||
context["selected_plane"] = self.selected_plane
|
||||
context["service_form"] = self.get_instance_form()
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
context = self.get_context_data(object=self.object)
|
||||
|
||||
if not self.context_object:
|
||||
context["form_error"] = True
|
||||
return self.render_to_response(context)
|
||||
|
||||
form = self.get_instance_form()
|
||||
if form.is_valid():
|
||||
try:
|
||||
service_instance = ServiceInstance.create_instance(
|
||||
organization=self.organization,
|
||||
name=form.cleaned_data["name"],
|
||||
context=self.context_object,
|
||||
created_by=request.user,
|
||||
spec_data=form.get_nested_data().get("spec"),
|
||||
)
|
||||
return redirect(service_instance.urls.base)
|
||||
except ValidationError as e:
|
||||
messages.error(self.request, e.message or str(e))
|
||||
except Exception as e:
|
||||
messages.error(self.request, str(e))
|
||||
|
||||
# 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)
|
||||
|
|
|
@ -210,6 +210,7 @@ LANGUAGE_COOKIE_NAME = "servala_lang"
|
|||
SESSION_COOKIE_NAME = "servala_sess"
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
|
||||
DEFAULT_LABEL_KEY = "appcat.vshn.io/provider-config"
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# TODO
|
||||
|
|
|
@ -15,12 +15,16 @@ def origin():
|
|||
|
||||
@pytest.fixture
|
||||
def organization(origin):
|
||||
return Organization.objects.create(name="Test Org", origin=origin)
|
||||
return Organization.objects.create(
|
||||
name="Test Org", namespace="test-org", origin=origin
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_organization(origin):
|
||||
return Organization.objects.create(name="Test Org Alternate", origin=origin)
|
||||
return Organization.objects.create(
|
||||
name="Test Org Alternate", namespace="test-org-alternate", origin=origin
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
99
uv.lock
generated
99
uv.lock
generated
|
@ -480,6 +480,33 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/aa/42/797895b952b682c3dafe23b1834507ee7f02f4d6299b65aaa61425763278/json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa", size = 34049 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2024.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/db/58f950c996c793472e336ff3655b13fbcf1e3b359dcf52dcf3ed3b52c352/jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", size = 15561 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kubernetes"
|
||||
version = "32.0.1"
|
||||
|
@ -769,6 +796,20 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.36.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2024.11.6"
|
||||
|
@ -835,6 +876,53 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/b3/52b213298a0ba7097c7ea96bee95e1947aa84cc816d48cebb539770cdf41/rpds_py-0.24.0.tar.gz", hash = "sha256:772cc1b2cd963e7e17e6cc55fe0371fb9c704d63e44cacec7b9b7f523b78919e", size = 26863 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e0/1c55f4a3be5f1ca1a4fd1f3ff1504a1478c1ed48d84de24574c4fa87e921/rpds_py-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:d8551e733626afec514b5d15befabea0dd70a343a9f23322860c4f16a9430205", size = 366945 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/1b/a3501574fbf29118164314dbc800d568b8c1c7b3258b505360e8abb3902c/rpds_py-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e374c0ce0ca82e5b67cd61fb964077d40ec177dd2c4eda67dba130de09085c7", size = 351935 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/47/77d3d71c55f6a374edde29f1aca0b2e547325ed00a9da820cabbc9497d2b/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d69d003296df4840bd445a5d15fa5b6ff6ac40496f956a221c4d1f6f7b4bc4d9", size = 390817 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ec/1e336ee27484379e19c7f9cc170f4217c608aee406d3ae3a2e45336bff36/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8212ff58ac6dfde49946bea57474a386cca3f7706fc72c25b772b9ca4af6b79e", size = 401983 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/39b65cbc272c635eaea6d393c2ad1ccc81c39eca2db6723a0ca4b2108fce/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:528927e63a70b4d5f3f5ccc1fa988a35456eb5d15f804d276709c33fc2f19bda", size = 451719 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/05/05c2b27dd9c30432f31738afed0300659cb9415db0ff7429b05dfb09bbde/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a824d2c7a703ba6daaca848f9c3d5cb93af0505be505de70e7e66829affd676e", size = 442546 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/e0/19383c8b5d509bd741532a47821c3e96acf4543d0832beba41b4434bcc49/rpds_py-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44d51febb7a114293ffd56c6cf4736cb31cd68c0fddd6aa303ed09ea5a48e029", size = 393695 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/15/39f14e96d94981d0275715ae8ea564772237f3fa89bc3c21e24de934f2c7/rpds_py-0.24.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fab5f4a2c64a8fb64fc13b3d139848817a64d467dd6ed60dcdd6b479e7febc9", size = 427218 },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/b9/12da7124905a680f690da7a9de6f11de770b5e359f5649972f7181c8bf51/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9be4f99bee42ac107870c61dfdb294d912bf81c3c6d45538aad7aecab468b6b7", size = 568062 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/17/75229017a2143d915f6f803721a6d721eca24f2659c5718a538afa276b4f/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:564c96b6076a98215af52f55efa90d8419cc2ef45d99e314fddefe816bc24f91", size = 596262 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/64/8e8a1d8bd1b6b638d6acb6d41ab2cec7f2067a5b8b4c9175703875159a7c/rpds_py-0.24.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75a810b7664c17f24bf2ffd7f92416c00ec84b49bb68e6a0d93e542406336b56", size = 564306 },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/1c/a7eac8d8ed8cb234a9b1064647824c387753343c3fab6ed7c83481ed0be7/rpds_py-0.24.0-cp312-cp312-win32.whl", hash = "sha256:f6016bd950be4dcd047b7475fdf55fb1e1f59fc7403f387be0e8123e4a576d30", size = 224281 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/46/b8b5424d1d21f2f2f3f2d468660085318d4f74a8df8289e3dd6ad224d488/rpds_py-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:998c01b8e71cf051c28f5d6f1187abbdf5cf45fc0efce5da6c06447cba997034", size = 239719 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/c3/3607abc770395bc6d5a00cb66385a5479fb8cd7416ddef90393b17ef4340/rpds_py-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2d8e4508e15fc05b31285c4b00ddf2e0eb94259c2dc896771966a163122a0c", size = 367072 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/35/8c7ee0fe465793e3af3298dc5a9f3013bd63e7a69df04ccfded8293a4982/rpds_py-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f00c16e089282ad68a3820fd0c831c35d3194b7cdc31d6e469511d9bffc535c", size = 351919 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/d3/7e1b972501eb5466b9aca46a9c31bcbbdc3ea5a076e9ab33f4438c1d069d/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:951cc481c0c395c4a08639a469d53b7d4afa252529a085418b82a6b43c45c240", size = 390360 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/a8/ccabb50d3c91c26ad01f9b09a6a3b03e4502ce51a33867c38446df9f896b/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9ca89938dff18828a328af41ffdf3902405a19f4131c88e22e776a8e228c5a8", size = 400704 },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/ae/5fa5bf0f3bc6ce21b5ea88fc0ecd3a439e7cb09dd5f9ffb3dbe1b6894fc5/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0ef550042a8dbcd657dfb284a8ee00f0ba269d3f2286b0493b15a5694f9fe8", size = 450839 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ac/c4e18b36d9938247e2b54f6a03746f3183ca20e1edd7d3654796867f5100/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b2356688e5d958c4d5cb964af865bea84db29971d3e563fb78e46e20fe1848b", size = 441494 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/08/b543969c12a8f44db6c0f08ced009abf8f519191ca6985509e7c44102e3c/rpds_py-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78884d155fd15d9f64f5d6124b486f3d3f7fd7cd71a78e9670a0f6f6ca06fb2d", size = 393185 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/7e/f6eb6a7042ce708f9dfc781832a86063cea8a125bbe451d663697b51944f/rpds_py-0.24.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a4a535013aeeef13c5532f802708cecae8d66c282babb5cd916379b72110cf7", size = 426168 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/b0/6cd2bb0509ac0b51af4bb138e145b7c4c902bb4b724d6fd143689d6e0383/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:84e0566f15cf4d769dade9b366b7b87c959be472c92dffb70462dd0844d7cbad", size = 567622 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b0/c401f4f077547d98e8b4c2ec6526a80e7cb04f519d416430ec1421ee9e0b/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:823e74ab6fbaa028ec89615ff6acb409e90ff45580c45920d4dfdddb069f2120", size = 595435 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/ec/7993b6e803294c87b61c85bd63e11142ccfb2373cf88a61ec602abcbf9d6/rpds_py-0.24.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c61a2cb0085c8783906b2f8b1f16a7e65777823c7f4d0a6aaffe26dc0d358dd9", size = 563762 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/29/4508003204cb2f461dc2b83dd85f8aa2b915bc98fe6046b9d50d4aa05401/rpds_py-0.24.0-cp313-cp313-win32.whl", hash = "sha256:60d9b630c8025b9458a9d114e3af579a2c54bd32df601c4581bd054e85258143", size = 223510 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/12/09e048d1814195e01f354155fb772fb0854bd3450b5f5a82224b3a319f0e/rpds_py-0.24.0-cp313-cp313-win_amd64.whl", hash = "sha256:6eea559077d29486c68218178ea946263b87f1c41ae7f996b1f30a983c476a5a", size = 239075 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/03/5027cde39bb2408d61e4dd0cf81f815949bb629932a6c8df1701d0257fc4/rpds_py-0.24.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:d09dc82af2d3c17e7dd17120b202a79b578d79f2b5424bda209d9966efeed114", size = 362974 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/10/24d374a2131b1ffafb783e436e770e42dfdb74b69a2cd25eba8c8b29d861/rpds_py-0.24.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5fc13b44de6419d1e7a7e592a4885b323fbc2f46e1f22151e3a8ed3b8b920405", size = 348730 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/d1/1ef88d0516d46cd8df12e5916966dbf716d5ec79b265eda56ba1b173398c/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c347a20d79cedc0a7bd51c4d4b7dbc613ca4e65a756b5c3e57ec84bd43505b47", size = 387627 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/35/07339051b8b901ecefd449ebf8e5522e92bcb95e1078818cbfd9db8e573c/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20f2712bd1cc26a3cc16c5a1bfee9ed1abc33d4cdf1aabd297fe0eb724df4272", size = 394094 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/62/ee89ece19e0ba322b08734e95441952062391065c157bbd4f8802316b4f1/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aad911555286884be1e427ef0dc0ba3929e6821cbeca2194b13dc415a462c7fd", size = 449639 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/24/b30e9f9e71baa0b9dada3a4ab43d567c6b04a36d1cb531045f7a8a0a7439/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0aeb3329c1721c43c58cae274d7d2ca85c1690d89485d9c63a006cb79a85771a", size = 438584 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d9/49f7b8f3b4147db13961e19d5e30077cd0854ccc08487026d2cb2142aa4a/rpds_py-0.24.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a0f156e9509cee987283abd2296ec816225145a13ed0391df8f71bf1d789e2d", size = 391047 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b0/e66918d0972c33a259ba3cd7b7ff10ed8bd91dbcfcbec6367b21f026db75/rpds_py-0.24.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa6800adc8204ce898c8a424303969b7aa6a5e4ad2789c13f8648739830323b7", size = 418085 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6b/99ed7ea0a94c7ae5520a21be77a82306aac9e4e715d4435076ead07d05c6/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a18fc371e900a21d7392517c6f60fe859e802547309e94313cd8181ad9db004d", size = 564498 },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/26/1cacfee6b800e6fb5f91acecc2e52f17dbf8b0796a7c984b4568b6d70e38/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9168764133fd919f8dcca2ead66de0105f4ef5659cbb4fa044f7014bed9a1797", size = 590202 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/9e/57bd2f9fba04a37cef673f9a66b11ca8c43ccdd50d386c455cd4380fe461/rpds_py-0.24.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f6e3cec44ba05ee5cbdebe92d052f69b63ae792e7d05f1020ac5e964394080c", size = 561771 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cf/b719120f375ab970d1c297dbf8de1e3c9edd26fe92c0ed7178dd94b45992/rpds_py-0.24.0-cp313-cp313t-win32.whl", hash = "sha256:8ebc7e65ca4b111d928b669713865f021b7773350eeac4a31d3e70144297baba", size = 221195 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/e5/22865285789f3412ad0c3d7ec4dc0a3e86483b794be8a5d9ed5a19390900/rpds_py-0.24.0-cp313-cp313t-win_amd64.whl", hash = "sha256:675269d407a257b8c00a6b58205b72eec8231656506c56fd429d924ca00bb350", size = 237354 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "4.9"
|
||||
|
@ -868,6 +956,7 @@ dependencies = [
|
|||
{ name = "django-fernet-encrypted-fields" },
|
||||
{ name = "django-scopes" },
|
||||
{ name = "django-template-partials" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "kubernetes" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psycopg2-binary" },
|
||||
|
@ -900,6 +989,7 @@ requires-dist = [
|
|||
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
|
||||
{ name = "django-scopes", specifier = ">=2.0.0" },
|
||||
{ name = "django-template-partials", specifier = ">=24.4" },
|
||||
{ name = "jsonschema", specifier = ">=4.23.0" },
|
||||
{ name = "kubernetes", specifier = ">=32.0.1" },
|
||||
{ name = "pillow", specifier = ">=11.1.0" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.10" },
|
||||
|
@ -953,6 +1043,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/b00a62db91a83fff600de219b6ea9908e6918664899a2d85db222f4fbf19/typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", size = 106520 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.1"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue