From e41f9c572a9e76f6c009a4fc407ed58d183684f8 Mon Sep 17 00:00:00 2001
From: Valentin Gagarin <valentin.gagarin@tweag.io>
Date: Thu, 13 Mar 2025 15:28:54 +0100
Subject: [PATCH] add basic service configuration (#236)

- test the form interaction for a fixed schema version
- also add a database migration missed in the last commit

Closes #73

Reviewed-on: https://git.fediversity.eu/Fediversity/Fediversity/pulls/236
Co-authored-by: Valentin Gagarin <valentin.gagarin@tweag.io>
Co-committed-by: Valentin Gagarin <valentin.gagarin@tweag.io>
---
 panel/src/panel/configuration/__init__.py     |   6 +
 panel/src/panel/configuration/v2.py           | 112 ++++++++++++++++++
 ...0002_alter_configuration_value_and_more.py |  27 +++++
 .../panel/tests/test_configuration_form.py    |  54 +++++++++
 .../{test_user_stories.py => test_login.py}   |   0
 panel/src/panel/views.py                      |   4 +-
 6 files changed, 202 insertions(+), 1 deletion(-)
 create mode 100644 panel/src/panel/configuration/v2.py
 create mode 100644 panel/src/panel/migrations/0002_alter_configuration_value_and_more.py
 create mode 100644 panel/src/panel/tests/test_configuration_form.py
 rename panel/src/panel/tests/{test_user_stories.py => test_login.py} (100%)

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 `<div>`
+#       - 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):