Improve settings hierarchy conciseness

This commit is contained in:
2026-03-07 16:32:24 +00:00
parent 611de57bf8
commit 10588a18b9
21 changed files with 846 additions and 80 deletions

View File

@@ -116,6 +116,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"core.util.django_settings_export.settings_export", "core.util.django_settings_export.settings_export",
"core.context_processors.settings_hierarchy_nav",
], ],
}, },
}, },

View File

@@ -17,8 +17,10 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.views import LogoutView from django.contrib.auth.views import LogoutView
from django.views.generic import RedirectView
from django.urls import include, path from django.urls import include, path
from two_factor.urls import urlpatterns as tf_urls from two_factor.urls import urlpatterns as tf_urls
from two_factor.views.profile import ProfileView
from core.views import ( from core.views import (
ais, ais,
@@ -49,21 +51,69 @@ urlpatterns = [
path("__debug__/", include("debug_toolbar.urls")), path("__debug__/", include("debug_toolbar.urls")),
path("", base.Home.as_view(), name="home"), path("", base.Home.as_view(), name="home"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path(
"account/two_factor/",
RedirectView.as_view(pattern_name="security_2fa", permanent=False),
),
# 2FA login urls # 2FA login urls
path("", include(tf_urls)), path("", include(tf_urls)),
path("accounts/signup/", base.Signup.as_view(), name="signup"), path("accounts/signup/", base.Signup.as_view(), name="signup"),
path("accounts/logout/", LogoutView.as_view(), name="logout"), path("accounts/logout/", LogoutView.as_view(), name="logout"),
# Notifications # Notifications
path(
"notifications/page/update/",
RedirectView.as_view(pattern_name="notifications_settings", permanent=False),
),
path( path(
"notifications/<str:type>/update/", "notifications/<str:type>/update/",
notifications.NotificationsUpdate.as_view(), notifications.NotificationsUpdate.as_view(),
name="notifications_update", name="notifications_update",
), ),
path(
"settings/notifications/",
notifications.NotificationsUpdate.as_view(),
{"type": "page"},
name="notifications_settings",
),
path( path(
"settings/security/", "settings/security/",
system.SecurityPage.as_view(), system.SecurityPage.as_view(page_mode="encryption"),
name="security_settings", 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( path(
"settings/system/", "settings/system/",
system.SystemSettings.as_view(), system.SystemSettings.as_view(),
@@ -115,10 +165,24 @@ urlpatterns = [
name="command_routing", name="command_routing",
), ),
path( path(
"settings/ai-execution/", "settings/ai/traces/",
automation.AIExecutionLogSettings.as_view(), automation.AIExecutionLogSettings.as_view(),
name="ai_execution_log", name="ai_execution_log",
), ),
path(
"settings/ai/traces/run/<int:run_id>/",
automation.AIExecutionRunDetailView.as_view(),
name="ai_execution_run_detail",
),
path(
"settings/ai/traces/run/<int:run_id>/tab/<str:tab_slug>/",
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( path(
"settings/translation/", "settings/translation/",
automation.TranslationSettings.as_view(), automation.TranslationSettings.as_view(),
@@ -500,6 +564,20 @@ urlpatterns = [
workspace.AIWorkspaceUpdatePlanMeta.as_view(), workspace.AIWorkspaceUpdatePlanMeta.as_view(),
name="ai_workspace_mitigation_meta_save", 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( path(
"ai/<str:type>/", "ai/<str:type>/",
ais.AIList.as_view(), ais.AIList.as_view(),

119
core/context_processors.py Normal file
View File

@@ -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}

View File

@@ -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,
),
),
],
),
]

View File

@@ -2925,6 +2925,17 @@ class UserXmppSecuritySettings(models.Model):
updated_at = models.DateTimeField(auto_now=True) 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): class TaskCompletionPattern(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_completion_patterns") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_completion_patterns")

View File

@@ -1,6 +1,7 @@
{% load static %} {% load static %}
{% load cache %} {% load cache %}
{% load page_title %} {% load page_title %}
{% load accessibility %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en-GB"> <html lang="en-GB">
@@ -290,10 +291,26 @@
.osint-search-form .button.is-fullwidth { .osint-search-form .button.is-fullwidth {
width: 100%; 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;
}
</style> </style>
</head> </head>
<body> {% get_accessibility_settings request.user as a11y_settings %}
<body{% if a11y_settings and a11y_settings.disable_animations %} class="reduced-motion"{% endif %}>
<nav class="navbar" role="navigation" aria-label="main navigation"> <nav class="navbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand"> <div class="navbar-brand">
@@ -343,6 +360,31 @@
<a class="navbar-item" href="{% url 'ai_workspace' %}"> <a class="navbar-item" href="{% url 'ai_workspace' %}">
AI AI
</a> </a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
Security
</a>
<div class="navbar-dropdown">
<a
class="navbar-item{% if request.resolver_match.url_name == 'encryption_settings' or request.resolver_match.url_name == 'security_settings' %} is-current-route{% endif %}"
href="{% url 'encryption_settings' %}"
>
Encryption
</a>
<a
class="navbar-item{% if request.resolver_match.url_name == 'permission_settings' %} is-current-route{% endif %}"
href="{% url 'permission_settings' %}"
>
Permission
</a>
<a
class="navbar-item{% if request.resolver_match.url_name == 'security_2fa' or request.resolver_match.namespace == 'two_factor' %} is-current-route{% endif %}"
href="{% url 'security_2fa' %}"
>
2FA
</a>
</div>
</div>
<a class="navbar-item" href="{% url 'osint_search' type='page' %}"> <a class="navbar-item" href="{% url 'osint_search' type='page' %}">
Search Search
</a> </a>
@@ -400,38 +442,47 @@
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="{% url 'security_settings' %}"> <div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
Security General
</a> </div>
<a class="navbar-item" href="{% url 'two_factor:profile' %}"> <a class="navbar-item{% if request.resolver_match.url_name == 'notifications_settings' or request.resolver_match.url_name == 'notifications_update' %} is-current-route{% endif %}" href="{% url 'notifications_settings' %}">
2FA
</a>
<a class="navbar-item" href="{% url 'notifications_update' type='page' %}">
Notifications Notifications
</a> </a>
<a class="navbar-item" href="{% url 'ais' type='page' %}">
Models
</a>
<a class="navbar-item" href="{% url 'command_routing' %}">
Command Routing
</a>
<a class="navbar-item" href="{% url 'tasks_settings' %}">
Task Settings
</a>
<a class="navbar-item" href="{% url 'availability_settings' %}">
Availability
</a>
<a class="navbar-item" href="{% url 'translation_settings' %}">
Translation
</a>
<a class="navbar-item" href="{% url 'ai_execution_log' %}">
AI Execution Log
</a>
{% if user.is_superuser %} {% if user.is_superuser %}
<a class="navbar-item" href="{% url 'system_settings' %}"> <a class="navbar-item{% if request.resolver_match.url_name == 'system_settings' %} is-current-route{% endif %}" href="{% url 'system_settings' %}">
System System
</a> </a>
{% endif %} {% endif %}
<hr class="navbar-divider">
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
AI
</div>
<a class="navbar-item{% if request.resolver_match.url_name == 'ai_models' or request.resolver_match.url_name == 'ais' %} is-current-route{% endif %}" href="{% url 'ai_models' %}">
Models
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'ai_execution_log' %} is-current-route{% endif %}" href="{% url 'ai_execution_log' %}">
Traces
</a>
<hr class="navbar-divider">
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
Modules
</div>
<a class="navbar-item{% if request.resolver_match.url_name == 'command_routing' %} is-current-route{% endif %}" href="{% url 'command_routing' %}">
Commands
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'tasks_settings' %} is-current-route{% endif %}" href="{% url 'tasks_settings' %}">
Tasks
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'translation_settings' %} is-current-route{% endif %}" href="{% url 'translation_settings' %}">
Translation
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'availability_settings' %} is-current-route{% endif %}" href="{% url 'availability_settings' %}">
Availability
</a>
<hr class="navbar-divider">
<a class="navbar-item{% if request.resolver_match.url_name == 'accessibility_settings' %} is-current-route{% endif %}" href="{% url 'accessibility_settings' %}">
Accessibility
</a>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -682,8 +733,9 @@
</script> </script>
{% block outer_content %} {% block outer_content %}
{% endblock %} {% endblock %}
<section class="section"> <div>
<div class="container"> <div class="container">
{% include "partials/settings-hierarchy-nav.html" %}
{% block content_wrapper %} {% block content_wrapper %}
{% block content %} {% block content %}
{% endblock %} {% endblock %}
@@ -695,6 +747,6 @@
<div id="widgets-here" style="display: none;"> <div id="widgets-here" style="display: none;">
</div> </div>
</div> </div>
</section> </div>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,61 @@
{% include 'mixins/partials/notify.html' %}
{% if page_title is not None %}
<h1 class="title is-4">{{ page_title }}</h1>
{% endif %}
{% if page_subtitle is not None %}
<h1 class="subtitle">{{ page_subtitle }}</h1>
{% endif %}
<div class="buttons">
{% if submit_url is not None %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ submit_url }}"
hx-trigger="click"
hx-target="#modals-here"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-plus"></i>
</span>
<span>{{ title_singular }}</span>
</span>
</button>
{% endif %}
{% if delete_all_url is not None %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{{ delete_all_url }}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to delete all {{ context_object_name }}?"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
<span>Delete all {{ context_object_name }} </span>
</span>
</button>
{% endif %}
{% for button in extra_buttons %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-{{ button.method }}="{{ button.url }}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
{% if button.confirm %}hx-confirm="Are you sure you wish to {{ button.action }}?"{% endif %}
class="button">
<span class="icon-text">
<span class="icon">
<i class="{{ button.icon }}"></i>
</span>
<span>{{ button.label }}</span>
</span>
</button>
{% endfor %}
</div>
{% include list_template %}

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Accessibility</h1>
<div class="box">
<h2 class="title is-6">Motion</h2>
<form method="post">
{% csrf_token %}
<div class="field">
<label class="checkbox">
<input type="checkbox" name="disable_animations"{% if accessibility_settings.disable_animations %} checked{% endif %}>
Disable animations
</label>
<p class="help is-size-7 has-text-grey mt-1">
Reduces motion by disabling most transitions and animations across the interface.
</p>
</div>
<button class="button is-link is-small" type="submit">Save</button>
</form>
</div>
</div>
</section>
{% endblock %}

View File

@@ -1,13 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<section class="section">
<div class="container">
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
<div> <div>
<h1 class="title is-4">AI Execution Log</h1> <h1 class="title is-4">Traces</h1>
<p class="subtitle is-6">Tracked model calls and usage metrics for this account.</p> <p class="subtitle is-6">Tracked model calls and usage metrics for this account.</p>
</div> </div>
</div> </div>
@@ -159,6 +157,7 @@
<table class="table is-fullwidth is-size-7 is-striped is-hoverable"> <table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead> <thead>
<tr> <tr>
<th></th>
<th>Started</th> <th>Started</th>
<th>Status</th> <th>Status</th>
<th>Operation</th> <th>Operation</th>
@@ -173,6 +172,22 @@
<tbody> <tbody>
{% for run in runs %} {% for run in runs %}
<tr> <tr>
<td>
<button
class="button is-small is-light trace-run-expand"
type="button"
data-detail-row="trace-run-detail-{{ run.id }}"
data-detail-content="trace-run-detail-content-{{ run.id }}"
data-expanded-label="Hide"
data-collapsed-label="Show"
hx-get="{% url 'ai_execution_run_detail' run_id=run.id %}"
hx-target="#trace-run-detail-content-{{ run.id }}"
hx-swap="innerHTML"
hx-trigger="click once"
>
Show
</button>
</td>
<td>{{ run.started_at }}</td> <td>{{ run.started_at }}</td>
<td> <td>
{% if run.status == "ok" %} {% if run.status == "ok" %}
@@ -197,14 +212,90 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr id="trace-run-detail-{{ run.id }}" class="is-hidden">
<td colspan="10">
<div id="trace-run-detail-content-{{ run.id }}" class="trace-run-detail-shell is-size-7 has-text-grey">
Click Show to load run details.
</div>
</td>
</tr>
{% empty %} {% empty %}
<tr><td colspan="9">No runs yet.</td></tr> <tr><td colspan="10">No runs yet.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</article> </article>
</div> <script>
</section> (function () {
document.querySelectorAll(".trace-run-expand").forEach(function (button) {
button.addEventListener("click", function () {
const rowId = String(button.getAttribute("data-detail-row") || "");
const row = rowId ? document.getElementById(rowId) : null;
if (!row) {
return;
}
const isHidden = row.classList.contains("is-hidden");
row.classList.toggle("is-hidden", !isHidden);
button.textContent = isHidden
? String(button.getAttribute("data-expanded-label") || "Hide")
: String(button.getAttribute("data-collapsed-label") || "Show");
});
});
document.addEventListener("click", function (event) {
const trigger = event.target.closest(".trace-run-tab-trigger");
if (!trigger) {
return;
}
event.preventDefault();
const shell = trigger.closest(".trace-run-detail-tabs");
if (!shell) {
return;
}
const targetName = String(trigger.getAttribute("data-tab-target") || "");
if (!targetName) {
return;
}
shell.querySelectorAll(".trace-run-tab-trigger").forEach(function (item) {
item.parentElement.classList.toggle("is-active", item === trigger);
});
shell.querySelectorAll(".trace-run-tab-panel").forEach(function (panel) {
const isActive = panel.getAttribute("data-tab-panel") === targetName;
panel.classList.toggle("is-hidden", !isActive);
});
const lazyUrl = String(trigger.getAttribute("data-lazy-url") || "");
if (!lazyUrl) {
return;
}
const panel = shell.querySelector(
'.trace-run-tab-panel[data-tab-panel="' + targetName + '"]'
);
if (!panel || panel.getAttribute("data-loaded") === "1") {
return;
}
panel.setAttribute("data-loaded", "1");
panel.classList.add("is-loading");
fetch(lazyUrl, { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("tab load failed");
}
return response.text();
})
.then(function (html) {
panel.innerHTML = html;
})
.catch(function () {
panel.innerHTML =
'<p class="has-text-danger">Unable to load this tab.</p>';
})
.finally(function () {
panel.classList.remove("is-loading");
});
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -3,8 +3,7 @@
{% block content %} {% block content %}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<h1 class="title is-4">Security</h1> {% if show_encryption %}
<div class="columns is-desktop is-variable is-8"> <div class="columns is-desktop is-variable is-8">
<div class="column"> <div class="column">
<div class="box"> <div class="box">
@@ -102,7 +101,9 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
{% if show_permission %}
<div class="box"> <div class="box">
<h2 class="title is-6">Global Scope Override</h2> <h2 class="title is-6">Global Scope Override</h2>
<p class="is-size-7 has-text-grey mb-3"> <p class="is-size-7 has-text-grey mb-3">
@@ -194,14 +195,24 @@
<label class="label is-size-7 mb-1">Require OMEMO</label> <label class="label is-size-7 mb-1">Require OMEMO</label>
<div class="is-flex is-align-items-center" style="gap:0.5rem;"> <div class="is-flex is-align-items-center" style="gap:0.5rem;">
<span class="tag is-size-7" data-global-mode-label="require_omemo"></span> <span class="tag is-size-7" data-global-mode-label="require_omemo"></span>
<button type="button" class="button is-small is-light" data-global-change-toggle="require_omemo">Change Global</button> <button
type="button"
class="button is-small is-light"
data-global-change-toggle="require_omemo"
{% if security_settings.require_omemo %}disabled title="Disable 'Require OMEMO encryption' in Encryption settings to edit this override."{% endif %}
>
Change Global
</button>
</div> </div>
</div> </div>
<div class="buttons has-addons is-hidden" data-global-mode-picker="require_omemo"> <div class="buttons has-addons is-hidden" data-global-mode-picker="require_omemo">
<button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="per_scope">Per Scope</button> <button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="per_scope"{% if security_settings.require_omemo %} disabled{% endif %}>Per Scope</button>
<button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="on">Force On</button> <button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="on"{% if security_settings.require_omemo %} disabled{% endif %}>Force On</button>
<button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="off">Force Off</button> <button class="button is-small" type="button" data-global-mode-set="require_omemo" data-mode="off"{% if security_settings.require_omemo %} disabled{% endif %}>Force Off</button>
</div> </div>
{% if security_settings.require_omemo %}
<p class="help is-size-7 has-text-grey">Locked by Encryption setting: disable "Require OMEMO encryption" to edit this override.</p>
{% endif %}
</div> </div>
<div class="field mb-3"> <div class="field mb-3">
<div class="is-flex is-justify-content-space-between is-align-items-center"> <div class="is-flex is-justify-content-space-between is-align-items-center">
@@ -263,7 +274,7 @@
</div> </div>
<div class="box"> <div class="box">
<h2 class="title is-6">Command Security Scopes</h2> <h2 class="title is-6">Fine-Grained Security Scopes</h2>
<p class="is-size-7 has-text-grey mb-3"> <p class="is-size-7 has-text-grey mb-3">
Choose a top-level category, expand a scope, then click <strong>Change</strong> to edit that scope. Choose a top-level category, expand a scope, then click <strong>Change</strong> to edit that scope.
</p> </p>
@@ -319,7 +330,7 @@
<input class="scope-editable" data-lock-state="{% if row.enabled_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_enabled"{% if row.enabled %} checked{% endif %}{% if row.enabled_locked %} disabled{% endif %}> <input class="scope-editable" data-lock-state="{% if row.enabled_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_enabled"{% if row.enabled %} checked{% endif %}{% if row.enabled_locked %} disabled{% endif %}>
Scope Enabled Scope Enabled
</label> </label>
<label class="checkbox mr-4" title="{% if row.require_omemo_locked %}{{ row.lock_help }}{% endif %}"> <label class="checkbox mr-4" title="{% if row.require_omemo_locked %}{{ row.require_omemo_lock_help }}{% endif %}">
<input class="scope-editable" data-lock-state="{% if row.require_omemo_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_require_omemo"{% if row.require_omemo %} checked{% endif %}{% if row.require_omemo_locked %} disabled{% endif %}> <input class="scope-editable" data-lock-state="{% if row.require_omemo_locked %}locked{% else %}free{% endif %}" type="checkbox" name="policy_require_omemo"{% if row.require_omemo %} checked{% endif %}{% if row.require_omemo_locked %} disabled{% endif %}>
Require OMEMO Require OMEMO
</label> </label>
@@ -380,7 +391,9 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
{% if show_encryption %}
<div class="box"> <div class="box">
<h2 class="title is-6">OMEMO Enablement Plan</h2> <h2 class="title is-6">OMEMO Enablement Plan</h2>
<p class="is-size-7 has-text-grey mb-3">Complete each step to achieve end-to-end encrypted messaging with the gateway.</p> <p class="is-size-7 has-text-grey mb-3">Complete each step to achieve end-to-end encrypted messaging with the gateway.</p>
@@ -402,6 +415,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% endif %}
</div> </div>
</section> </section>

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">{{ category_title }}</h1>
<p class="subtitle is-6">{{ category_description }}</p>
<div class="tabs is-boxed is-small mb-4 security-page-tabs">
<ul>
{% for tab in category_tabs %}
<li class="{% if tab.active %}is-active{% endif %}">
<a href="{{ tab.href }}">{{ tab.label }}</a>
</li>
{% endfor %}
</ul>
</div>
<div class="box">
<p class="is-size-7 has-text-grey">Choose a tab above to open settings in this category.</p>
</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% if tab_slug == "error" %}
{% if run.error %}
<pre style="white-space: pre-wrap; margin: 0;">{{ run.error }}</pre>
{% else %}
<p class="has-text-grey">No error recorded for this run.</p>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,56 @@
<div class="trace-run-detail-tabs">
<div class="tabs is-small is-toggle is-toggle-rounded mb-2">
<ul>
<li class="is-active">
<a href="#" class="trace-run-tab-trigger" data-tab-target="summary">Summary</a>
</li>
<li>
<a href="#" class="trace-run-tab-trigger" data-tab-target="identifiers">Identifiers</a>
</li>
<li>
<a href="#" class="trace-run-tab-trigger" data-tab-target="usage">Usage</a>
</li>
<li>
<a
href="#"
class="trace-run-tab-trigger"
data-tab-target="error"
data-lazy-url="{% url 'ai_execution_run_detail_tab' run_id=run.id tab_slug='error' %}"
>Error</a>
</li>
</ul>
</div>
<div class="trace-run-tab-panel" data-tab-panel="summary">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Status</th><td>{{ run.status|default:"-" }}</td></tr>
<tr><th>Operation</th><td>{{ run.operation|default:"-" }}</td></tr>
<tr><th>Model</th><td>{{ run.model|default:"-" }}</td></tr>
<tr><th>Started</th><td>{{ run.started_at|default:"-" }}</td></tr>
<tr><th>Finished</th><td>{{ run.finished_at|default:"-" }}</td></tr>
<tr><th>Duration (ms)</th><td>{{ run.duration_ms|default:"-" }}</td></tr>
</tbody>
</table>
</div>
<div class="trace-run-tab-panel is-hidden" data-tab-panel="identifiers">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Run ID</th><td>{{ run.id }}</td></tr>
<tr><th>AI ID</th><td>{% if run.ai_id %}{{ run.ai_id }}{% else %}-{% endif %}</td></tr>
<tr><th>Base URL</th><td><code>{{ run.base_url|default:"-" }}</code></td></tr>
</tbody>
</table>
</div>
<div class="trace-run-tab-panel is-hidden" data-tab-panel="usage">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Message Count</th><td>{{ run.message_count }}</td></tr>
<tr><th>Prompt Chars</th><td>{{ run.prompt_chars }}</td></tr>
<tr><th>Response Chars</th><td>{{ run.response_chars }}</td></tr>
</tbody>
</table>
</div>
<div class="trace-run-tab-panel is-hidden" data-tab-panel="error" data-loaded="0">
<p class="has-text-grey">Click the Error tab to load details.</p>
</div>
</div>

View File

@@ -0,0 +1,12 @@
{% if settings_nav %}
<h1 class="title is-4">{{ settings_nav.title }}</h1>
<div class="tabs is-boxed is-small mb-4 security-page-tabs">
<ul>
{% for tab in settings_nav.tabs %}
<li class="{% if tab.active %}is-active{% endif %}">
<a href="{{ tab.href }}">{{ tab.label }}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@@ -21,7 +21,7 @@
{% endif %} {% endif %}
<form method="post">{% csrf_token %}{{ form }} <form method="post">{% csrf_token %}{{ form }}
<a href="{% url 'two_factor:profile'%}" <a href="{% url 'security_2fa' %}"
class="float-right button">{% trans "Back to Account Security" %}</a> class="float-right button">{% trans "Back to Account Security" %}</a>
<button class="button" type="submit">{% trans "Generate Tokens" %}</button> <button class="button" type="submit">{% trans "Generate Tokens" %}</button>
</form> </form>

View File

@@ -8,14 +8,14 @@
authentication.{% endblocktrans %}</p> authentication.{% endblocktrans %}</p>
{% if not phone_methods %} {% if not phone_methods %}
<p class="subtitle"><a href="{% url 'two_factor:profile' %}" <p class="subtitle"><a href="{% url 'security_2fa' %}"
class="button">{% trans "Back to Account Security" %}</a></p> class="button">{% trans "Back to Account Security" %}</a></p>
{% else %} {% else %}
<p class="subtitle">{% blocktrans trimmed %}However, it might happen that you don't have access to <p class="subtitle">{% blocktrans trimmed %}However, it might happen that you don't have access to
your primary token device. To enable account recovery, add a phone your primary token device. To enable account recovery, add a phone
number.{% endblocktrans %}</p> number.{% endblocktrans %}</p>
<a href="{% url 'two_factor:profile' %}" <a href="{% url 'security_2fa' %}"
class="float-right button">{% trans "Back to Account Security" %}</a> class="float-right button">{% trans "Back to Account Security" %}</a>
<p class="subtitle"><a href="{% url 'two_factor:phone_create' %}" <p class="subtitle"><a href="{% url 'two_factor:phone_create' %}"
class="button">{% trans "Add Phone Number" %}</a></p> class="button">{% trans "Add Phone Number" %}</a></p>

View File

@@ -2,7 +2,7 @@
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
<h1 class="title">{% block title %}{% trans "Account Security" %}{% endblock %}</h1> <h2 class="title is-5">{% block title %}{% trans "Account Security" %}{% endblock %}</h2>
{% if default_device %} {% if default_device %}
{% if default_device_type == 'TOTPDevice' %} {% if default_device_type == 'TOTPDevice' %}

View File

@@ -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

View File

@@ -1,4 +1,5 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from core.forms import AIForm from core.forms import AIForm
@@ -11,7 +12,7 @@ log = logs.get_logger(__name__)
class AIList(LoginRequiredMixin, ObjectList): class AIList(LoginRequiredMixin, ObjectList):
list_template = "partials/ai-list.html" list_template = "partials/ai-list.html"
model = AI model = AI
page_title = "AIs" page_title = None
# page_subtitle = "Add times here in order to permit trading." # page_subtitle = "Add times here in order to permit trading."
list_url_name = "ais" list_url_name = "ais"
@@ -19,6 +20,28 @@ class AIList(LoginRequiredMixin, ObjectList):
submit_url_name = "ai_create" 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): class AICreate(LoginRequiredMixin, ObjectCreate):
model = AI model = AI

View File

@@ -482,12 +482,55 @@ class AIExecutionLogSettings(LoginRequiredMixin, View):
"runs": runs, "runs": runs,
"operation_breakdown": operation_breakdown, "operation_breakdown": operation_breakdown,
"model_breakdown": model_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): def get(self, request):
return render(request, self.template_name, self._context(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): class BusinessPlanEditor(LoginRequiredMixin, View):
template_name = "pages/business-plan-editor.html" template_name = "pages/business-plan-editor.html"

View File

@@ -31,6 +31,7 @@ from core.models import (
PersonIdentifier, PersonIdentifier,
QueuedMessage, QueuedMessage,
CommandSecurityPolicy, CommandSecurityPolicy,
UserAccessibilitySettings,
UserXmppOmemoState, UserXmppOmemoState,
UserXmppSecuritySettings, UserXmppSecuritySettings,
WorkspaceConversation, WorkspaceConversation,
@@ -489,6 +490,7 @@ class SecurityPage(LoginRequiredMixin, View):
"""Security settings page for OMEMO and command-scope policy controls.""" """Security settings page for OMEMO and command-scope policy controls."""
template_name = "pages/security.html" template_name = "pages/security.html"
page_mode = "encryption"
GLOBAL_SCOPE_KEY = "global.override" GLOBAL_SCOPE_KEY = "global.override"
# Allowed Services list used by both Global Scope Override and local scopes. # Allowed Services list used by both Global Scope Override and local scopes.
# Keep this in sync with the UI text on the Security page. # Keep this in sync with the UI text on the Security page.
@@ -520,6 +522,18 @@ class SecurityPage(LoginRequiredMixin, View):
"other": "Other", "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): def _security_settings(self, request):
row, _ = UserXmppSecuritySettings.objects.get_or_create(user=request.user) row, _ = UserXmppSecuritySettings.objects.get_or_create(user=request.user)
return row return row
@@ -616,6 +630,7 @@ class SecurityPage(LoginRequiredMixin, View):
def _scope_rows(self, request): def _scope_rows(self, request):
global_overrides = self._global_override_payload(request)["values"] global_overrides = self._global_override_payload(request)["values"]
security_settings = self._security_settings(request)
rows = { rows = {
str(item.scope_key or "").strip().lower(): item str(item.scope_key or "").strip().lower(): item
for item in CommandSecurityPolicy.objects.filter(user=request.user).exclude( for item in CommandSecurityPolicy.objects.filter(user=request.user).exclude(
@@ -637,7 +652,10 @@ class SecurityPage(LoginRequiredMixin, View):
if not channel_rules: if not channel_rules:
channel_rules = [{"service": "xmpp", "pattern": ""}] channel_rules = [{"service": "xmpp", "pattern": ""}]
enabled_locked = global_overrides["scope_enabled"] != "per_scope" 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 = ( require_trusted_locked = (
global_overrides["require_trusted_fingerprint"] != "per_scope" global_overrides["require_trusted_fingerprint"] != "per_scope"
) )
@@ -661,6 +679,11 @@ class SecurityPage(LoginRequiredMixin, View):
"require_omemo_locked": require_omemo_locked, "require_omemo_locked": require_omemo_locked,
"require_trusted_fingerprint_locked": require_trusted_locked, "require_trusted_fingerprint_locked": require_trusted_locked,
"lock_help": "Set this field to 'Per Scope' in Global Scope Override to edit it here.", "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, "allowed_services": raw_allowed_services,
"channel_rules": channel_rules, "channel_rules": channel_rules,
}) })
@@ -668,6 +691,8 @@ class SecurityPage(LoginRequiredMixin, View):
def _scope_group_key(self, scope_key: str) -> str: def _scope_group_key(self, scope_key: str) -> str:
key = str(scope_key or "").strip().lower() key = str(scope_key or "").strip().lower()
if key in {"tasks.commands", "gateway.tasks"}:
return "tasks"
if key in {"command.codex", "command.claude"}: if key in {"command.codex", "command.claude"}:
return "agentic" return "agentic"
if key.startswith("gateway."): if key.startswith("gateway."):
@@ -712,12 +737,17 @@ class SecurityPage(LoginRequiredMixin, View):
if "require_omemo" in request.POST: if "require_omemo" in request.POST:
row.require_omemo = _to_bool(request.POST.get("require_omemo"), False) row.require_omemo = _to_bool(request.POST.get("require_omemo"), False)
row.save(update_fields=["require_omemo", "updated_at"]) 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() 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: if scope_key == self.GLOBAL_SCOPE_KEY:
global_row = self._global_override_payload(request)["row"] global_row = self._global_override_payload(request)["row"]
security_settings = self._security_settings(request)
settings_payload = dict(global_row.settings or {}) settings_payload = dict(global_row.settings or {})
for field in self.GLOBAL_OVERRIDE_FIELDS: 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( settings_payload[field] = self._parse_override_value(
request.POST.get(f"global_{field}") 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": if str(request.POST.get("scope_change_mode") or "").strip() != "1":
return redirect_to return redirect_to
global_overrides = self._global_override_payload(request)["values"] global_overrides = self._global_override_payload(request)["values"]
security_settings = self._security_settings(request)
allowed_services = [ allowed_services = [
str(item or "").strip().lower() str(item or "").strip().lower()
for item in request.POST.getlist("allowed_services") for item in request.POST.getlist("allowed_services")
@@ -756,7 +787,10 @@ class SecurityPage(LoginRequiredMixin, View):
policy.allowed_channels = allowed_channels policy.allowed_channels = allowed_channels
if global_overrides["scope_enabled"] == "per_scope": if global_overrides["scope_enabled"] == "per_scope":
policy.enabled = _to_bool(request.POST.get("policy_enabled"), True) 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( policy.require_omemo = _to_bool(
request.POST.get("policy_require_omemo"), False request.POST.get("policy_require_omemo"), False
) )
@@ -778,35 +812,41 @@ class SecurityPage(LoginRequiredMixin, View):
return redirect_to return redirect_to
def get(self, request): def get(self, request):
xmpp_state = transport.get_runtime_state("xmpp") show_encryption = self._show_encryption()
try: show_permission = self._show_permission()
omemo_row = UserXmppOmemoState.objects.get(user=request.user) xmpp_state = transport.get_runtime_state("xmpp") if show_encryption else {}
except UserXmppOmemoState.DoesNotExist: omemo_row = None
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) security_settings = self._security_settings(request)
sender_jid = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "") sender_jid = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "")
omemo_plan = [ omemo_plan = []
{ if show_encryption:
"label": "Component OMEMO active", omemo_plan = [
"done": bool(xmpp_state.get("omemo_enabled")), {
"hint": "The gateway's OMEMO plugin must be loaded and initialised.", "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": "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": "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.", "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, { return render(request, self.template_name, {
"xmpp_state": xmpp_state, "xmpp_state": xmpp_state,
"omemo_row": omemo_row, "omemo_row": omemo_row,
@@ -817,4 +857,62 @@ class SecurityPage(LoginRequiredMixin, View):
"policy_groups": self._grouped_scope_rows(request), "policy_groups": self._grouped_scope_rows(request),
"sender_jid": sender_jid, "sender_jid": sender_jid,
"omemo_plan": omemo_plan, "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"))