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 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("@/", identity.ViewIdentity.as_view()), path("@/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 %}
- +
{% 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 }}
CW - +
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 @@ + {% block extra_head %}{% endblock %} @@ -23,8 +28,11 @@ {% if user.is_authenticated %} - Compose - Settings + Compose + Settings + {% if request.user.admin %} + Admin + {% endif %}
{% 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 @@ + 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 @@ + 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 %} +
+ {% csrf_token %} + {% for field in form %} + {% include "forms/_field.html" %} + {% endfor %} +
+ +
+
+{% 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", + }, + }