Remove versions

This commit is contained in:
Lois Verheij 2025-03-13 15:25:07 +01:00
parent e41f9c572a
commit 08d109cc82
8 changed files with 49 additions and 137 deletions

View file

@ -1,42 +0,0 @@
import os
import re
import sys
from importlib import import_module
from importlib.util import find_spec
from django.utils.functional import classproperty
from django_pydantic_field import SchemaField
from django.apps import apps
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
form: forms.ModelForm
@classproperty
def latest(cls):
current_dir = os.path.dirname(os.path.abspath(__file__))
version_pattern = re.compile(r'v(\d+)\.py$')
versions = []
for filename in os.listdir(current_dir):
match = version_pattern.match(filename)
if match:
versions.append(int(match.group(1)))
return max(versions)
def __init__(self, version: int):
module = import_module(f"{__name__}.v{version}")
self.model = getattr(module, "Configuration")
self.form = getattr(module, "Form")

View file

@ -7,24 +7,29 @@ from django_pydantic_field import SchemaField
# TODO(@fricklerhandwerk): # TODO(@fricklerhandwerk):
# Eventually should probably maintain a separate series of configuration schema versions for each service. # 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. # I didn't start it here yet, mainly to keep it readable.
class PeerTube(BaseModel): class PeerTube(BaseModel):
enable: bool = Field( enable: bool = Field(
default=False, default=False,
description="Enable PeerTube", description="Enable PeerTube",
) )
class Pixelfed(BaseModel): class Pixelfed(BaseModel):
enable: bool = Field( enable: bool = Field(
default=False, default=False,
description="Enable Pixelfed", description="Enable Pixelfed",
) )
class Mastodon(BaseModel): class Mastodon(BaseModel):
enable: bool = Field( enable: bool = Field(
default=False, default=False,
description="Enable Mastodon", description="Enable Mastodon",
) )
class Configuration(BaseModel): class Configuration(BaseModel):
enable: bool = Field( enable: bool = Field(
default=False, default=False,
@ -43,25 +48,25 @@ class Configuration(BaseModel):
) )
peertube: PeerTube = Field( peertube: PeerTube = Field(
default=PeerTube(), default=PeerTube(),
description="Configuration for PeerTube", description="Configuration for PeerTube",
) )
pixelfed: Pixelfed = Field( pixelfed: Pixelfed = Field(
default=Pixelfed(), default=Pixelfed(),
description="Configuration for Pixelfed", description="Configuration for Pixelfed",
) )
mastodon: Mastodon = Field( mastodon: Mastodon = Field(
default=Mastodon(), default=Mastodon(),
description="Configuration for Mastodon", description="Configuration for Mastodon",
) )
# TODO@(fricklerhandwerk): # TODO@(fricklerhandwerk):
# generate this automatically from the Pydantic model so this code can go away: # 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 # - 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` # - 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>` # - use a custom'{"name": "John Doe", "age": 30 }' widget that renders nested forms in e.g. a `<div>`
# - likely this also needs overriding `as_p()`, `as_li()` # - likely this also needs overriding `as_p()`, `as_li()`
# more inspiration: https://levelup.gitconnected.com/how-to-export-pydantic-models-as-django-forms-c1b59ddca580 # 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 # that work goes through the JSON Schema generated by Pydantic, which seems unnecessary to me, but it follows the same princple
@ -69,6 +74,8 @@ class Configuration(BaseModel):
# eventually we probably want to validate each field separately, # 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. # 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` # those htmx parts can be generated by each form field using `attrs`
class Form(forms.Form): class Form(forms.Form):
enable = forms.BooleanField(required=False) enable = forms.BooleanField(required=False)
domain = forms.ChoiceField( domain = forms.ChoiceField(
@ -101,12 +108,11 @@ class Form(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def to_python(self): def to_python(self):
return Configuration( return Configuration(
enable=self.cleaned_data['enable'], enable=self.cleaned_data['enable'],
domain=Configuration.Domain[self.cleaned_data['domain']], domain=Configuration.Domain[self.cleaned_data['domain']],
peertube = PeerTube(enable=self.cleaned_data['peertube_enable']), peertube=PeerTube(enable=self.cleaned_data['peertube_enable']),
pixelfed = Pixelfed(enable=self.cleaned_data['pixelfed_enable']), pixelfed=Pixelfed(enable=self.cleaned_data['pixelfed_enable']),
mastodon = Mastodon(enable=self.cleaned_data['mastodon_enable']), mastodon=Mastodon(enable=self.cleaned_data['mastodon_enable']),
) )

View file

@ -1,44 +0,0 @@
from django import forms
from django.db import models
from pydantic import BaseModel, Field
from enum import Enum
from django_pydantic_field import SchemaField
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@(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(choices=[(d.name, d.value) for d in Configuration.Domain])
def to_python(self):
return Configuration(
enable=self.cleaned_data['enable'],
domain=Configuration.Domain[self.cleaned_data['domain']]
)

View file

@ -17,10 +17,12 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name='Configuration', name='Configuration',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True,
('version', models.PositiveIntegerField(default=1, help_text='Configuration schema version')), primary_key=True, serialize=False, verbose_name='ID')),
('value', models.JSONField(help_text='Stored configuration value', null=True)), ('value', models.JSONField(
('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)), help_text='Stored configuration value', null=True)),
('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)),
], ],
), ),
] ]

View file

@ -2,9 +2,8 @@
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import pydantic.main from panel.configuration.forms import Configuration
from panel.configuration import Version
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -17,11 +16,7 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='configuration', model_name='configuration',
name='value', name='value',
field=models.JSONField(default=Version(Version.latest).model().model_dump_json, help_text='Stored configuration value'), field=models.JSONField(default=Configuration(
), ).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'),
), ),
] ]

View file

@ -1,10 +1,15 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from panel.configuration import Version from panel.configuration import forms
from pydantic import BaseModel from pydantic import BaseModel
def get_default_config():
return forms.Configuration().model_dump_json()
class Configuration(models.Model): class Configuration(models.Model):
operator = models.ForeignKey( operator = models.ForeignKey(
User, User,
@ -14,16 +19,11 @@ class Configuration(models.Model):
help_text="Operator who owns the configuration", help_text="Operator who owns the configuration",
) )
version = models.PositiveIntegerField(
help_text="Configuration schema version",
default=Version.latest,
)
value = models.JSONField( value = models.JSONField(
help_text="Stored configuration value", help_text="Stored configuration value",
default=Version(Version.latest).model().model_dump_json, default=get_default_config,
) )
@property @property
def parsed_value(self) -> BaseModel: def parsed_value(self) -> BaseModel:
return Version(self.version).model.model_validate_json(self.value) return forms.Configuration.model_validate_json(self.value)

View file

@ -17,19 +17,13 @@ class ConfigurationForm(TestCase):
def test_configuration_form_submission(self): def test_configuration_form_submission(self):
config = Configuration.objects.create( config = Configuration.objects.create(
operator = self.user, 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) self.client.login(username=self.username, password=self.password)
response = self.client.get(self.config_url) response = self.client.get(self.config_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
context = response.context[0] context = response.context[0]
# configuration should be disabled by default # configuration should be disabled by default
@ -39,7 +33,7 @@ class ConfigurationForm(TestCase):
form_data = context['form'].initial.copy() form_data = context['form'].initial.copy()
form_data.update( form_data.update(
enable= True, enable=True,
mastodon_enable=True, mastodon_enable=True,
) )

View file

@ -7,33 +7,34 @@ from django.views.generic import TemplateView, DetailView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from panel import models from panel import models
from panel.configuration import Version from panel.configuration import forms
class Index(TemplateView): class Index(TemplateView):
template_name = 'index.html' template_name = 'index.html'
class AccountDetail(LoginRequiredMixin, DetailView): class AccountDetail(LoginRequiredMixin, DetailView):
model = User model = User
template_name = 'account_detail.html' template_name = 'account_detail.html'
def get_object(self): def get_object(self):
return self.request.user return self.request.user
class ServiceList(TemplateView): class ServiceList(TemplateView):
template_name = 'service_list.html' template_name = 'service_list.html'
class ConfigurationForm(LoginRequiredMixin, FormView): class ConfigurationForm(LoginRequiredMixin, FormView):
template_name = 'configuration_form.html' template_name = 'configuration_form.html'
success_url = reverse_lazy('configuration_form') success_url = reverse_lazy('configuration_form')
form_class = forms.Form
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["version"] = self.get_object().version
return context return context
def get_form_class(self):
config = self.get_object()
return Version(config.version).form
def get_object(self): def get_object(self):
"""Get or create the configuration object for the current user""" """Get or create the configuration object for the current user"""
obj, created = models.Configuration.objects.get_or_create( obj, created = models.Configuration.objects.get_or_create(