Add start of a settings (config) system

This commit is contained in:
Andrew Godwin 2022-11-16 17:23:46 -07:00
parent 495e955378
commit 44af0d4c59
19 changed files with 392 additions and 29 deletions

8
core/admin.py Normal file
View File

@ -0,0 +1,8 @@
from django.contrib import admin
from core.models import Config
@admin.register(Config)
class ConfigAdmin(admin.ModelAdmin):
list_display = ["id", "key", "user", "identity"]

View File

@ -1,20 +0,0 @@
import pydantic
class Config(pydantic.BaseModel):
# Basic configuration options
site_name: str = "takahē"
identity_max_age: int = 24 * 60 * 60
# Cached ORM object storage
__singleton__ = None
class Config:
env_prefix = "takahe_"
@classmethod
def load(cls) -> "Config":
if cls.__singleton__ is None:
cls.__singleton__ = cls()
return cls.__singleton__

View File

@ -1,7 +1,10 @@
from core.config import Config from core.models import Config
def config_context(request): def config_context(request):
return { return {
"config": Config.load(), "config": Config.load_system(),
"config_identity": (
Config.load_identity(request.identity) if request.identity else None
),
} }

View File

@ -0,0 +1,63 @@
# Generated by Django 4.1.3 on 2022-11-16 21:23
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("users", "0002_identity_public_key_id"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Config",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key", models.CharField(max_length=500)),
("json", models.JSONField(blank=True, null=True)),
(
"image",
models.ImageField(
blank=True, null=True, upload_to="config/%Y/%m/%d/"
),
),
(
"identity",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="configs",
to="users.identity",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="configs",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("key", "user", "identity")},
},
),
]

View File

1
core/models/__init__.py Normal file
View File

@ -0,0 +1 @@
from .config import Config # noqa

111
core/models/config.py Normal file
View File

@ -0,0 +1,111 @@
from typing import ClassVar
import pydantic
from django.db import models
from django.utils.functional import classproperty
class Config(models.Model):
"""
A configuration setting for either the server or a specific user or identity.
The possible options and their defaults are defined at the bottom of the file.
"""
key = models.CharField(max_length=500)
user = models.ForeignKey(
"users.user",
blank=True,
null=True,
related_name="configs",
on_delete=models.CASCADE,
)
identity = models.ForeignKey(
"users.identity",
blank=True,
null=True,
related_name="configs",
on_delete=models.CASCADE,
)
json = models.JSONField(blank=True, null=True)
image = models.ImageField(blank=True, null=True, upload_to="config/%Y/%m/%d/")
class Meta:
unique_together = [
("key", "user", "identity"),
]
@classproperty
def system(cls):
cls.system = cls.load_system()
return cls.system
system: ClassVar["Config.ConfigOptions"] # type: ignore
@classmethod
def load_system(cls):
"""
Load all of the system config options and return an object with them
"""
values = {}
for config in cls.objects.filter(user__isnull=True, identity__isnull=True):
values[config.key] = config.image or config.json
return cls.SystemOptions(**values)
@classmethod
def load_user(cls, user):
"""
Load all of the user config options and return an object with them
"""
values = {}
for config in cls.objects.filter(user=user, identity__isnull=True):
values[config.key] = config.image or config.json
return cls.UserOptions(**values)
@classmethod
def load_identity(cls, identity):
"""
Load all of the identity config options and return an object with them
"""
values = {}
for config in cls.objects.filter(user__isnull=True, identity=identity):
values[config.key] = config.image or config.json
return cls.IdentityOptions(**values)
@classmethod
def set_system(cls, key, value):
config_field = cls.SystemOptions.__fields__[key]
if not isinstance(value, config_field.type_):
raise ValueError(f"Invalid type for {key}: {type(value)}")
cls.objects.update_or_create(
key=key,
defaults={"json": value},
)
@classmethod
def set_identity(cls, identity, key, value):
config_field = cls.IdentityOptions.__fields__[key]
if not isinstance(value, config_field.type_):
raise ValueError(f"Invalid type for {key}: {type(value)}")
cls.objects.update_or_create(
identity=identity,
key=key,
defaults={"json": value},
)
class SystemOptions(pydantic.BaseModel):
site_name: str = "takahē"
highlight_color: str = "#449c8c"
identity_max_age: int = 24 * 60 * 60
class UserOptions(pydantic.BaseModel):
pass
class IdentityOptions(pydantic.BaseModel):
toot_mode: bool = False

View File

@ -163,6 +163,8 @@ header menu a.identity {
} }
header menu a i { header menu a i {
display: inline-block;
vertical-align: middle;
margin-right: 10px; margin-right: 10px;
} }
@ -604,3 +606,19 @@ h1.identity small {
} }
} }
@media (max-width: 800px) {
header menu a {
font-size: 0;
padding: 10px 20px 4px 20px;
}
header menu a i {
display: inline-block;
vertical-align: middle;
margin: 0;
font-size: 20px;
}
}

View File

@ -4,7 +4,7 @@ from django.urls import path
from activities.views import posts, timelines from activities.views import posts, timelines
from core import views as core from core import views as core
from stator import views as stator from stator import views as stator
from users.views import activitypub, auth, identity from users.views import activitypub, auth, identity, settings_identity, settings_system
urlpatterns = [ urlpatterns = [
path("", core.homepage), path("", core.homepage),
@ -13,6 +13,10 @@ urlpatterns = [
path("notifications/", timelines.Notifications.as_view()), path("notifications/", timelines.Notifications.as_view()),
path("local/", timelines.Local.as_view()), path("local/", timelines.Local.as_view()),
path("federated/", timelines.Federated.as_view()), path("federated/", timelines.Federated.as_view()),
path("settings/", settings_identity.IdentitySettingsRoot.as_view()),
path("settings/interface/", settings_identity.InterfacePage.as_view()),
path("settings/system/", settings_system.SystemSettingsRoot.as_view()),
path("settings/system/basic/", settings_system.BasicPage.as_view()),
# Identity views # Identity views
path("@<handle>/", identity.ViewIdentity.as_view()), path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/actor/", activitypub.Actor.as_view()), path("@<handle>/actor/", activitypub.Actor.as_view()),

View File

@ -13,7 +13,7 @@
{% include "forms/_field.html" %} {% include "forms/_field.html" %}
{% endfor %} {% endfor %}
<div class="buttons"> <div class="buttons">
<button>Post</button> <button>{% if config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -32,7 +32,7 @@
{{ form.content_warning }} {{ form.content_warning }}
<div class="buttons"> <div class="buttons">
<span class="button toggle" _="on click toggle .enabled then toggle .hidden on #id_content_warning">CW</span> <span class="button toggle" _="on click toggle .enabled then toggle .hidden on #id_content_warning">CW</span>
<button>Post</button> <button>{% if config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -11,6 +11,11 @@
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<script src="{% static "js/hyperscript.min.js" %}"></script> <script src="{% static "js/hyperscript.min.js" %}"></script>
<script src="{% static "js/htmx.min.js" %}"></script> <script src="{% static "js/htmx.min.js" %}"></script>
<style>
body {
--color-highlight: {{ config.highlight_color }};
}
</style>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body class="{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> <body class="{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
@ -23,8 +28,11 @@
</a> </a>
<menu> <menu>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="/compose/"><i class="fa-solid fa-feather"></i> Compose</a> <a href="/compose/" title="Compose"><i class="fa-solid fa-feather"></i> Compose</a>
<a href="/settings/"><i class="fa-solid fa-gear"></i> Settings</a> <a href="/settings/" title="Settings"><i class="fa-solid fa-gear"></i> Settings</a>
{% if request.user.admin %}
<a href="/settings/system/" title="Admin"><i class="fa-solid fa-toolbox"></i> Admin</a>
{% endif %}
<div class="gap"></div> <div class="gap"></div>
<a href="/identity/select/" class="identity"> <a href="/identity/select/" class="identity">
{% if not request.identity %} {% if not request.identity %}

View File

@ -0,0 +1,5 @@
<nav>
<a href="#" {% if section == "profile" %}class="selected"{% endif %}>Profile</a>
<a href="#" {% if section == "interface" %}class="selected"{% endif %}>Interface</a>
<a href="#" {% if section == "filtering" %}class="selected"{% endif %}>Filtering</a>
</nav>

View File

@ -0,0 +1,3 @@
<nav>
<a href="#" {% if section == "basic" %}class="selected"{% endif %}>Basic</a>
</nav>

View File

@ -0,0 +1,7 @@
{% extends "settings/settings_system.html" %}
{% block title %}{{ section.title }} - Settings{% endblock %}
{% block menu %}
{% include "settings/_settings_identity_menu.html" %}
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}{{ section.title }} - System Settings{% endblock %}
{% block content %}
{% block menu %}
{% include "settings/_settings_system_menu.html" %}
{% endblock %}
<form action="." method="POST">
{% csrf_token %}
{% for field in form %}
{% include "forms/_field.html" %}
{% endfor %}
<div class="buttons">
<button>Save</button>
</div>
</form>
{% endblock %}

View File

@ -7,7 +7,7 @@ from django.shortcuts import redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView, View from django.views.generic import FormView, TemplateView, View
from core.config import Config from core.models import Config
from users.decorators import identity_required from users.decorators import identity_required
from users.models import Domain, Follow, Identity, IdentityStates from users.models import Domain, Follow, Identity, IdentityStates
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -25,7 +25,7 @@ class ViewIdentity(TemplateView):
fetch=True, fetch=True,
) )
posts = identity.posts.all()[:100] posts = identity.posts.all()[:100]
if identity.data_age > Config.load().identity_max_age: if identity.data_age > Config.system.identity_max_age:
identity.transition_perform(IdentityStates.outdated) identity.transition_perform(IdentityStates.outdated)
return { return {
"identity": identity, "identity": identity,

View File

@ -0,0 +1,39 @@
from django.utils.decorators import method_decorator
from django.views.generic import RedirectView
from core.models import Config
from users.decorators import identity_required
from users.views.settings_system import SystemSettingsPage
@method_decorator(identity_required, name="dispatch")
class IdentitySettingsRoot(RedirectView):
url = "/settings/interface/"
class IdentitySettingsPage(SystemSettingsPage):
"""
Shows a settings page dynamically created from our settings layout
at the bottom of the page. Don't add this to a URL directly - subclass!
"""
options_class = Config.IdentityOptions
template_name = "settings/settings_identity.html"
def load_config(self):
return Config.load_identity(self.request.identity)
def save_config(self, key, value):
Config.set_identity(self.request.identity, key, value)
class InterfacePage(IdentitySettingsPage):
section = "interface"
options = {
"toot_mode": {
"title": "I Will Toot As I Please",
"help_text": "If enabled, changes all 'Post' buttons to 'Toot!'",
}
}

View File

@ -0,0 +1,95 @@
from functools import partial
from typing import ClassVar, Dict
from django import forms
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.views.generic import FormView, RedirectView
from core.models import Config
from users.decorators import identity_required
@method_decorator(identity_required, name="dispatch")
class SystemSettingsRoot(RedirectView):
url = "/settings/system/basic/"
@method_decorator(identity_required, name="dispatch")
class SystemSettingsPage(FormView):
"""
Shows a settings page dynamically created from our settings layout
at the bottom of the page. Don't add this to a URL directly - subclass!
"""
template_name = "settings/settings_system.html"
options_class = Config.SystemOptions
section: ClassVar[str]
options: Dict[str, Dict[str, str]]
def get_form_class(self):
# Create the fields dict from the config object
fields = {}
for key, details in self.options.items():
config_field = self.options_class.__fields__[key]
if config_field.type_ is bool:
form_field = partial(
forms.BooleanField,
widget=forms.Select(
choices=[(True, "Enabled"), (False, "Disabled")]
),
)
elif config_field.type_ is str:
form_field = forms.CharField
else:
raise ValueError(f"Cannot render settings type {config_field.type_}")
fields[key] = form_field(
label=details["title"],
help_text=details.get("help_text", ""),
required=details.get("required", False),
)
# Create a form class dynamically (yeah, right?) and return that
return type("SettingsForm", (forms.Form,), fields)
def load_config(self):
return Config.load_system()
def save_config(self, key, value):
Config.set_system(key, value)
def get_initial(self):
config = self.load_config()
initial = {}
for key in self.options.keys():
initial[key] = getattr(config, key)
return initial
def get_context_data(self):
context = super().get_context_data()
context["section"] = self.section
return context
def form_valid(self, form):
# Save each key
for field in form:
self.save_config(
field.name,
form.cleaned_data[field.name],
)
return redirect(".")
class BasicPage(SystemSettingsPage):
section = "basic"
options = {
"site_name": {
"title": "Site Name",
"help_text": "Shown in the top-left of the page, and titles",
},
"highlight_color": {
"title": "Highlight Color",
"help_text": "Used for logo background and other highlights",
},
}