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