Add start of a settings (config) system
This commit is contained in:
parent
495e955378
commit
44af0d4c59
|
@ -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"]
|
|
@ -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__
|
|
|
@ -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
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1 @@
|
||||||
|
from .config import Config # noqa
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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()),
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<nav>
|
||||||
|
<a href="#" {% if section == "basic" %}class="selected"{% endif %}>Basic</a>
|
||||||
|
</nav>
|
|
@ -0,0 +1,7 @@
|
||||||
|
{% extends "settings/settings_system.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ section.title }} - Settings{% endblock %}
|
||||||
|
|
||||||
|
{% block menu %}
|
||||||
|
{% include "settings/_settings_identity_menu.html" %}
|
||||||
|
{% endblock %}
|
|
@ -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 %}
|
|
@ -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,
|
||||||
|
|
|
@ -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!'",
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
},
|
||||||
|
}
|
Loading…
Reference in New Issue