stkit 17.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stk/__init__.py +19 -0
- stk/__main__.py +6 -0
- stk/agent_login.py +78 -0
- stk/app.py +323 -0
- stk/checks.py +634 -0
- stk/cli/__init__.py +49 -0
- stk/cli/agent.py +267 -0
- stk/cli/base.py +79 -0
- stk/cli/database.py +199 -0
- stk/cli/develop.py +63 -0
- stk/cli/doctor.py +262 -0
- stk/cli/main.py +101 -0
- stk/cli/reports.py +296 -0
- stk/cli/smoke.py +399 -0
- stk/cli/users.py +259 -0
- stk/cli/watch.py +68 -0
- stk/extensions.py +29 -0
- stk/migrations/__init__.py +130 -0
- stk/migrations/env.py +58 -0
- stk/migrations/script.py.mako +17 -0
- stk/migrations/versions/stk_0001_initial_schema.py +213 -0
- stk/portal/views.py +34 -0
- stk/public/__init__.py +0 -0
- stk/public/models.py +0 -0
- stk/public/views.py +344 -0
- stk/scaffold/__init__.py +1 -0
- stk/scaffold/app/CLAUDE.md +65 -0
- stk/scaffold/app/Dockerfile +41 -0
- stk/scaffold/app/README.md +16 -0
- stk/scaffold/app/__app__/__init__.py +1 -0
- stk/scaffold/app/__app__/app.py +8 -0
- stk/scaffold/app/__app__/settings.py +7 -0
- stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
- stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
- stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
- stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
- stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
- stk/scaffold/app/docker-compose.yml +88 -0
- stk/scaffold/app/dockerignore +13 -0
- stk/scaffold/app/env-sample +78 -0
- stk/scaffold/app/gitignore +8 -0
- stk/scaffold/app/nginx/nginx.conf +46 -0
- stk/scaffold/app/nginx/stk.conf +36 -0
- stk/scaffold/app/postgres/init-db.sh +8 -0
- stk/scaffold/app/pyproject.toml +23 -0
- stk/scaffold/app/tests/__init__.py +0 -0
- stk/scaffold/app/tests/test_app.py +23 -0
- stk/scaffold/generator.py +209 -0
- stk/scaffold/templates.py +437 -0
- stk/security.py +145 -0
- stk/settings.py +147 -0
- stk/shell.py +284 -0
- stk/static/VERSIONS.txt +4 -0
- stk/static/css/app.css +229 -0
- stk/static/css/layout.css +330 -0
- stk/static/css/vuetify.min.css +6 -0
- stk/static/icons/fonts/tabler-icons.woff2 +0 -0
- stk/static/icons/tabler-icons.min.css +4 -0
- stk/static/img/auth-bg.webp +0 -0
- stk/static/img/favicon.ico +0 -0
- stk/static/img/stk.svg +1664 -0
- stk/static/js/axios.min.js +5 -0
- stk/static/js/components/NotificationDropdown.js +156 -0
- stk/static/js/components/ThemeSwitcher.js +68 -0
- stk/static/js/components/TransitionExpand.js +64 -0
- stk/static/js/components/VerticalNavGroup.js +119 -0
- stk/static/js/components/VerticalNavLink.js +36 -0
- stk/static/js/components/VerticalNavSectionTitle.js +21 -0
- stk/static/js/components/index.js +47 -0
- stk/static/js/config.js +150 -0
- stk/static/js/vue.min.js +13 -0
- stk/static/js/vuetify.min.js +3148 -0
- stk/static/robots.txt +1 -0
- stk/tasks/__init__.py +78 -0
- stk/templates/401.html +14 -0
- stk/templates/404.html +12 -0
- stk/templates/500.html +13 -0
- stk/templates/_brand.html +1 -0
- stk/templates/auth_layout.html +26 -0
- stk/templates/cms/activities.html +135 -0
- stk/templates/cms/roles.html +215 -0
- stk/templates/cms/users.html +312 -0
- stk/templates/dashboard.html +132 -0
- stk/templates/index.html +52 -0
- stk/templates/layout.html +328 -0
- stk/templates/login-layout.html +54 -0
- stk/templates/security/_macros.html +39 -0
- stk/templates/security/_messages.html +12 -0
- stk/templates/security/change_password.html +100 -0
- stk/templates/security/confirm_email.html +44 -0
- stk/templates/security/email/change_notice.html +4 -0
- stk/templates/security/email/change_notice.txt +5 -0
- stk/templates/security/email/confirmation_instructions.html +3 -0
- stk/templates/security/email/confirmation_instructions.txt +3 -0
- stk/templates/security/email/login_instructions.html +5 -0
- stk/templates/security/email/login_instructions.txt +5 -0
- stk/templates/security/email/reset_instructions.html +1 -0
- stk/templates/security/email/reset_instructions.txt +3 -0
- stk/templates/security/email/reset_notice.html +1 -0
- stk/templates/security/email/reset_notice.txt +1 -0
- stk/templates/security/email/welcome.html +7 -0
- stk/templates/security/email/welcome.txt +7 -0
- stk/templates/security/forgot_password.html +63 -0
- stk/templates/security/login_user.html +206 -0
- stk/templates/security/mf_recovery.html +67 -0
- stk/templates/security/mf_recovery_codes.html +119 -0
- stk/templates/security/register_user.html +263 -0
- stk/templates/security/reset_password.html +53 -0
- stk/templates/security/send_confirmation.html +48 -0
- stk/templates/security/send_login.html +39 -0
- stk/templates/security/two_factor_select.html +78 -0
- stk/templates/security/two_factor_setup.html +166 -0
- stk/templates/security/two_factor_verify_code.html +71 -0
- stk/templates/security/wan_register.html +179 -0
- stk/templates/security/wan_signin.html +123 -0
- stk/templates/security/wan_verify.html +109 -0
- stk/user/__init__.py +0 -0
- stk/user/forms.py +10 -0
- stk/user/models.py +308 -0
- stk/user/views.py +339 -0
- stk/utils/email.py +32 -0
- stk/utils/ratelimit.py +67 -0
- stk/websocket.py +133 -0
- stkit-17.0.0.dist-info/METADATA +135 -0
- stkit-17.0.0.dist-info/RECORD +128 -0
- stkit-17.0.0.dist-info/WHEEL +4 -0
- stkit-17.0.0.dist-info/entry_points.txt +2 -0
- stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
stk Framework
|
|
3
|
+
A modern Python web framework optimized for AI-assisted development.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "17.0.0"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def __getattr__(name):
|
|
10
|
+
# Lazy, so `import stk` loads no app code until a name is used.
|
|
11
|
+
if name == "Stk":
|
|
12
|
+
from stk.app import Stk
|
|
13
|
+
|
|
14
|
+
return Stk
|
|
15
|
+
if name in ("Config", "TestingConfig"):
|
|
16
|
+
from stk import settings
|
|
17
|
+
|
|
18
|
+
return getattr(settings, name)
|
|
19
|
+
raise AttributeError(f"module 'stk' has no attribute {name!r}")
|
stk/__main__.py
ADDED
stk/agent_login.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
|
2
|
+
from quart import Blueprint, current_app, g, redirect, request
|
|
3
|
+
from quart_security.proxies import _security
|
|
4
|
+
from sqlalchemy import select
|
|
5
|
+
|
|
6
|
+
from stk.user.models import User
|
|
7
|
+
|
|
8
|
+
bp_agent_login = Blueprint("agent_login", __name__, url_prefix="/_test")
|
|
9
|
+
TOKEN_SALT = "stk-agent-login"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def agent_login_enabled(app):
|
|
13
|
+
enabled = app.config.get("STK_ENABLE_AGENT_LOGIN", False)
|
|
14
|
+
if not enabled:
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
is_safe_env = app.testing or app.config.get("STK_ENV") == "development"
|
|
18
|
+
if not is_safe_env:
|
|
19
|
+
raise RuntimeError("agent login cannot be enabled outside development/testing")
|
|
20
|
+
|
|
21
|
+
if not app.config.get("SECRET_KEY"):
|
|
22
|
+
raise RuntimeError("agent login cannot be enabled without SECRET_KEY")
|
|
23
|
+
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _serializer():
|
|
28
|
+
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validate_local_next(next_path):
|
|
32
|
+
if not next_path.startswith("/") or next_path.startswith("//"):
|
|
33
|
+
raise ValueError("next path must be local")
|
|
34
|
+
return next_path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_agent_login_token(email, next_path="/dashboard/"):
|
|
38
|
+
return _serializer().dumps(
|
|
39
|
+
{"email": email, "next": _validate_local_next(next_path)},
|
|
40
|
+
salt=TOKEN_SALT,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_agent_login_token(token, max_age):
|
|
45
|
+
try:
|
|
46
|
+
return _serializer().loads(token, salt=TOKEN_SALT, max_age=max_age)
|
|
47
|
+
except SignatureExpired as exc:
|
|
48
|
+
raise ValueError("agent login token expired") from exc
|
|
49
|
+
except BadSignature as exc:
|
|
50
|
+
raise ValueError("invalid agent login token") from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@bp_agent_login.get("/login")
|
|
54
|
+
async def agent_login():
|
|
55
|
+
token = request.args.get("token")
|
|
56
|
+
if not token:
|
|
57
|
+
return "", 400
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
payload = read_agent_login_token(
|
|
61
|
+
token,
|
|
62
|
+
max_age=current_app.config["STK_AGENT_LOGIN_MAX_TTL_SECONDS"],
|
|
63
|
+
)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return "", 400
|
|
66
|
+
|
|
67
|
+
email = payload["email"]
|
|
68
|
+
if not email.endswith("@example.com"):
|
|
69
|
+
return "", 403
|
|
70
|
+
|
|
71
|
+
user = (
|
|
72
|
+
await g.db_session.execute(select(User).where(User.email == email))
|
|
73
|
+
).scalar_one_or_none()
|
|
74
|
+
if user is None or not user.active:
|
|
75
|
+
return "", 403
|
|
76
|
+
|
|
77
|
+
await _security.login_user(user)
|
|
78
|
+
return redirect(payload["next"])
|
stk/app.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib
|
|
5
|
+
import importlib.util
|
|
6
|
+
import inspect
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from jinja2 import ChoiceLoader, FileSystemLoader, PrefixLoader
|
|
11
|
+
from quart import Quart, g, render_template, request, send_from_directory
|
|
12
|
+
from quart_security import Security, SQLAlchemyUserDatastore
|
|
13
|
+
from quart_security.views import _ensure_csrf_token
|
|
14
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
15
|
+
from werkzeug.exceptions import HTTPException, NotFound
|
|
16
|
+
from werkzeug.security import safe_join
|
|
17
|
+
|
|
18
|
+
import stk.extensions as ext
|
|
19
|
+
from stk.agent_login import agent_login_enabled, bp_agent_login
|
|
20
|
+
from stk.extensions import session
|
|
21
|
+
from stk.portal.views import portal
|
|
22
|
+
from stk.public.views import public
|
|
23
|
+
from stk.settings import Config
|
|
24
|
+
from stk.user.forms import ExtendedRegisterForm, OAuthAwareChangePasswordForm
|
|
25
|
+
from stk.user.models import Role, User, WebAuthn
|
|
26
|
+
from stk.user.views import bp_user
|
|
27
|
+
from stk.websocket import ws_bp
|
|
28
|
+
|
|
29
|
+
CORE_TEMPLATES = Path(__file__).parent / "templates"
|
|
30
|
+
CORE_STATIC = Path(__file__).parent / "static"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Stk(Quart):
|
|
34
|
+
"""A Quart app with stk's core, then the app's extensions in list order.
|
|
35
|
+
|
|
36
|
+
Each extension is a package exposing `init_app(app)`; its `models` module, if
|
|
37
|
+
any, is imported first so migrations see its tables. Templates and static
|
|
38
|
+
files resolve app first, then each extension's, then stk's. Core templates
|
|
39
|
+
stay reachable as `stk/<name>`, so an override can extend the original.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, import_name, *, config=Config, extensions=()):
|
|
43
|
+
super().__init__(
|
|
44
|
+
import_name, static_folder=str(CORE_STATIC), template_folder=None
|
|
45
|
+
)
|
|
46
|
+
self.config.from_object(config)
|
|
47
|
+
for key in ("SECRET_KEY", "SECURITY_PASSWORD_SALT"):
|
|
48
|
+
if not self.config.get(key):
|
|
49
|
+
raise ValueError(f"{key} is required: set it in .env")
|
|
50
|
+
if not self.config.get("SQLALCHEMY_DATABASE_URI"):
|
|
51
|
+
os.makedirs(self.instance_path, exist_ok=True)
|
|
52
|
+
database = Path(self.instance_path) / "stk.db"
|
|
53
|
+
self.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite+aiosqlite:///{database}"
|
|
54
|
+
|
|
55
|
+
roots = [Path(self.root_path), *(Path(m.__path__[0]) for m in extensions)]
|
|
56
|
+
self.template_folders = [
|
|
57
|
+
*(path for root in roots if (path := root / "templates").is_dir()),
|
|
58
|
+
CORE_TEMPLATES,
|
|
59
|
+
]
|
|
60
|
+
self.static_folders = [
|
|
61
|
+
*(path for root in roots if (path := root / "static").is_dir()),
|
|
62
|
+
CORE_STATIC,
|
|
63
|
+
]
|
|
64
|
+
# Sidebar pages the app adds; the layout places them under Dashboard.
|
|
65
|
+
self.navigation = []
|
|
66
|
+
|
|
67
|
+
register_blueprints(self)
|
|
68
|
+
register_extensions(self)
|
|
69
|
+
register_errorhandlers(self)
|
|
70
|
+
register_static_versioning(self)
|
|
71
|
+
register_navigation(self)
|
|
72
|
+
|
|
73
|
+
for module in extensions:
|
|
74
|
+
if importlib.util.find_spec(f"{module.__name__}.models"):
|
|
75
|
+
importlib.import_module(f"{module.__name__}.models")
|
|
76
|
+
module.init_app(self)
|
|
77
|
+
|
|
78
|
+
@functools.cached_property
|
|
79
|
+
def jinja_loader(self):
|
|
80
|
+
return ChoiceLoader(
|
|
81
|
+
[
|
|
82
|
+
PrefixLoader({"stk": FileSystemLoader(CORE_TEMPLATES)}),
|
|
83
|
+
*(FileSystemLoader(folder) for folder in self.template_folders),
|
|
84
|
+
]
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def static_file(self, filename):
|
|
88
|
+
"""Return the first static file at `filename`, app first, or None."""
|
|
89
|
+
for folder in self.static_folders:
|
|
90
|
+
path = safe_join(str(folder), filename)
|
|
91
|
+
if path is not None and os.path.isfile(path):
|
|
92
|
+
return Path(path)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
async def send_static_file(self, filename):
|
|
96
|
+
path = self.static_file(filename)
|
|
97
|
+
if path is None:
|
|
98
|
+
raise NotFound()
|
|
99
|
+
return await send_from_directory(path.parent, path.name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def register_extensions(app):
|
|
103
|
+
# Async SQLAlchemy engine + session factory
|
|
104
|
+
ext.engine = create_async_engine(app.config["SQLALCHEMY_DATABASE_URI"])
|
|
105
|
+
ext.async_session_factory = async_sessionmaker(ext.engine, expire_on_commit=False)
|
|
106
|
+
|
|
107
|
+
user_datastore = SQLAlchemyUserDatastore(
|
|
108
|
+
ext.async_session_factory, User, Role, webauthn_model=WebAuthn
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def _open_session():
|
|
112
|
+
g.db_session = user_datastore.session
|
|
113
|
+
|
|
114
|
+
@app.after_request
|
|
115
|
+
async def _close_session(response):
|
|
116
|
+
db_session = g.pop("db_session", None)
|
|
117
|
+
if db_session is not None:
|
|
118
|
+
await db_session.close()
|
|
119
|
+
return response
|
|
120
|
+
|
|
121
|
+
async def _open_ws_session():
|
|
122
|
+
g.db_session = user_datastore.session
|
|
123
|
+
|
|
124
|
+
@app.after_websocket
|
|
125
|
+
async def _close_ws_session(response):
|
|
126
|
+
db_session = g.pop("db_session", None)
|
|
127
|
+
if db_session is not None:
|
|
128
|
+
await db_session.close()
|
|
129
|
+
return response
|
|
130
|
+
|
|
131
|
+
@app.while_serving
|
|
132
|
+
async def _lifespan():
|
|
133
|
+
yield
|
|
134
|
+
# quart-session initializes backend connections in before_serving,
|
|
135
|
+
# but doesn't close them on shutdown. Ensure we always release them
|
|
136
|
+
# so Ctrl+C exits cleanly in dev.
|
|
137
|
+
session_interface = getattr(app, "session_interface", None)
|
|
138
|
+
backend = getattr(session_interface, "backend", None)
|
|
139
|
+
if backend is not None:
|
|
140
|
+
close_method = getattr(backend, "aclose", None) or getattr(
|
|
141
|
+
backend, "close", None
|
|
142
|
+
)
|
|
143
|
+
if close_method is not None:
|
|
144
|
+
try:
|
|
145
|
+
close_result = close_method()
|
|
146
|
+
if inspect.isawaitable(close_result):
|
|
147
|
+
await close_result
|
|
148
|
+
except Exception:
|
|
149
|
+
app.logger.exception("Error while closing session backend")
|
|
150
|
+
|
|
151
|
+
pool = getattr(backend, "connection_pool", None)
|
|
152
|
+
disconnect = getattr(pool, "disconnect", None) if pool else None
|
|
153
|
+
if disconnect is not None:
|
|
154
|
+
try:
|
|
155
|
+
disconnect_result = disconnect()
|
|
156
|
+
if inspect.isawaitable(disconnect_result):
|
|
157
|
+
await disconnect_result
|
|
158
|
+
except Exception:
|
|
159
|
+
app.logger.exception("Error while disconnecting session pool")
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
await asyncio.wait_for(ext.engine.dispose(), timeout=5)
|
|
163
|
+
except TimeoutError:
|
|
164
|
+
app.logger.warning("Timed out while disposing SQLAlchemy engine")
|
|
165
|
+
|
|
166
|
+
from stk.security import register_security_controls
|
|
167
|
+
from stk.utils.ratelimit import register_rate_limits
|
|
168
|
+
|
|
169
|
+
register_rate_limits(app)
|
|
170
|
+
register_security_controls(app)
|
|
171
|
+
|
|
172
|
+
Security(
|
|
173
|
+
app,
|
|
174
|
+
user_datastore,
|
|
175
|
+
register_form=ExtendedRegisterForm,
|
|
176
|
+
change_password_form=OAuthAwareChangePasswordForm,
|
|
177
|
+
)
|
|
178
|
+
# The library resets its session before loading the user. Share that session
|
|
179
|
+
# so signals and application routes participate in the same transaction.
|
|
180
|
+
app.before_request(_open_session)
|
|
181
|
+
app.before_websocket(_open_ws_session)
|
|
182
|
+
|
|
183
|
+
# Session initialization
|
|
184
|
+
if app.config.get("SESSION_TYPE") == "redis":
|
|
185
|
+
session.init_app(app)
|
|
186
|
+
# For non-redis, fall back to Quart's built-in cookie sessions
|
|
187
|
+
|
|
188
|
+
# CSRF token for POSTs outside quart-security templates (e.g. logout form).
|
|
189
|
+
# Uses the library's own get-or-create so tokens stay in sync.
|
|
190
|
+
app.jinja_env.globals["csrf_token"] = _ensure_csrf_token
|
|
191
|
+
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def register_blueprints(app):
|
|
196
|
+
app.register_blueprint(bp_user)
|
|
197
|
+
app.register_blueprint(public)
|
|
198
|
+
app.register_blueprint(portal)
|
|
199
|
+
app.register_blueprint(ws_bp)
|
|
200
|
+
if agent_login_enabled(app):
|
|
201
|
+
app.register_blueprint(bp_agent_login)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@functools.cache
|
|
206
|
+
def _static_digest(path, _mtime_ns):
|
|
207
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()[:12]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def register_static_versioning(app):
|
|
211
|
+
"""Version `url_for('static', ...)` by content so a deploy never serves stale JS.
|
|
212
|
+
|
|
213
|
+
Unversioned files carry no cache lifetime, so browsers keep them heuristically
|
|
214
|
+
and an old component can blank every page until a hard reload.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
@app.url_defaults
|
|
218
|
+
def _version_static(endpoint, values):
|
|
219
|
+
if endpoint == "static" and "filename" in values:
|
|
220
|
+
path = app.static_file(values["filename"])
|
|
221
|
+
if path is None:
|
|
222
|
+
raise FileNotFoundError(f"no static file {values['filename']}")
|
|
223
|
+
values["v"] = _static_digest(path, path.stat().st_mtime_ns)
|
|
224
|
+
|
|
225
|
+
@app.after_request
|
|
226
|
+
async def _cache_static(response):
|
|
227
|
+
if request.path.startswith(f"{app.static_url_path}/"):
|
|
228
|
+
response.headers["Cache-Control"] = (
|
|
229
|
+
"public, max-age=31536000, immutable"
|
|
230
|
+
if request.args.get("v")
|
|
231
|
+
else "no-cache"
|
|
232
|
+
)
|
|
233
|
+
return response
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
CORE_NAVIGATION_START = [
|
|
237
|
+
{"heading": "Main"},
|
|
238
|
+
{"title": "Dashboard", "icon": "ti ti-home", "to": "/dashboard"},
|
|
239
|
+
]
|
|
240
|
+
CORE_NAVIGATION_END = [
|
|
241
|
+
{"heading": "Account"},
|
|
242
|
+
{"title": "Change Password", "icon": "ti ti-key", "to": "/change"},
|
|
243
|
+
{"title": "Two-Factor Auth", "icon": "ti ti-shield-lock", "to": "/tf-setup"},
|
|
244
|
+
{"title": "Recovery Codes", "icon": "ti ti-lifebuoy", "to": "/mf-recovery-codes"},
|
|
245
|
+
{"heading": "Administration"},
|
|
246
|
+
{
|
|
247
|
+
"title": "User Management",
|
|
248
|
+
"icon": "ti ti-users-group",
|
|
249
|
+
"role": "admin",
|
|
250
|
+
"children": [
|
|
251
|
+
{"title": "Users", "icon": "ti ti-users", "to": "/users"},
|
|
252
|
+
{"title": "Roles", "icon": "ti ti-shield", "to": "/roles"},
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"title": "Activity Logs",
|
|
257
|
+
"icon": "ti ti-history",
|
|
258
|
+
"to": "/activities",
|
|
259
|
+
"role": "admin",
|
|
260
|
+
},
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def register_navigation(app):
|
|
265
|
+
app.jinja_env.globals["stk_navigation"] = lambda: [
|
|
266
|
+
*CORE_NAVIGATION_START,
|
|
267
|
+
*app.navigation,
|
|
268
|
+
*CORE_NAVIGATION_END,
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def register_errorhandlers(app):
|
|
273
|
+
import logging
|
|
274
|
+
|
|
275
|
+
logger = logging.getLogger(__name__)
|
|
276
|
+
|
|
277
|
+
def _is_api_request():
|
|
278
|
+
return (
|
|
279
|
+
request.path.startswith("/api/")
|
|
280
|
+
or request.accept_mimetypes.best == "application/json"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@app.errorhandler(Exception)
|
|
284
|
+
async def handle_exception(error):
|
|
285
|
+
db_session = g.pop("db_session", None)
|
|
286
|
+
if db_session is not None:
|
|
287
|
+
try:
|
|
288
|
+
# Detach loaded instances first so rollback doesn't expire them;
|
|
289
|
+
# error templates still read current_user after the session closes.
|
|
290
|
+
db_session.expunge_all()
|
|
291
|
+
await db_session.rollback()
|
|
292
|
+
except Exception:
|
|
293
|
+
logger.warning(
|
|
294
|
+
"Failed to rollback session during error handling", exc_info=True
|
|
295
|
+
)
|
|
296
|
+
finally:
|
|
297
|
+
await db_session.close()
|
|
298
|
+
|
|
299
|
+
# Only HTTP errors carry a status; SQLAlchemy errors also have a .code ("e3q8").
|
|
300
|
+
code = error.code if isinstance(error, HTTPException) else 500
|
|
301
|
+
if code == 500:
|
|
302
|
+
logger.exception("Unhandled exception")
|
|
303
|
+
|
|
304
|
+
if _is_api_request():
|
|
305
|
+
return {
|
|
306
|
+
"message": "Internal server error"
|
|
307
|
+
if code == 500
|
|
308
|
+
else getattr(error, "description", "Request failed")
|
|
309
|
+
}, code
|
|
310
|
+
# Fall back to 500.html for codes without a dedicated template (405, 403, ...)
|
|
311
|
+
return await render_template([f"{code}.html", "500.html"]), code
|
|
312
|
+
|
|
313
|
+
async def render_error(error):
|
|
314
|
+
error_code = getattr(error, "code", 500)
|
|
315
|
+
if _is_api_request():
|
|
316
|
+
return {
|
|
317
|
+
"message": error.name if hasattr(error, "name") else "Error"
|
|
318
|
+
}, error_code
|
|
319
|
+
return await render_template([f"{error_code}.html", "500.html"]), error_code
|
|
320
|
+
|
|
321
|
+
for errcode in [401, 404, 500]:
|
|
322
|
+
app.errorhandler(errcode)(render_error)
|
|
323
|
+
return None
|