From 9dd92b4cc180e88ffc14a96ec979a90c84e1676f Mon Sep 17 00:00:00 2001
From: Valentin Gagarin <valentin.gagarin@tweag.io>
Date: Wed, 5 Mar 2025 11:07:55 +0100
Subject: [PATCH] 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.
---
 panel/src/panel/configuration/__init__.py     |  5 ++
 panel/src/panel/configuration/v1.py           | 25 ++++++
 panel/src/panel/templates/base.html           |  3 +
 .../panel/templates/configuration_form.html   | 13 ++++
 panel/src/panel/urls.py                       |  1 +
 panel/src/panel/views.py                      | 76 ++++++++++++++++++-
 6 files changed, 121 insertions(+), 2 deletions(-)
 create mode 100644 panel/src/panel/templates/configuration_form.html

diff --git a/panel/src/panel/configuration/__init__.py b/panel/src/panel/configuration/__init__.py
index 9104cf94..41231e24 100644
--- a/panel/src/panel/configuration/__init__.py
+++ b/panel/src/panel/configuration/__init__.py
@@ -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")
diff --git a/panel/src/panel/configuration/v1.py b/panel/src/panel/configuration/v1.py
index f15fc433..15f2bbb8 100644
--- a/panel/src/panel/configuration/v1.py
+++ b/panel/src/panel/configuration/v1.py
@@ -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']]
+        )
diff --git a/panel/src/panel/templates/base.html b/panel/src/panel/templates/base.html
index aa29745a..913b19fb 100644
--- a/panel/src/panel/templates/base.html
+++ b/panel/src/panel/templates/base.html
@@ -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>
diff --git a/panel/src/panel/templates/configuration_form.html b/panel/src/panel/templates/configuration_form.html
new file mode 100644
index 00000000..d5fad4a7
--- /dev/null
+++ b/panel/src/panel/templates/configuration_form.html
@@ -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 %}
diff --git a/panel/src/panel/urls.py b/panel/src/panel/urls.py
index 46c765e0..2f0ba434 100644
--- a/panel/src/panel/urls.py
+++ b/panel/src/panel/urls.py
@@ -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'),
 ]
diff --git a/panel/src/panel/views.py b/panel/src/panel/views.py
index 17656966..7a356fe0 100644
--- a/panel/src/panel/views.py
+++ b/panel/src/panel/views.py
@@ -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)