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

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.mixins import LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.views.generic import DetailView 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): class Index(TemplateView):
template_name = 'index.html' template_name = 'index.html'
class AccountDetail(LoginRequiredMixin, DetailView): class AccountDetail(LoginRequiredMixin, DetailView):
model = User model = User
template_name = 'account_detail.html' template_name = 'account_detail.html'
def get_object(self): def get_object(self):
return self.request.user return self.request.user
class ServiceList(TemplateView): class ServiceList(TemplateView):
template_name = 'service_list.html' 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")