WIP: versioned configurations

TODO:
- package https://github.com/surenkov/django-pydantic-field
- manual tests and debugging
- unit test (de-)serialisation
This commit is contained in:
Valentin Gagarin 2025-03-03 11:00:36 +01:00
parent 4db91bd0b7
commit 4435b99851
6 changed files with 163 additions and 0 deletions
panel/src/panel

View file

@ -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])

View file

@ -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"
]

View file

@ -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)),
],
),
]

49
panel/src/panel/models.py Normal file
View file

@ -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)

View file

@ -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")