From 10588a18b92ee906cf3ef9274d79858a5674802e Mon Sep 17 00:00:00 2001
From: Mark Veidemanis
Date: Sat, 7 Mar 2026 16:32:24 +0000
Subject: [PATCH] Improve settings hierarchy conciseness
---
app/settings.py | 1 +
app/urls.py | 82 ++++++++-
core/context_processors.py | 119 +++++++++++++
.../0041_useraccessibilitysettings.py | 40 +++++
core/models.py | 11 ++
core/templates/base.html | 110 ++++++++----
.../mixins/window-content/objects.html | 61 +++++++
.../pages/accessibility-settings.html | 25 +++
core/templates/pages/ai-execution-log.html | 103 +++++++++++-
core/templates/pages/security.html | 30 +++-
core/templates/pages/settings-category.html | 22 +++
.../partials/ai-execution-run-detail-tab.html | 7 +
.../partials/ai-execution-run-detail.html | 56 +++++++
.../partials/settings-hierarchy-nav.html | 12 ++
.../two_factor/core/backup_tokens.html | 2 +-
.../two_factor/core/setup_complete.html | 4 +-
.../templates/two_factor/profile/profile.html | 2 +-
core/templatetags/accessibility.py | 13 ++
core/views/ais.py | 25 ++-
core/views/automation.py | 43 +++++
core/views/system.py | 158 ++++++++++++++----
21 files changed, 846 insertions(+), 80 deletions(-)
create mode 100644 core/context_processors.py
create mode 100644 core/migrations/0041_useraccessibilitysettings.py
create mode 100644 core/templates/mixins/window-content/objects.html
create mode 100644 core/templates/pages/accessibility-settings.html
create mode 100644 core/templates/pages/settings-category.html
create mode 100644 core/templates/partials/ai-execution-run-detail-tab.html
create mode 100644 core/templates/partials/ai-execution-run-detail.html
create mode 100644 core/templates/partials/settings-hierarchy-nav.html
create mode 100644 core/templatetags/accessibility.py
diff --git a/app/settings.py b/app/settings.py
index 9e6ceaf..d2ccc74 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -116,6 +116,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"core.util.django_settings_export.settings_export",
+ "core.context_processors.settings_hierarchy_nav",
],
},
},
diff --git a/app/urls.py b/app/urls.py
index 9c0cb05..452f8f1 100644
--- a/app/urls.py
+++ b/app/urls.py
@@ -17,8 +17,10 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LogoutView
+from django.views.generic import RedirectView
from django.urls import include, path
from two_factor.urls import urlpatterns as tf_urls
+from two_factor.views.profile import ProfileView
from core.views import (
ais,
@@ -49,21 +51,69 @@ urlpatterns = [
path("__debug__/", include("debug_toolbar.urls")),
path("", base.Home.as_view(), name="home"),
path("admin/", admin.site.urls),
+ path(
+ "account/two_factor/",
+ RedirectView.as_view(pattern_name="security_2fa", permanent=False),
+ ),
# 2FA login urls
path("", include(tf_urls)),
path("accounts/signup/", base.Signup.as_view(), name="signup"),
path("accounts/logout/", LogoutView.as_view(), name="logout"),
# Notifications
+ path(
+ "notifications/page/update/",
+ RedirectView.as_view(pattern_name="notifications_settings", permanent=False),
+ ),
path(
"notifications//update/",
notifications.NotificationsUpdate.as_view(),
name="notifications_update",
),
+ path(
+ "settings/notifications/",
+ notifications.NotificationsUpdate.as_view(),
+ {"type": "page"},
+ name="notifications_settings",
+ ),
path(
"settings/security/",
- system.SecurityPage.as_view(),
+ system.SecurityPage.as_view(page_mode="encryption"),
name="security_settings",
),
+ path(
+ "settings/security/encryption/",
+ system.SecurityPage.as_view(page_mode="encryption"),
+ name="encryption_settings",
+ ),
+ path(
+ "settings/security/permissions/",
+ system.SecurityPage.as_view(page_mode="permission"),
+ name="permission_settings",
+ ),
+ path(
+ "settings/security/permission/",
+ RedirectView.as_view(pattern_name="permission_settings", permanent=False),
+ ),
+ path(
+ "settings/security/2fa/",
+ ProfileView.as_view(),
+ name="security_2fa",
+ ),
+ path(
+ "settings/accessibility/",
+ system.AccessibilitySettings.as_view(),
+ name="accessibility_settings",
+ ),
+ path(
+ "settings/ai/",
+ system.AISettingsPage.as_view(),
+ name="ai_settings",
+ ),
+ path(
+ "settings/modules/",
+ system.ModulesSettingsPage.as_view(),
+ name="modules_settings",
+ ),
path(
"settings/system/",
system.SystemSettings.as_view(),
@@ -115,10 +165,24 @@ urlpatterns = [
name="command_routing",
),
path(
- "settings/ai-execution/",
+ "settings/ai/traces/",
automation.AIExecutionLogSettings.as_view(),
name="ai_execution_log",
),
+ path(
+ "settings/ai/traces/run//",
+ automation.AIExecutionRunDetailView.as_view(),
+ name="ai_execution_run_detail",
+ ),
+ path(
+ "settings/ai/traces/run//tab//",
+ automation.AIExecutionRunDetailTabView.as_view(),
+ name="ai_execution_run_detail_tab",
+ ),
+ path(
+ "settings/ai-execution/",
+ RedirectView.as_view(pattern_name="ai_execution_log", permanent=False),
+ ),
path(
"settings/translation/",
automation.TranslationSettings.as_view(),
@@ -500,6 +564,20 @@ urlpatterns = [
workspace.AIWorkspaceUpdatePlanMeta.as_view(),
name="ai_workspace_mitigation_meta_save",
),
+ path(
+ "settings/ai/models/",
+ ais.AIList.as_view(),
+ {"type": "page"},
+ name="ai_models",
+ ),
+ path(
+ "ai/models/",
+ RedirectView.as_view(pattern_name="ai_models", permanent=False),
+ ),
+ path(
+ "ai/page/",
+ RedirectView.as_view(pattern_name="ai_models", permanent=False),
+ ),
path(
"ai//",
ais.AIList.as_view(),
diff --git a/core/context_processors.py b/core/context_processors.py
new file mode 100644
index 0000000..8871b7b
--- /dev/null
+++ b/core/context_processors.py
@@ -0,0 +1,119 @@
+from django.urls import reverse
+
+
+def _tab(label: str, href: str, active: bool) -> dict:
+ return {
+ "label": label,
+ "href": href,
+ "active": bool(active),
+ }
+
+
+def settings_hierarchy_nav(request):
+ match = getattr(request, "resolver_match", None)
+ if match is None:
+ return {}
+
+ url_name = str(getattr(match, "url_name", "") or "")
+ namespace = str(getattr(match, "namespace", "") or "")
+ path = str(getattr(request, "path", "") or "")
+
+ notifications_href = reverse("notifications_settings")
+ system_href = reverse("system_settings")
+ accessibility_href = reverse("accessibility_settings")
+ encryption_href = reverse("encryption_settings")
+ permissions_href = reverse("permission_settings")
+ security_2fa_href = reverse("security_2fa")
+ ai_models_href = reverse("ai_models")
+ ai_traces_href = reverse("ai_execution_log")
+ commands_href = reverse("command_routing")
+ tasks_href = reverse("tasks_settings")
+ translation_href = reverse("translation_settings")
+ availability_href = reverse("availability_settings")
+
+ general_routes = {
+ "notifications_settings",
+ "notifications_update",
+ "system_settings",
+ "accessibility_settings",
+ }
+ security_routes = {
+ "security_settings",
+ "encryption_settings",
+ "permission_settings",
+ "security_2fa",
+ }
+ ai_routes = {
+ "ai_settings",
+ "ai_models",
+ "ais",
+ "ai_create",
+ "ai_update",
+ "ai_delete",
+ "ai_execution_log",
+ }
+ modules_routes = {
+ "modules_settings",
+ "command_routing",
+ "tasks_settings",
+ "translation_settings",
+ "availability_settings",
+ }
+
+ two_factor_security_routes = {
+ "profile",
+ "setup",
+ "backup_tokens",
+ "disable",
+ "phone_create",
+ "phone_delete",
+ }
+
+ if url_name in general_routes:
+ settings_nav = {
+ "title": "General",
+ "tabs": [
+ _tab("Notifications", notifications_href, path == notifications_href),
+ _tab("System", system_href, path == system_href),
+ _tab("Accessibility", accessibility_href, path == accessibility_href),
+ ],
+ }
+ elif url_name in security_routes or (
+ namespace == "two_factor" and url_name in two_factor_security_routes
+ ):
+ settings_nav = {
+ "title": "Security",
+ "tabs": [
+ _tab("Encryption", encryption_href, path == encryption_href),
+ _tab("Permissions", permissions_href, path == permissions_href),
+ _tab(
+ "2FA",
+ security_2fa_href,
+ path == security_2fa_href or namespace == "two_factor",
+ ),
+ ],
+ }
+ elif url_name in ai_routes:
+ settings_nav = {
+ "title": "AI",
+ "tabs": [
+ _tab("Models", ai_models_href, path == ai_models_href),
+ _tab("Traces", ai_traces_href, path == ai_traces_href),
+ ],
+ }
+ elif url_name in modules_routes:
+ settings_nav = {
+ "title": "Modules",
+ "tabs": [
+ _tab("Commands", commands_href, path == commands_href),
+ _tab("Tasks", tasks_href, path == tasks_href),
+ _tab("Translation", translation_href, path == translation_href),
+ _tab("Availability", availability_href, path == availability_href),
+ ],
+ }
+ else:
+ settings_nav = None
+
+ if not settings_nav:
+ return {}
+ return {"settings_nav": settings_nav}
diff --git a/core/migrations/0041_useraccessibilitysettings.py b/core/migrations/0041_useraccessibilitysettings.py
new file mode 100644
index 0000000..eead8c3
--- /dev/null
+++ b/core/migrations/0041_useraccessibilitysettings.py
@@ -0,0 +1,40 @@
+# Generated by Django 5.2.7 on 2026-03-07 15:47
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0040_commandsecuritypolicy_gatewaycommandevent"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="UserAccessibilitySettings",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("disable_animations", models.BooleanField(default=False)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "user",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="accessibility_settings",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index 7dbd1b0..8ba8a4f 100644
--- a/core/models.py
+++ b/core/models.py
@@ -2925,6 +2925,17 @@ class UserXmppSecuritySettings(models.Model):
updated_at = models.DateTimeField(auto_now=True)
+class UserAccessibilitySettings(models.Model):
+ user = models.OneToOneField(
+ User,
+ on_delete=models.CASCADE,
+ related_name="accessibility_settings",
+ )
+ disable_animations = models.BooleanField(default=False)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+
class TaskCompletionPattern(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_completion_patterns")
diff --git a/core/templates/base.html b/core/templates/base.html
index ea8a441..ad07418 100644
--- a/core/templates/base.html
+++ b/core/templates/base.html
@@ -1,6 +1,7 @@
{% load static %}
{% load cache %}
{% load page_title %}
+{% load accessibility %}
@@ -290,10 +291,26 @@
.osint-search-form .button.is-fullwidth {
width: 100%;
}
+ .navbar-dropdown .navbar-item.is-current-route {
+ background-color: rgba(50, 115, 220, 0.14) !important;
+ color: #1f4f99 !important;
+ font-weight: 600;
+ }
+ .security-page-tabs a {
+ transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
+ }
+ .reduced-motion,
+ .reduced-motion * {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
-
+ {% get_accessibility_settings request.user as a11y_settings %}
+
@@ -343,6 +360,31 @@
AI
+
Search
@@ -400,38 +442,47 @@
{% endif %}
@@ -682,8 +733,9 @@
{% block outer_content %}
{% endblock %}
-
+
+ {% include "partials/settings-hierarchy-nav.html" %}
{% block content_wrapper %}
{% block content %}
{% endblock %}
@@ -695,6 +747,6 @@
-
+
diff --git a/core/templates/mixins/window-content/objects.html b/core/templates/mixins/window-content/objects.html
new file mode 100644
index 0000000..3553da2
--- /dev/null
+++ b/core/templates/mixins/window-content/objects.html
@@ -0,0 +1,61 @@
+{% include 'mixins/partials/notify.html' %}
+{% if page_title is not None %}
+ {{ page_title }}
+{% endif %}
+{% if page_subtitle is not None %}
+ {{ page_subtitle }}
+{% endif %}
+
+
+ {% if submit_url is not None %}
+
+
+
+
+
+ {{ title_singular }}
+
+
+ {% endif %}
+ {% if delete_all_url is not None %}
+
+
+
+
+
+ Delete all {{ context_object_name }}
+
+
+ {% endif %}
+ {% for button in extra_buttons %}
+
+
+
+
+
+ {{ button.label }}
+
+
+ {% endfor %}
+
+
+{% include list_template %}
diff --git a/core/templates/pages/accessibility-settings.html b/core/templates/pages/accessibility-settings.html
new file mode 100644
index 0000000..1a16655
--- /dev/null
+++ b/core/templates/pages/accessibility-settings.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+{% endblock %}
diff --git a/core/templates/pages/ai-execution-log.html b/core/templates/pages/ai-execution-log.html
index 83e29ca..784fa35 100644
--- a/core/templates/pages/ai-execution-log.html
+++ b/core/templates/pages/ai-execution-log.html
@@ -1,13 +1,11 @@
{% extends "base.html" %}
{% block content %}
-
-
-
AI Execution Log
+
Traces
Tracked model calls and usage metrics for this account.
@@ -159,6 +157,7 @@
+
Started
Status
Operation
@@ -173,6 +172,22 @@
{% for run in runs %}
+
+
+ Show
+
+
{{ run.started_at }}
{% if run.status == "ok" %}
@@ -197,14 +212,90 @@
{% endif %}
+
+
+
+ Click Show to load run details.
+
+
+
{% empty %}
- No runs yet.
+ No runs yet.
{% endfor %}
-
-
+
{% endblock %}
diff --git a/core/templates/pages/security.html b/core/templates/pages/security.html
index 2378c31..8178732 100644
--- a/core/templates/pages/security.html
+++ b/core/templates/pages/security.html
@@ -3,8 +3,7 @@
{% block content %}
-
Security
-
+ {% if show_encryption %}
+ {% endif %}
+ {% if show_permission %}
Global Scope Override
@@ -194,14 +195,24 @@
Require OMEMO
- Change Global
+
+ Change Global
+
- Per Scope
- Force On
- Force Off
+ Per Scope
+ Force On
+ Force Off
+ {% if security_settings.require_omemo %}
+
Locked by Encryption setting: disable "Require OMEMO encryption" to edit this override.
+ {% endif %}
@@ -263,7 +274,7 @@
{% endfor %}
+ {% endif %}
+ {% if show_encryption %}
OMEMO Enablement Plan
Complete each step to achieve end-to-end encrypted messaging with the gateway.
@@ -402,6 +415,7 @@
+ {% endif %}
diff --git a/core/templates/pages/settings-category.html b/core/templates/pages/settings-category.html
new file mode 100644
index 0000000..9c00187
--- /dev/null
+++ b/core/templates/pages/settings-category.html
@@ -0,0 +1,22 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+
+
{{ category_title }}
+
{{ category_description }}
+
+
+
Choose a tab above to open settings in this category.
+
+
+
+{% endblock %}
diff --git a/core/templates/partials/ai-execution-run-detail-tab.html b/core/templates/partials/ai-execution-run-detail-tab.html
new file mode 100644
index 0000000..e615ab0
--- /dev/null
+++ b/core/templates/partials/ai-execution-run-detail-tab.html
@@ -0,0 +1,7 @@
+{% if tab_slug == "error" %}
+ {% if run.error %}
+ {{ run.error }}
+ {% else %}
+ No error recorded for this run.
+ {% endif %}
+{% endif %}
diff --git a/core/templates/partials/ai-execution-run-detail.html b/core/templates/partials/ai-execution-run-detail.html
new file mode 100644
index 0000000..d04dea0
--- /dev/null
+++ b/core/templates/partials/ai-execution-run-detail.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Status {{ run.status|default:"-" }}
+ Operation {{ run.operation|default:"-" }}
+ Model {{ run.model|default:"-" }}
+ Started {{ run.started_at|default:"-" }}
+ Finished {{ run.finished_at|default:"-" }}
+ Duration (ms) {{ run.duration_ms|default:"-" }}
+
+
+
+
+
+
+ Run ID {{ run.id }}
+ AI ID {% if run.ai_id %}{{ run.ai_id }}{% else %}-{% endif %}
+ Base URL {{ run.base_url|default:"-" }}
+
+
+
+
+
+
+ Message Count {{ run.message_count }}
+ Prompt Chars {{ run.prompt_chars }}
+ Response Chars {{ run.response_chars }}
+
+
+
+
+
Click the Error tab to load details.
+
+
diff --git a/core/templates/partials/settings-hierarchy-nav.html b/core/templates/partials/settings-hierarchy-nav.html
new file mode 100644
index 0000000..7b6a99a
--- /dev/null
+++ b/core/templates/partials/settings-hierarchy-nav.html
@@ -0,0 +1,12 @@
+{% if settings_nav %}
+ {{ settings_nav.title }}
+
+
+ {% for tab in settings_nav.tabs %}
+
+ {{ tab.label }}
+
+ {% endfor %}
+
+
+{% endif %}
diff --git a/core/templates/two_factor/core/backup_tokens.html b/core/templates/two_factor/core/backup_tokens.html
index ca4f9ff..955eb3b 100644
--- a/core/templates/two_factor/core/backup_tokens.html
+++ b/core/templates/two_factor/core/backup_tokens.html
@@ -21,7 +21,7 @@
{% endif %}
diff --git a/core/templates/two_factor/core/setup_complete.html b/core/templates/two_factor/core/setup_complete.html
index 0d947ab..f0f364e 100644
--- a/core/templates/two_factor/core/setup_complete.html
+++ b/core/templates/two_factor/core/setup_complete.html
@@ -8,14 +8,14 @@
authentication.{% endblocktrans %}
{% if not phone_methods %}
- {% trans "Back to Account Security" %}
{% else %}
{% blocktrans trimmed %}However, it might happen that you don't have access to
your primary token device. To enable account recovery, add a phone
number.{% endblocktrans %}
- {% trans "Back to Account Security" %}
{% trans "Add Phone Number" %}
diff --git a/core/templates/two_factor/profile/profile.html b/core/templates/two_factor/profile/profile.html
index f72e7bc..b509030 100644
--- a/core/templates/two_factor/profile/profile.html
+++ b/core/templates/two_factor/profile/profile.html
@@ -2,7 +2,7 @@
{% load i18n %}
{% block content %}
- {% block title %}{% trans "Account Security" %}{% endblock %}
+ {% block title %}{% trans "Account Security" %}{% endblock %}
{% if default_device %}
{% if default_device_type == 'TOTPDevice' %}
diff --git a/core/templatetags/accessibility.py b/core/templatetags/accessibility.py
new file mode 100644
index 0000000..1f1716f
--- /dev/null
+++ b/core/templatetags/accessibility.py
@@ -0,0 +1,13 @@
+from django import template
+
+from core.models import UserAccessibilitySettings
+
+register = template.Library()
+
+
+@register.simple_tag
+def get_accessibility_settings(user):
+ if not getattr(user, "is_authenticated", False):
+ return None
+ row, _ = UserAccessibilitySettings.objects.get_or_create(user=user)
+ return row
diff --git a/core/views/ais.py b/core/views/ais.py
index 56f4a85..d634d9f 100644
--- a/core/views/ais.py
+++ b/core/views/ais.py
@@ -1,4 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin
+from django.urls import reverse
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from core.forms import AIForm
@@ -11,7 +12,7 @@ log = logs.get_logger(__name__)
class AIList(LoginRequiredMixin, ObjectList):
list_template = "partials/ai-list.html"
model = AI
- page_title = "AIs"
+ page_title = None
# page_subtitle = "Add times here in order to permit trading."
list_url_name = "ais"
@@ -19,6 +20,28 @@ class AIList(LoginRequiredMixin, ObjectList):
submit_url_name = "ai_create"
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ current_path = str(getattr(self.request, "path", "") or "")
+ models_path = reverse("ai_models")
+ traces_path = reverse("ai_execution_log")
+ context["settings_nav"] = {
+ "title": "AI",
+ "tabs": [
+ {
+ "label": "Models",
+ "href": models_path,
+ "active": current_path == models_path,
+ },
+ {
+ "label": "Traces",
+ "href": traces_path,
+ "active": current_path == traces_path,
+ },
+ ],
+ }
+ return context
+
class AICreate(LoginRequiredMixin, ObjectCreate):
model = AI
diff --git a/core/views/automation.py b/core/views/automation.py
index a4a2ed4..38c2ed8 100644
--- a/core/views/automation.py
+++ b/core/views/automation.py
@@ -482,12 +482,55 @@ class AIExecutionLogSettings(LoginRequiredMixin, View):
"runs": runs,
"operation_breakdown": operation_breakdown,
"model_breakdown": model_breakdown,
+ "settings_nav": {
+ "title": "AI",
+ "tabs": [
+ {
+ "label": "Models",
+ "href": reverse("ai_models"),
+ "active": str(getattr(request, "path", "") or "")
+ == reverse("ai_models"),
+ },
+ {
+ "label": "Traces",
+ "href": reverse("ai_execution_log"),
+ "active": str(getattr(request, "path", "") or "")
+ == reverse("ai_execution_log"),
+ },
+ ],
+ },
}
def get(self, request):
return render(request, self.template_name, self._context(request))
+class AIExecutionRunDetailView(LoginRequiredMixin, View):
+ template_name = "partials/ai-execution-run-detail.html"
+
+ def get(self, request, run_id):
+ run = get_object_or_404(AIRunLog, id=run_id, user=request.user)
+ return render(request, self.template_name, {"run": run})
+
+
+class AIExecutionRunDetailTabView(LoginRequiredMixin, View):
+ template_name = "partials/ai-execution-run-detail-tab.html"
+
+ def get(self, request, run_id, tab_slug):
+ run = get_object_or_404(AIRunLog, id=run_id, user=request.user)
+ slug = str(tab_slug or "").strip().lower()
+ if slug not in {"error"}:
+ return JsonResponse({"ok": False, "error": "unknown_tab"}, status=404)
+ return render(
+ request,
+ self.template_name,
+ {
+ "run": run,
+ "tab_slug": slug,
+ },
+ )
+
+
class BusinessPlanEditor(LoginRequiredMixin, View):
template_name = "pages/business-plan-editor.html"
diff --git a/core/views/system.py b/core/views/system.py
index 2176341..8fc776f 100644
--- a/core/views/system.py
+++ b/core/views/system.py
@@ -31,6 +31,7 @@ from core.models import (
PersonIdentifier,
QueuedMessage,
CommandSecurityPolicy,
+ UserAccessibilitySettings,
UserXmppOmemoState,
UserXmppSecuritySettings,
WorkspaceConversation,
@@ -489,6 +490,7 @@ class SecurityPage(LoginRequiredMixin, View):
"""Security settings page for OMEMO and command-scope policy controls."""
template_name = "pages/security.html"
+ page_mode = "encryption"
GLOBAL_SCOPE_KEY = "global.override"
# Allowed Services list used by both Global Scope Override and local scopes.
# Keep this in sync with the UI text on the Security page.
@@ -520,6 +522,18 @@ class SecurityPage(LoginRequiredMixin, View):
"other": "Other",
}
+ def _show_encryption(self) -> bool:
+ return str(getattr(self, "page_mode", "encryption")).strip().lower() in {
+ "encryption",
+ "all",
+ }
+
+ def _show_permission(self) -> bool:
+ return str(getattr(self, "page_mode", "encryption")).strip().lower() in {
+ "permission",
+ "all",
+ }
+
def _security_settings(self, request):
row, _ = UserXmppSecuritySettings.objects.get_or_create(user=request.user)
return row
@@ -616,6 +630,7 @@ class SecurityPage(LoginRequiredMixin, View):
def _scope_rows(self, request):
global_overrides = self._global_override_payload(request)["values"]
+ security_settings = self._security_settings(request)
rows = {
str(item.scope_key or "").strip().lower(): item
for item in CommandSecurityPolicy.objects.filter(user=request.user).exclude(
@@ -637,7 +652,10 @@ class SecurityPage(LoginRequiredMixin, View):
if not channel_rules:
channel_rules = [{"service": "xmpp", "pattern": ""}]
enabled_locked = global_overrides["scope_enabled"] != "per_scope"
- require_omemo_locked = global_overrides["require_omemo"] != "per_scope"
+ require_omemo_locked = (
+ global_overrides["require_omemo"] != "per_scope"
+ or bool(security_settings.require_omemo)
+ )
require_trusted_locked = (
global_overrides["require_trusted_fingerprint"] != "per_scope"
)
@@ -661,6 +679,11 @@ class SecurityPage(LoginRequiredMixin, View):
"require_omemo_locked": require_omemo_locked,
"require_trusted_fingerprint_locked": require_trusted_locked,
"lock_help": "Set this field to 'Per Scope' in Global Scope Override to edit it here.",
+ "require_omemo_lock_help": (
+ "Disable 'Require OMEMO encryption' in Encryption settings to edit this field."
+ if bool(security_settings.require_omemo)
+ else "Set this field to 'Per Scope' in Global Scope Override to edit it here."
+ ),
"allowed_services": raw_allowed_services,
"channel_rules": channel_rules,
})
@@ -668,6 +691,8 @@ class SecurityPage(LoginRequiredMixin, View):
def _scope_group_key(self, scope_key: str) -> str:
key = str(scope_key or "").strip().lower()
+ if key in {"tasks.commands", "gateway.tasks"}:
+ return "tasks"
if key in {"command.codex", "command.claude"}:
return "agentic"
if key.startswith("gateway."):
@@ -712,12 +737,17 @@ class SecurityPage(LoginRequiredMixin, View):
if "require_omemo" in request.POST:
row.require_omemo = _to_bool(request.POST.get("require_omemo"), False)
row.save(update_fields=["require_omemo", "updated_at"])
- redirect_to = HttpResponseRedirect(reverse("security_settings"))
+ redirect_to = HttpResponseRedirect(request.path)
scope_key = str(request.POST.get("scope_key") or "").strip().lower()
+ if not self._show_permission():
+ return redirect_to
if scope_key == self.GLOBAL_SCOPE_KEY:
global_row = self._global_override_payload(request)["row"]
+ security_settings = self._security_settings(request)
settings_payload = dict(global_row.settings or {})
for field in self.GLOBAL_OVERRIDE_FIELDS:
+ if field == "require_omemo" and bool(security_settings.require_omemo):
+ continue
settings_payload[field] = self._parse_override_value(
request.POST.get(f"global_{field}")
)
@@ -742,6 +772,7 @@ class SecurityPage(LoginRequiredMixin, View):
if str(request.POST.get("scope_change_mode") or "").strip() != "1":
return redirect_to
global_overrides = self._global_override_payload(request)["values"]
+ security_settings = self._security_settings(request)
allowed_services = [
str(item or "").strip().lower()
for item in request.POST.getlist("allowed_services")
@@ -756,7 +787,10 @@ class SecurityPage(LoginRequiredMixin, View):
policy.allowed_channels = allowed_channels
if global_overrides["scope_enabled"] == "per_scope":
policy.enabled = _to_bool(request.POST.get("policy_enabled"), True)
- if global_overrides["require_omemo"] == "per_scope":
+ if (
+ global_overrides["require_omemo"] == "per_scope"
+ and not bool(security_settings.require_omemo)
+ ):
policy.require_omemo = _to_bool(
request.POST.get("policy_require_omemo"), False
)
@@ -778,35 +812,41 @@ class SecurityPage(LoginRequiredMixin, View):
return redirect_to
def get(self, request):
- xmpp_state = transport.get_runtime_state("xmpp")
- try:
- omemo_row = UserXmppOmemoState.objects.get(user=request.user)
- except UserXmppOmemoState.DoesNotExist:
- omemo_row = None
+ show_encryption = self._show_encryption()
+ show_permission = self._show_permission()
+ xmpp_state = transport.get_runtime_state("xmpp") if show_encryption else {}
+ omemo_row = None
+ if show_encryption:
+ try:
+ omemo_row = UserXmppOmemoState.objects.get(user=request.user)
+ except UserXmppOmemoState.DoesNotExist:
+ omemo_row = None
security_settings = self._security_settings(request)
sender_jid = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "")
- omemo_plan = [
- {
- "label": "Component OMEMO active",
- "done": bool(xmpp_state.get("omemo_enabled")),
- "hint": "The gateway's OMEMO plugin must be loaded and initialised.",
- },
- {
- "label": "OMEMO observed from your client",
- "done": omemo_row is not None and omemo_row.status == "detected",
- "hint": "Send any message with OMEMO enabled in your XMPP client.",
- },
- {
- "label": "Client key on file",
- "done": bool(getattr(omemo_row, "latest_client_key", "")),
- "hint": "A device key (sid/rid) must be recorded from your client.",
- },
- {
- "label": "Encryption required",
- "done": security_settings.require_omemo,
- "hint": "Enable 'Require OMEMO encryption' in Security Policy above to enforce this policy.",
- },
- ]
+ omemo_plan = []
+ if show_encryption:
+ omemo_plan = [
+ {
+ "label": "Component OMEMO active",
+ "done": bool(xmpp_state.get("omemo_enabled")),
+ "hint": "The gateway's OMEMO plugin must be loaded and initialised.",
+ },
+ {
+ "label": "OMEMO observed from your client",
+ "done": omemo_row is not None and omemo_row.status == "detected",
+ "hint": "Send any message with OMEMO enabled in your XMPP client.",
+ },
+ {
+ "label": "Client key on file",
+ "done": bool(getattr(omemo_row, "latest_client_key", "")),
+ "hint": "A device key (sid/rid) must be recorded from your client.",
+ },
+ {
+ "label": "Encryption required",
+ "done": security_settings.require_omemo,
+ "hint": "Enable 'Require OMEMO encryption' in Security Policy above to enforce this policy.",
+ },
+ ]
return render(request, self.template_name, {
"xmpp_state": xmpp_state,
"omemo_row": omemo_row,
@@ -817,4 +857,62 @@ class SecurityPage(LoginRequiredMixin, View):
"policy_groups": self._grouped_scope_rows(request),
"sender_jid": sender_jid,
"omemo_plan": omemo_plan,
+ "show_encryption": show_encryption,
+ "show_permission": show_permission,
})
+
+
+class AccessibilitySettings(LoginRequiredMixin, View):
+ template_name = "pages/accessibility-settings.html"
+
+ def _row(self, request):
+ row, _ = UserAccessibilitySettings.objects.get_or_create(user=request.user)
+ return row
+
+ def get(self, request):
+ return render(request, self.template_name, {
+ "accessibility_settings": self._row(request),
+ })
+
+ def post(self, request):
+ row = self._row(request)
+ row.disable_animations = _to_bool(request.POST.get("disable_animations"), False)
+ row.save(update_fields=["disable_animations", "updated_at"])
+ return HttpResponseRedirect(reverse("accessibility_settings"))
+
+
+class _SettingsCategoryPage(LoginRequiredMixin, View):
+ template_name = "pages/settings-category.html"
+ category_key = "general"
+ category_title = "General"
+ category_description = ""
+ tabs = ()
+
+ def _tab_rows(self):
+ current_path = str(getattr(self.request, "path", "") or "")
+ rows = []
+ for label, href in self.tabs:
+ rows.append({
+ "label": label,
+ "href": href,
+ "active": current_path == href,
+ })
+ return rows
+
+ def get(self, request):
+ return render(request, self.template_name, {
+ "category_key": self.category_key,
+ "category_title": self.category_title,
+ "category_description": self.category_description,
+ "category_tabs": self._tab_rows(),
+ })
+
+
+class AISettingsPage(LoginRequiredMixin, View):
+ def get(self, request):
+ return HttpResponseRedirect(reverse("ai_models"))
+
+
+class ModulesSettingsPage(_SettingsCategoryPage):
+ def get(self, request):
+ return HttpResponseRedirect(reverse("command_routing"))