summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Godwin2022-11-16 17:23:46 -0700
committerAndrew Godwin2022-11-16 17:23:46 -0700
commit44af0d4c59eed1c3715e9044e75c159cfddf54cc (patch)
treed2c87f953de12a526a158f2c03def5eb08b2d203
parent495e955378d62dc439c4c210785e5d401bc77f64 (diff)
downloadtakahe-44af0d4c59eed1c3715e9044e75c159cfddf54cc.tar.gz
takahe-44af0d4c59eed1c3715e9044e75c159cfddf54cc.tar.bz2
takahe-44af0d4c59eed1c3715e9044e75c159cfddf54cc.zip
Add start of a settings (config) system
-rw-r--r--core/admin.py8
-rw-r--r--core/config.py20
-rw-r--r--core/context.py7
-rw-r--r--core/migrations/0001_initial.py63
-rw-r--r--core/migrations/__init__.py0
-rw-r--r--core/models/__init__.py1
-rw-r--r--core/models/config.py111
-rw-r--r--static/css/style.css18
-rw-r--r--takahe/urls.py6
-rw-r--r--templates/activities/compose.html2
-rw-r--r--templates/activities/home.html2
-rw-r--r--templates/base.html12
-rw-r--r--templates/settings/_settings_identity_menu.html5
-rw-r--r--templates/settings/_settings_system_menu.html3
-rw-r--r--templates/settings/settings_identity.html7
-rw-r--r--templates/settings/settings_system.html18
-rw-r--r--users/views/identity.py4
-rw-r--r--users/views/settings_identity.py39
-rw-r--r--users/views/settings_system.py95
19 files changed, 392 insertions, 29 deletions
diff --git a/core/admin.py b/core/admin.py
new file mode 100644
index 0000000..e4a6ad0
--- /dev/null
+++ b/core/admin.py
@@ -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"]
diff --git a/core/config.py b/core/config.py
deleted file mode 100644
index b9f6878..0000000
--- a/core/config.py
+++ /dev/null
@@ -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__
diff --git a/core/context.py b/core/context.py
index 17617b9..4346cbb 100644
--- a/core/context.py
+++ b/core/context.py
@@ -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
+ ),
}
diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py
new file mode 100644
index 0000000..2c4731f
--- /dev/null
+++ b/core/migrations/0001_initial.py
@@ -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")},
+ },
+ ),
+ ]
diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/core/migrations/__init__.py
diff --git a/core/models/__init__.py b/core/models/__init__.py
new file mode 100644
index 0000000..87bfe4e
--- /dev/null
+++ b/core/models/__init__.py
@@ -0,0 +1 @@
+from .config import Config # noqa
diff --git a/core/models/config.py b/core/models/config.py
new file mode 100644
index 0000000..8a2e40b
--- /dev/null
+++ b/core/models/config.py
@@ -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
diff --git a/static/css/style.css b/static/css/style.css
index c791023..01f9076 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -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;
+ }
+}
diff --git a/takahe/urls.py b/takahe/urls.py
index 723516a..bdb5946 100644
--- a/takahe/urls.py
+++ b/takahe/urls.py
@@ -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()),
diff --git a/templates/activities/compose.html b/templates/activities/compose.html
index ad0457b..dfa6d1e 100644
--- a/templates/activities/compose.html
+++ b/templates/activities/compose.html
@@ -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 %}
diff --git a/templates/activities/home.html b/templates/activities/home.html
index bfa11f7..08e338e 100644
--- a/templates/activities/home.html
+++ b/templates/activities/home.html
@@ -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>
diff --git a/templates/base.html b/templates/base.html
index 553a2cc..e392cb9 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -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 %}
diff --git a/templates/settings/_settings_identity_menu.html b/templates/settings/_settings_identity_menu.html
new file mode 100644
index 0000000..bdae143
--- /dev/null
+++ b/templates/settings/_settings_identity_menu.html
@@ -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>
diff --git a/templates/settings/_settings_system_menu.html b/templates/settings/_settings_system_menu.html
new file mode 100644
index 0000000..fb4da02
--- /dev/null
+++ b/templates/settings/_settings_system_menu.html
@@ -0,0 +1,3 @@
+<nav>
+ <a href="#" {% if section == "basic" %}class="selected"{% endif %}>Basic</a>
+</nav>
diff --git a/templates/settings/settings_identity.html b/templates/settings/settings_identity.html
new file mode 100644
index 0000000..cdbf197
--- /dev/null
+++ b/templates/settings/settings_identity.html
@@ -0,0 +1,7 @@
+{% extends "settings/settings_system.html" %}
+
+{% block title %}{{ section.title }} - Settings{% endblock %}
+
+{% block menu %}
+ {% include "settings/_settings_identity_menu.html" %}
+{% endblock %}
diff --git a/templates/settings/settings_system.html b/templates/settings/settings_system.html
new file mode 100644
index 0000000..c10964f
--- /dev/null
+++ b/templates/settings/settings_system.html
@@ -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 %}
diff --git a/users/views/identity.py b/users/views/identity.py
index 64e3c62..4b92e14 100644
--- a/users/views/identity.py
+++ b/users/views/identity.py
@@ -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,
diff --git a/users/views/settings_identity.py b/users/views/settings_identity.py
new file mode 100644
index 0000000..8c52f9e
--- /dev/null
+++ b/users/views/settings_identity.py
@@ -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!'",
+ }
+ }
diff --git a/users/views/settings_system.py b/users/views/settings_system.py
new file mode 100644
index 0000000..52ba349
--- /dev/null
+++ b/users/views/settings_system.py
@@ -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",
+ },
+ }