diff --git a/panel/src/panel/configuration/__init__.py b/panel/src/panel/configuration/__init__.py
index 41231e24..034b0298 100644
--- a/panel/src/panel/configuration/__init__.py
+++ b/panel/src/panel/configuration/__init__.py
@@ -12,6 +12,12 @@ from django.db import models
from django import forms
+# NOTE(@fricklerhandwerk):
+# Never change historical configuration schema versions.
+# (Changing helper and display code is okay though.)
+# We're exercising handling versioning even if it's not required for production yet.
+# Old versions can be deleted before prime time once we've established reliable patterns.
+
class Version():
model: models.Model
diff --git a/panel/src/panel/configuration/v2.py b/panel/src/panel/configuration/v2.py
new file mode 100644
index 00000000..eaaf972d
--- /dev/null
+++ b/panel/src/panel/configuration/v2.py
@@ -0,0 +1,112 @@
+from django import forms
+from django.db import models
+from pydantic import BaseModel, Field
+from enum import Enum
+from django_pydantic_field import SchemaField
+
+# TODO(@fricklerhandwerk):
+# Eventually should probably maintain a separate series of configuration schema versions for each service.
+# I didn't start it here yet, mainly to keep it readable.
+class PeerTube(BaseModel):
+ enable: bool = Field(
+ default=False,
+ description="Enable PeerTube",
+ )
+
+class Pixelfed(BaseModel):
+ enable: bool = Field(
+ default=False,
+ description="Enable Pixelfed",
+ )
+
+class Mastodon(BaseModel):
+ enable: bool = Field(
+ default=False,
+ description="Enable Mastodon",
+ )
+
+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"
+ )
+
+ peertube: PeerTube = Field(
+ default=PeerTube(),
+ description="Configuration for PeerTube",
+ )
+
+ pixelfed: Pixelfed = Field(
+ default=Pixelfed(),
+ description="Configuration for Pixelfed",
+ )
+
+ mastodon: Mastodon = Field(
+ default=Mastodon(),
+ description="Configuration for Mastodon",
+ )
+
+# 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 `
`
+# - 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(
+ required=False,
+ choices=[(d.name, d.value) for d in Configuration.Domain],
+ )
+ peertube_enable = forms.BooleanField(
+ required=False,
+ label=Configuration.model_fields["peertube"].annotation.model_fields["enable"].description,
+ )
+ pixelfed_enable = forms.BooleanField(
+ required=False,
+ label=Configuration.model_fields["pixelfed"].annotation.model_fields["enable"].description,
+ )
+ mastodon_enable = forms.BooleanField(
+ required=False,
+ label=Configuration.model_fields["mastodon"].annotation.model_fields["enable"].description,
+ )
+
+ # HACK: take out nested dict fields manually
+ # TODO: make this generic
+ def __init__(self, *args, **kwargs):
+ initial = kwargs.pop('initial')
+
+ initial["pixelfed_enable"] = initial.pop('pixelfed')["enable"]
+ initial["peertube_enable"] = initial.pop('peertube')["enable"]
+ initial["mastodon_enable"] = initial.pop('mastodon')["enable"]
+
+ kwargs["initial"] = initial
+
+ super().__init__(*args, **kwargs)
+
+
+ def to_python(self):
+ return Configuration(
+ enable=self.cleaned_data['enable'],
+ domain=Configuration.Domain[self.cleaned_data['domain']],
+ peertube = PeerTube(enable=self.cleaned_data['peertube_enable']),
+ pixelfed = Pixelfed(enable=self.cleaned_data['pixelfed_enable']),
+ mastodon = Mastodon(enable=self.cleaned_data['mastodon_enable']),
+ )
diff --git a/panel/src/panel/migrations/0002_alter_configuration_value_and_more.py b/panel/src/panel/migrations/0002_alter_configuration_value_and_more.py
new file mode 100644
index 00000000..fbc95f83
--- /dev/null
+++ b/panel/src/panel/migrations/0002_alter_configuration_value_and_more.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.2.16 on 2025-03-09 21:25
+
+from django.conf import settings
+from django.db import migrations, models
+import pydantic.main
+
+from panel.configuration import Version
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('panel', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='configuration',
+ name='value',
+ field=models.JSONField(default=Version(Version.latest).model().model_dump_json, help_text='Stored configuration value'),
+ ),
+ migrations.AlterField(
+ model_name='configuration',
+ name='version',
+ field=models.PositiveIntegerField(default=Version.latest, help_text='Configuration schema version'),
+ ),
+ ]
diff --git a/panel/src/panel/tests/test_configuration_form.py b/panel/src/panel/tests/test_configuration_form.py
new file mode 100644
index 00000000..ced65b93
--- /dev/null
+++ b/panel/src/panel/tests/test_configuration_form.py
@@ -0,0 +1,54 @@
+from django.test import TestCase
+from django.contrib.auth.models import User
+from django.urls import reverse
+from panel.models import Configuration
+
+
+class ConfigurationForm(TestCase):
+ def setUp(self):
+ self.username = 'testuser'
+ self.password = 'securepassword123'
+ self.user = User.objects.create_user(
+ username=self.username,
+ password=self.password
+ )
+
+ self.config_url = reverse('configuration_form')
+
+ def test_configuration_form_submission(self):
+ config = Configuration.objects.create(
+ operator = self.user,
+ # hard code the version so we can make some concrete assertions
+ # XXX(@fricklerhandwerk):
+ # As an exception, this test is allowed to change arbitrarily,
+ # exactly when we purge/squash development versions of the configuration schema.
+ version = 2,
+ )
+
+ self.client.login(username=self.username, password=self.password)
+ response = self.client.get(self.config_url)
+ self.assertEqual(response.status_code, 200)
+
+
+ context = response.context[0]
+
+ # configuration should be disabled by default
+ self.assertFalse(context['view'].get_object().parsed_value.enable)
+ # ...and be displayed as such
+ self.assertFalse(context['form'].initial["enable"])
+
+ form_data = context['form'].initial.copy()
+ form_data.update(
+ enable= True,
+ mastodon_enable=True,
+ )
+
+ response = self.client.post(self.config_url, data=form_data)
+
+ self.assertEqual(response.status_code, 302)
+ config.refresh_from_db()
+
+ self.assertTrue(config.parsed_value.enable)
+ self.assertTrue(config.parsed_value.mastodon.enable)
+ # this should not have changed
+ self.assertFalse(config.parsed_value.peertube.enable)
diff --git a/panel/src/panel/tests/test_user_stories.py b/panel/src/panel/tests/test_login.py
similarity index 100%
rename from panel/src/panel/tests/test_user_stories.py
rename to panel/src/panel/tests/test_login.py
diff --git a/panel/src/panel/views.py b/panel/src/panel/views.py
index 7a356fe0..535b9b96 100644
--- a/panel/src/panel/views.py
+++ b/panel/src/panel/views.py
@@ -37,7 +37,9 @@ class ConfigurationForm(LoginRequiredMixin, FormView):
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)
+ operator=self.request.user,
+ )
+
return obj
# TODO(@fricklerhandwerk):