mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-25 08:41:00 +00:00
Add start of a settings (config) system
This commit is contained in:
parent
495e955378
commit
44af0d4c59
19 changed files with 392 additions and 29 deletions
8
core/admin.py
Normal file
8
core/admin.py
Normal 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"]
|
|
@ -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):
|
||||
return {
|
||||
"config": Config.load(),
|
||||
"config": Config.load_system(),
|
||||
"config_identity": (
|
||||
Config.load_identity(request.identity) if request.identity else None
|
||||
),
|
||||
}
|
||||
|
|
63
core/migrations/0001_initial.py
Normal file
63
core/migrations/0001_initial.py
Normal 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")},
|
||||
},
|
||||
),
|
||||
]
|
0
core/migrations/__init__.py
Normal file
0
core/migrations/__init__.py
Normal file
1
core/models/__init__.py
Normal file
1
core/models/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .config import Config # noqa
|
111
core/models/config.py
Normal file
111
core/models/config.py
Normal 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
|
|
@ -163,6 +163,8 @@ header menu a.identity {
|
|||
}
|
||||
|
||||
header menu a i {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
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 core import views as core
|
||||
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 = [
|
||||
path("", core.homepage),
|
||||
|
@ -13,6 +13,10 @@ urlpatterns = [
|
|||
path("notifications/", timelines.Notifications.as_view()),
|
||||
path("local/", timelines.Local.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
|
||||
path("@<handle>/", identity.ViewIdentity.as_view()),
|
||||
path("@<handle>/actor/", activitypub.Actor.as_view()),
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
{% include "forms/_field.html" %}
|
||||
{% endfor %}
|
||||
<div class="buttons">
|
||||
<button>Post</button>
|
||||
<button>{% if config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
{{ form.content_warning }}
|
||||
<div class="buttons">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -11,6 +11,11 @@
|
|||
<link rel="manifest" href="/manifest.json" />
|
||||
<script src="{% static "js/hyperscript.min.js" %}"></script>
|
||||
<script src="{% static "js/htmx.min.js" %}"></script>
|
||||
<style>
|
||||
body {
|
||||
--color-highlight: {{ config.highlight_color }};
|
||||
}
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
||||
|
@ -23,8 +28,11 @@
|
|||
</a>
|
||||
<menu>
|
||||
{% if user.is_authenticated %}
|
||||
<a href="/compose/"><i class="fa-solid fa-feather"></i> Compose</a>
|
||||
<a href="/settings/"><i class="fa-solid fa-gear"></i> Settings</a>
|
||||
<a href="/compose/" title="Compose"><i class="fa-solid fa-feather"></i> Compose</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>
|
||||
<a href="/identity/select/" class="identity">
|
||||
{% if not request.identity %}
|
||||
|
|
5
templates/settings/_settings_identity_menu.html
Normal file
5
templates/settings/_settings_identity_menu.html
Normal 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>
|
3
templates/settings/_settings_system_menu.html
Normal file
3
templates/settings/_settings_system_menu.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<nav>
|
||||
<a href="#" {% if section == "basic" %}class="selected"{% endif %}>Basic</a>
|
||||
</nav>
|
7
templates/settings/settings_identity.html
Normal file
7
templates/settings/settings_identity.html
Normal 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 %}
|
18
templates/settings/settings_system.html
Normal file
18
templates/settings/settings_system.html
Normal 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 %}
|
|
@ -7,7 +7,7 @@ from django.shortcuts import redirect
|
|||
from django.utils.decorators import method_decorator
|
||||
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.models import Domain, Follow, Identity, IdentityStates
|
||||
from users.shortcuts import by_handle_or_404
|
||||
|
@ -25,7 +25,7 @@ class ViewIdentity(TemplateView):
|
|||
fetch=True,
|
||||
)
|
||||
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)
|
||||
return {
|
||||
"identity": identity,
|
||||
|
|
39
users/views/settings_identity.py
Normal file
39
users/views/settings_identity.py
Normal 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!'",
|
||||
}
|
||||
}
|
95
users/views/settings_system.py
Normal file
95
users/views/settings_system.py
Normal 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",
|
||||
},
|
||||
}
|
Loading…
Reference in a new issue