forked from fediversity/fediversity
		
	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: Fediversity/Fediversity#236 Co-authored-by: Valentin Gagarin <valentin.gagarin@tweag.io> Co-committed-by: Valentin Gagarin <valentin.gagarin@tweag.io>
This commit is contained in:
		
							parent
							
								
									607b17a10a
								
							
						
					
					
						commit
						e41f9c572a
					
				
					 6 changed files with 202 additions and 1 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										112
									
								
								panel/src/panel/configuration/v2.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								panel/src/panel/configuration/v2.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -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']), | ||||
|         ) | ||||
|  | @ -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'), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										54
									
								
								panel/src/panel/tests/test_configuration_form.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								panel/src/panel/tests/test_configuration_form.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -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) | ||||
|  | @ -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): | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue