From 4435b99851f6e4556ba350c9aca0a2c11d9a2b17 Mon Sep 17 00:00:00 2001
From: Valentin Gagarin <valentin.gagarin@tweag.io>
Date: Mon, 3 Mar 2025 11:00:36 +0100
Subject: [PATCH] WIP: versioned configurations

TODO:
- package https://github.com/surenkov/django-pydantic-field
- manual tests and debugging
- unit test (de-)serialisation
---
 panel/src/panel/configuration/__init__.py  |  0
 panel/src/panel/configuration/base.py      | 18 ++++++++
 panel/src/panel/configuration/v1.py        | 30 +++++++++++++
 panel/src/panel/migrations/0001_initial.py | 25 +++++++++++
 panel/src/panel/models.py                  | 49 ++++++++++++++++++++++
 panel/src/panel/views.py                   | 41 ++++++++++++++++++
 6 files changed, 163 insertions(+)
 create mode 100644 panel/src/panel/configuration/__init__.py
 create mode 100644 panel/src/panel/configuration/base.py
 create mode 100644 panel/src/panel/configuration/v1.py
 create mode 100644 panel/src/panel/migrations/0001_initial.py
 create mode 100644 panel/src/panel/models.py

diff --git a/panel/src/panel/configuration/__init__.py b/panel/src/panel/configuration/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/panel/src/panel/configuration/base.py b/panel/src/panel/configuration/base.py
new file mode 100644
index 00000000..d0ccbb6c
--- /dev/null
+++ b/panel/src/panel/configuration/base.py
@@ -0,0 +1,18 @@
+from django.db import models
+import os
+import inspect
+
+class VersionedConfiguration(models.Model):
+    """Base class for all configuration versions"""
+
+    class Meta:
+        abstract = True  # don't create a table for this model
+
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # get version from file name and set it on the model instance
+        frame = inspect.stack()[1]
+        module = inspect.getmodule(frame[0])
+        module_file = os.path.basename(module.__file__)
+        self.version = int(module_file.replace('v', '').split('.')[0])
diff --git a/panel/src/panel/configuration/v1.py b/panel/src/panel/configuration/v1.py
new file mode 100644
index 00000000..e17524a5
--- /dev/null
+++ b/panel/src/panel/configuration/v1.py
@@ -0,0 +1,30 @@
+from django import forms
+from pydantic import BaseModel, Field
+from typing import Dict, List, Optional
+from enum import Enum
+
+class Configuration(BaseModel):
+    enable: bool = Field(
+        default=False,
+        description="Enable the configuration",
+    )
+
+    # XXX: hard-code available apex domains for now,
+    #      they will be prefixed by the user name
+    class Domain(Enum):
+        EU = "fediversity.eu"
+        NET = "fediversity.net"
+
+    domain: Domain = Field(
+        default=Domain.EU,
+        description="DNS domain where to expose services"
+    )
+
+# TODO: package and use https://github.com/surenkov/django-pydantic-field for this to work
+class Form(forms.ModelForm):
+    class Meta:
+        model = Configuration
+        fields = [
+            "enable"
+            "domain"
+        ]
diff --git a/panel/src/panel/migrations/0001_initial.py b/panel/src/panel/migrations/0001_initial.py
new file mode 100644
index 00000000..4cce2c13
--- /dev/null
+++ b/panel/src/panel/migrations/0001_initial.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2.16 on 2025-03-04 00:01
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import panel.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Configuration',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('operator', models.ForeignKey(help_text='Operator who owns the configuration', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='configurations', to=settings.AUTH_USER_MODEL)),
+                ('version', models.PositiveIntegerField(default=panel.models.latest_version, help_text='Configuration schema version')),
+                ('value', models.JSONField(help_text='Stored configuration value', null=True)),
+            ],
+        ),
+    ]
diff --git a/panel/src/panel/models.py b/panel/src/panel/models.py
new file mode 100644
index 00000000..49603dd8
--- /dev/null
+++ b/panel/src/panel/models.py
@@ -0,0 +1,49 @@
+import os
+import re
+import importlib.util
+import importlib
+from django.db import models
+from django.contrib.auth.models import User
+
+
+# TODO: add to factored-out dataclass
+def latest_version():
+    # Get the directory where version modules are stored
+    module_dir = os.path.dirname(importlib.util.find_spec('configuration').origin)
+
+    # Look for files matching v{number}.py
+    version_pattern = re.compile(r'v(\d+)\.py$')
+    versions = []
+    for filename in os.listdir(module_dir):
+        match = version_pattern.match(filename)
+        if match:
+            versions.append(int(match.group(1)))
+    return max(versions)
+
+
+class Configuration(models.Model):
+    operator = models.ForeignKey(
+        User,
+        on_delete=models.SET_NULL,
+        null=True,
+        related_name="configurations",
+        help_text="Operator who owns the configuration",
+    )
+
+    version = models.PositiveIntegerField(
+        help_text="Configuration schema version",
+        default=latest_version,
+    )
+
+    value = models.JSONField(
+        help_text="Stored configuration value",
+        # TODO: use the model's default value instead
+        null=True,
+    )
+
+    @property
+    def parsed_value(self):
+        # TODO: use factored-out dataclass
+        module = importlib.import_module(f"configuration.v{version}")
+        config_class = getattr(module, "Configuration")
+        return config_class.model_validate_json(self.value)
diff --git a/panel/src/panel/views.py b/panel/src/panel/views.py
index 17656966..0232d166 100644
--- a/panel/src/panel/views.py
+++ b/panel/src/panel/views.py
@@ -1,16 +1,57 @@
+from importlib import import_module
 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.edit import UpdateView
+from django.shortcuts import get_object_or_404
+from django.urls import reverse_lazy
+from panel import models
+from panel import forms
+
 
 class Index(TemplateView):
     template_name = 'index.html'
 
+
 class AccountDetail(LoginRequiredMixin, DetailView):
     model = User
     template_name = 'account_detail.html'
     def get_object(self):
         return self.request.user
 
+
 class ServiceList(TemplateView):
     template_name = 'service_list.html'
+
+
+class ConfigurationForm(LoginRequiredMixin, UpdateView):
+    template_name = 'configuration_form.html'
+    model = models.Configuration
+    success_url = reverse_lazy('configuration_form')
+
+    def get_object(self, queryset=None):
+        obj, created = models.Configuration.objects.get_or_create(
+            operator=self.request.user)
+        return obj
+
+    def get_version_module(self):
+        """Helper method to get the version-specific module and config class"""
+        config = self.get_object()
+        # TODO: factor out a helper dataclass for versioned configurations featuring:
+        # - latest available version
+        # - schema(version):
+        #   - model
+        #   - form
+        #   - template
+        module = import_module(f"configuration.v{config.version}")
+        config_class = getattr(module, "Configuration")
+        return module, config_class
+
+    def get_form_class(self):
+        # Get module for this version
+        # TODO: use the factored-out dataclass so we can do something like `self.get_version().form` instead
+        module, _ = self.get_version_module()
+
+        # Return the ConfigurationForm class from that module
+        return getattr(module, "Form")