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