1
0
Fork 0

basic versioned forms

this is still rudimentary since the actual forms are not auto-generated
from the underlying model. the comments show a path towards that.
This commit is contained in:
Valentin Gagarin 2025-03-05 11:07:55 +01:00
parent 981ba011ab
commit 9dd92b4cc1
6 changed files with 121 additions and 2 deletions

View file

@ -5,14 +5,17 @@ import sys
from importlib import import_module
from importlib.util import find_spec
from django.utils.functional import classproperty
from django_pydantic_field import SchemaField
from django.apps import apps
from django.db import models
from django import forms
class Version():
model: models.Model
form: forms.ModelForm
@classproperty
def latest(cls):
@ -29,3 +32,5 @@ class Version():
def __init__(self, version: int):
module = import_module(f"{__name__}.v{version}")
self.model = getattr(module, "Configuration")
self.form = getattr(module, "Form")

View file

@ -1,5 +1,8 @@
from django import forms
from django.db import models
from pydantic import BaseModel, Field
from enum import Enum
from django_pydantic_field import SchemaField
class Configuration(BaseModel):
enable: bool = Field(
@ -17,3 +20,25 @@ class Configuration(BaseModel):
default=Domain.EU,
description="DNS domain where to expose services"
)
# TODO@(fricklerhandwerk):
# generate this automatically from the Pydantic model so this code can go away:
# - add custom types, splicing `forms.Form` into `pydantic.BaseModel` and `forms.Field` into `pydantic.Field` so we can attach Django validators to Pydantic models
# - map primitive or well-known field types as captured by `pydantic.Field` to `forms.Field` constructors, and `BaseModel` to `Form` using `BaseModel.model_fields`
# - use a custom widget that renders nested forms in e.g. a `<div>`
# - likely this also needs overriding `as_p()`, `as_li()`
# more inspiration: https://levelup.gitconnected.com/how-to-export-pydantic-models-as-django-forms-c1b59ddca580
# that work goes through the JSON Schema generated by Pydantic, which seems unnecessary to me, but it follows the same princple
# TODO(@fricklerhandwerk):
# eventually we probably want to validate each field separately,
# so we should also auto-generate views per field that will be called by htmx directives defined in the templates.
# those htmx parts can be generated by each form field using `attrs`
class Form(forms.Form):
enable = forms.BooleanField(required=False)
domain = forms.ChoiceField(choices=[(d.name, d.value) for d in Configuration.Domain])
def to_python(self):
return Configuration(
enable=self.cleaned_data['enable'],
domain=Configuration.Domain[self.cleaned_data['domain']]
)

View file

@ -26,6 +26,9 @@
<li>
<a href="{% url 'service_list' %}">Services</a>
</li>
<li>
<a href="{% url 'configuration_form' %}">Configuration</a>
</li>
{% load custom_tags %}
<li>

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<form method="post" enctype="multipart/form-data" action="{% url 'configuration_form' %}">
{% csrf_token %}
{{ form.as_p }}
<button class="button" disabled>Deploy</button>
<button class="button" type="submit" >Save</button>
</form>
<p><sub>Configuration schema version {{ version }}</sub></p>
{% endblock %}

View file

@ -25,4 +25,5 @@ urlpatterns = [
path("", include("django.contrib.auth.urls")),
path("account/", views.AccountDetail.as_view(), name='account_detail'),
path("services/", views.ServiceList.as_view(), name='service_list'),
path("configuration/", views.ConfigurationForm.as_view(), name='configuration_form'),
]

View file

@ -1,7 +1,13 @@
from enum import Enum
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.views.generic import TemplateView
from django.views.generic import DetailView
from django.views.generic import TemplateView, DetailView
from django.views.generic.edit import FormView
from panel import models
from panel.configuration import Version
class Index(TemplateView):
template_name = 'index.html'
@ -14,3 +20,69 @@ class AccountDetail(LoginRequiredMixin, DetailView):
class ServiceList(TemplateView):
template_name = 'service_list.html'
class ConfigurationForm(LoginRequiredMixin, FormView):
template_name = 'configuration_form.html'
success_url = reverse_lazy('configuration_form')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["version"] = self.get_object().version
return context
def get_form_class(self):
config = self.get_object()
return Version(config.version).form
def get_object(self):
"""Get or create the configuration object for the current user"""
obj, created = models.Configuration.objects.get_or_create(
operator=self.request.user)
return obj
# TODO(@fricklerhandwerk):
# this should probably live somewhere else
def convert_enums_to_names(self, data_dict):
"""
Recursively convert all enum values in a dictionary to their string names.
This handles nested dictionaries and lists as well.
Needed for converting a Pydantic `BaseModel` instance to a `Form` input.
"""
if isinstance(data_dict, dict):
result = {}
for key, value in data_dict.items():
if isinstance(value, Enum):
# Convert Enum to its name
result[key] = value.name
elif isinstance(value, (dict, list)):
# Recursively process nested structures
result[key] = self.convert_enums_to_names(value)
else:
# Keep other values as is
result[key] = value
return result
elif isinstance(data_dict, list):
# Process each item in the list
return [self.convert_enums_to_names(item) for item in data_dict]
elif isinstance(data_dict, Enum):
# Convert single Enum value
return data_dict.name
else:
# Return non-dict, non-list, non-Enum values as is
return data_dict
def get_initial(self):
initial = super().get_initial()
config = self.get_object()
config_dict = config.parsed_value.model_dump()
initial.update(self.convert_enums_to_names(config_dict))
return initial
def form_valid(self, form):
obj = self.get_object()
obj.value = form.to_python().model_dump_json()
obj.save()
return super().form_valid(form)