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/cli/watch.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""File watching for `stk verify --watch`, with no watcher dependency.
|
|
2
|
+
|
|
3
|
+
A polling scan over the project's own source files. The tree is small (a few
|
|
4
|
+
hundred files), so a stat() sweep twice a second costs less than importing a
|
|
5
|
+
watcher library would.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
IGNORED_DIRS = {
|
|
11
|
+
".git",
|
|
12
|
+
".venv",
|
|
13
|
+
".ruff_cache",
|
|
14
|
+
".pytest_cache",
|
|
15
|
+
"__pycache__",
|
|
16
|
+
"node_modules",
|
|
17
|
+
"instance",
|
|
18
|
+
".stk",
|
|
19
|
+
}
|
|
20
|
+
# Checks worth re-running when a file of this kind changes. Also defines what
|
|
21
|
+
# counts as a source file, so the two can never disagree.
|
|
22
|
+
CHECKS_BY_SUFFIX = {
|
|
23
|
+
".py": ("ruff", "checks", "migration-drift"),
|
|
24
|
+
".html": ("checks",),
|
|
25
|
+
".jinja2": ("checks",),
|
|
26
|
+
".js": ("checks",),
|
|
27
|
+
".css": ("checks",),
|
|
28
|
+
}
|
|
29
|
+
# Vendored assets are refreshed by vendor.sh, not edited, and they are large.
|
|
30
|
+
IGNORED_PARTS = {"static/js/vue.min.js", "static/js/vuetify.min.js"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def scan(root: Path) -> dict[Path, float]:
|
|
34
|
+
"""Return {path: mtime} for every source file worth reacting to."""
|
|
35
|
+
seen = {}
|
|
36
|
+
for path in root.rglob("*"):
|
|
37
|
+
if path.suffix not in CHECKS_BY_SUFFIX:
|
|
38
|
+
continue
|
|
39
|
+
if IGNORED_DIRS & set(path.parts):
|
|
40
|
+
continue
|
|
41
|
+
if any(part in path.as_posix() for part in IGNORED_PARTS):
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
seen[path] = path.stat().st_mtime
|
|
45
|
+
except OSError: # deleted between rglob and stat
|
|
46
|
+
continue
|
|
47
|
+
return seen
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def changed(previous: dict[Path, float], current: dict[Path, float]) -> list[Path]:
|
|
51
|
+
"""Return the paths that were added, removed, or touched."""
|
|
52
|
+
touched = [
|
|
53
|
+
path
|
|
54
|
+
for path, mtime in current.items()
|
|
55
|
+
if previous.get(path) != mtime # new file or new mtime
|
|
56
|
+
]
|
|
57
|
+
touched += [path for path in previous if path not in current]
|
|
58
|
+
return sorted(set(touched))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def checks_for(paths) -> list[str]:
|
|
62
|
+
"""Return the check names worth re-running for this batch of changes."""
|
|
63
|
+
names = []
|
|
64
|
+
for path in paths:
|
|
65
|
+
for name in CHECKS_BY_SUFFIX.get(path.suffix, ()):
|
|
66
|
+
if name not in names:
|
|
67
|
+
names.append(name)
|
|
68
|
+
return names
|
stk/extensions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Extensions module. Each extension is initialized in the app factory located
|
|
2
|
+
in app.py
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from quart_session import Session
|
|
6
|
+
from sqlalchemy import MetaData
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
8
|
+
|
|
9
|
+
# SQLite batch migrations can only add or drop named constraints; without a
|
|
10
|
+
# convention autogenerate emits None and the upgrade fails half-applied.
|
|
11
|
+
NAMING_CONVENTION = {
|
|
12
|
+
"ix": "ix_%(column_0_label)s",
|
|
13
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
14
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
15
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s",
|
|
16
|
+
"pk": "pk_%(table_name)s",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Base(DeclarativeBase):
|
|
21
|
+
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
|
22
|
+
# A server-side default or onupdate expires its column on flush; reading it
|
|
23
|
+
# later lazy-loads, which async sessions refuse with MissingGreenlet.
|
|
24
|
+
__mapper_args__ = {"eager_defaults": True}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
engine = None
|
|
28
|
+
async_session_factory = None
|
|
29
|
+
session = Session()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Alembic integration: stk's lineage ships here, each app keeps its own.
|
|
2
|
+
|
|
3
|
+
Both lineages share one version table. stk's revisions carry the `stk` branch
|
|
4
|
+
label; an app's carry its package name and live in `<package>/migrations/`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import os
|
|
11
|
+
import pkgutil
|
|
12
|
+
import tempfile
|
|
13
|
+
import uuid
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from alembic.config import Config as AlembicConfig
|
|
18
|
+
from sqlalchemy.engine import make_url
|
|
19
|
+
|
|
20
|
+
import stk
|
|
21
|
+
from stk.extensions import Base
|
|
22
|
+
|
|
23
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
24
|
+
CORE_VERSIONS = SCRIPT_DIR / "versions"
|
|
25
|
+
CORE_LABEL = "stk"
|
|
26
|
+
|
|
27
|
+
SYNC_DRIVER_MAP = {
|
|
28
|
+
"postgresql+asyncpg": "postgresql+psycopg2",
|
|
29
|
+
"sqlite+aiosqlite": "sqlite",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Tables an extension owns (PostGIS keeps its reference systems here); no model
|
|
33
|
+
# describes them, so autogenerate and the drift check must not offer to drop them.
|
|
34
|
+
EXTENSION_TABLES = {"spatial_ref_sys"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def import_model_modules() -> None:
|
|
38
|
+
"""Import every `stk.<feature>.models`; apps import theirs when built."""
|
|
39
|
+
for module in pkgutil.iter_modules(stk.__path__, prefix="stk."):
|
|
40
|
+
model_module = f"{module.name}.models"
|
|
41
|
+
try:
|
|
42
|
+
importlib.import_module(model_module)
|
|
43
|
+
except ModuleNotFoundError as exc:
|
|
44
|
+
if exc.name != model_module:
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_target_metadata():
|
|
49
|
+
"""Return metadata after loading all model modules."""
|
|
50
|
+
import_model_modules()
|
|
51
|
+
return Base.metadata
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def include_object(obj, name, type_, reflected, compare_to):
|
|
55
|
+
return not (type_ == "table" and name in EXTENSION_TABLES)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sync_url(database_url: str) -> str:
|
|
59
|
+
"""Convert an async driver URL to the sync driver Alembic uses."""
|
|
60
|
+
url = make_url(database_url)
|
|
61
|
+
drivername = SYNC_DRIVER_MAP.get(url.drivername)
|
|
62
|
+
if drivername is not None:
|
|
63
|
+
url = url.set(drivername=drivername)
|
|
64
|
+
# str(URL) masks the password as *** on SQLAlchemy >= 2.0.50
|
|
65
|
+
return url.render_as_string(hide_password=False)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def app_label(app) -> str:
|
|
69
|
+
"""The branch label of an app's lineage: its top-level package name."""
|
|
70
|
+
return app.import_name.split(".")[0]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def app_versions(app) -> Path:
|
|
74
|
+
return Path(app.root_path) / "migrations"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_alembic_config(app=None, url=None, *, quiet=False) -> AlembicConfig:
|
|
78
|
+
"""Build an Alembic config for stk's lineage plus the app's, if given."""
|
|
79
|
+
config = AlembicConfig()
|
|
80
|
+
config.set_main_option("script_location", str(SCRIPT_DIR))
|
|
81
|
+
config.set_main_option("path_separator", "os")
|
|
82
|
+
locations = [CORE_VERSIONS] + ([app_versions(app)] if app is not None else [])
|
|
83
|
+
config.set_main_option("version_locations", os.pathsep.join(map(str, locations)))
|
|
84
|
+
if url is None:
|
|
85
|
+
url = sync_url(app.config["SQLALCHEMY_DATABASE_URI"])
|
|
86
|
+
# The ini parser behind main options treats % as interpolation.
|
|
87
|
+
config.set_main_option("sqlalchemy.url", url.replace("%", "%%"))
|
|
88
|
+
config.attributes["quiet"] = quiet
|
|
89
|
+
return config
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def portable_tables() -> list:
|
|
93
|
+
"""Every model table except those marked `info={"postgis": True}`, for SQLite."""
|
|
94
|
+
return [t for t in get_target_metadata().sorted_tables if not t.info.get("postgis")]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _render(url, *, sync: bool) -> str:
|
|
98
|
+
rendered = url.render_as_string(hide_password=False)
|
|
99
|
+
return sync_url(rendered) if sync else rendered
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@contextmanager
|
|
103
|
+
def throwaway_database_url(database_url: str, *, sync: bool = False):
|
|
104
|
+
"""Yield the URL of an empty database of the same dialect, then drop it.
|
|
105
|
+
|
|
106
|
+
Postgres apps need Postgres here: their models and migrations use types
|
|
107
|
+
SQLite cannot render. Postgres needs a role with CREATEDB.
|
|
108
|
+
"""
|
|
109
|
+
from sqlalchemy import create_engine, text
|
|
110
|
+
|
|
111
|
+
url = make_url(database_url)
|
|
112
|
+
if url.get_backend_name() != "postgresql":
|
|
113
|
+
with tempfile.TemporaryDirectory(prefix="stk-db-") as temp_dir:
|
|
114
|
+
yield _render(url.set(database=f"{temp_dir}/throwaway.db"), sync=sync)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
name = f"stk_tmp_{uuid.uuid4().hex[:12]}"
|
|
118
|
+
admin = create_engine(
|
|
119
|
+
_render(url.set(database="postgres"), sync=True), isolation_level="AUTOCOMMIT"
|
|
120
|
+
)
|
|
121
|
+
try:
|
|
122
|
+
with admin.connect() as connection:
|
|
123
|
+
connection.execute(text(f'create database "{name}"'))
|
|
124
|
+
try:
|
|
125
|
+
yield _render(url.set(database=name), sync=sync)
|
|
126
|
+
finally:
|
|
127
|
+
with admin.connect() as connection:
|
|
128
|
+
connection.execute(text(f'drop database "{name}" with (force)'))
|
|
129
|
+
finally:
|
|
130
|
+
admin.dispose()
|
stk/migrations/env.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from alembic import context
|
|
6
|
+
from sqlalchemy import engine_from_config, pool
|
|
7
|
+
|
|
8
|
+
from stk.migrations import get_target_metadata, include_object
|
|
9
|
+
|
|
10
|
+
config = context.config
|
|
11
|
+
|
|
12
|
+
if not config.attributes.get("quiet"):
|
|
13
|
+
logging.basicConfig(format="%(levelname)-5.5s [%(name)s] %(message)s")
|
|
14
|
+
logging.getLogger("alembic").setLevel(logging.INFO)
|
|
15
|
+
|
|
16
|
+
target_metadata = get_target_metadata()
|
|
17
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_migrations_offline() -> None:
|
|
21
|
+
context.configure(
|
|
22
|
+
url=url,
|
|
23
|
+
target_metadata=target_metadata,
|
|
24
|
+
literal_binds=True,
|
|
25
|
+
compare_type=True,
|
|
26
|
+
compare_server_default=True,
|
|
27
|
+
include_object=include_object,
|
|
28
|
+
render_as_batch=url.startswith("sqlite"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
with context.begin_transaction():
|
|
32
|
+
context.run_migrations()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_migrations_online() -> None:
|
|
36
|
+
connectable = engine_from_config(
|
|
37
|
+
{"sqlalchemy.url": url}, prefix="sqlalchemy.", poolclass=pool.NullPool
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
with connectable.connect() as connection:
|
|
41
|
+
context.configure(
|
|
42
|
+
connection=connection,
|
|
43
|
+
target_metadata=target_metadata,
|
|
44
|
+
compare_type=True,
|
|
45
|
+
compare_server_default=True,
|
|
46
|
+
include_object=include_object,
|
|
47
|
+
# SQLite alters tables by copying them; batch mode does that for us.
|
|
48
|
+
render_as_batch=connection.dialect.name == "sqlite",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
with context.begin_transaction():
|
|
52
|
+
context.run_migrations()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if context.is_offline_mode():
|
|
56
|
+
run_migrations_offline()
|
|
57
|
+
else:
|
|
58
|
+
run_migrations_online()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""${message}"""
|
|
2
|
+
|
|
3
|
+
import sqlalchemy as sa
|
|
4
|
+
from alembic import op
|
|
5
|
+
${imports if imports else ""}
|
|
6
|
+
revision = ${repr(up_revision)}
|
|
7
|
+
down_revision = ${repr(down_revision)}
|
|
8
|
+
branch_labels = ${repr(branch_labels)}
|
|
9
|
+
depends_on = ${repr(depends_on)}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def upgrade() -> None:
|
|
13
|
+
${upgrades if upgrades else "pass"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def downgrade() -> None:
|
|
17
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""stk initial schema: users, roles, sessions, passkeys, OAuth, security state."""
|
|
2
|
+
|
|
3
|
+
import sqlalchemy as sa
|
|
4
|
+
from alembic import op
|
|
5
|
+
|
|
6
|
+
revision = "stk_0001"
|
|
7
|
+
down_revision = None
|
|
8
|
+
branch_labels = ("stk",)
|
|
9
|
+
depends_on = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def upgrade() -> None:
|
|
13
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
14
|
+
op.create_table(
|
|
15
|
+
"quart_security_state",
|
|
16
|
+
sa.Column("token", sa.String(length=160), nullable=False),
|
|
17
|
+
sa.Column("payload", sa.JSON(), nullable=False),
|
|
18
|
+
sa.Column("expires_at", sa.BigInteger(), nullable=False),
|
|
19
|
+
sa.PrimaryKeyConstraint("token", name=op.f("pk_quart_security_state")),
|
|
20
|
+
)
|
|
21
|
+
with op.batch_alter_table("quart_security_state", schema=None) as batch_op:
|
|
22
|
+
batch_op.create_index(
|
|
23
|
+
batch_op.f("ix_quart_security_state_expires_at"),
|
|
24
|
+
["expires_at"],
|
|
25
|
+
unique=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
op.create_table(
|
|
29
|
+
"rate_limit_window",
|
|
30
|
+
sa.Column("key", sa.String(length=160), nullable=False),
|
|
31
|
+
sa.Column("count", sa.Integer(), nullable=False),
|
|
32
|
+
sa.Column("expires_at", sa.Integer(), nullable=False),
|
|
33
|
+
sa.PrimaryKeyConstraint("key", name=op.f("pk_rate_limit_window")),
|
|
34
|
+
)
|
|
35
|
+
with op.batch_alter_table("rate_limit_window", schema=None) as batch_op:
|
|
36
|
+
batch_op.create_index(
|
|
37
|
+
batch_op.f("ix_rate_limit_window_expires_at"), ["expires_at"], unique=False
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
op.create_table(
|
|
41
|
+
"role",
|
|
42
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
43
|
+
sa.Column("name", sa.String(length=80), nullable=True),
|
|
44
|
+
sa.Column("description", sa.String(length=255), nullable=True),
|
|
45
|
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_role")),
|
|
46
|
+
sa.UniqueConstraint("name", name=op.f("uq_role_name")),
|
|
47
|
+
)
|
|
48
|
+
op.create_table(
|
|
49
|
+
"user",
|
|
50
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
51
|
+
sa.Column("username", sa.String(length=255), nullable=True),
|
|
52
|
+
sa.Column("fs_uniquifier", sa.String(length=255), nullable=False),
|
|
53
|
+
sa.Column("name", sa.String(length=255), nullable=True),
|
|
54
|
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
|
55
|
+
sa.Column("email", sa.String(length=255), nullable=False),
|
|
56
|
+
sa.Column("password", sa.String(length=255), nullable=False),
|
|
57
|
+
sa.Column("password_set", sa.Boolean(), nullable=False),
|
|
58
|
+
sa.Column("active", sa.Boolean(), nullable=True),
|
|
59
|
+
sa.Column("failed_login_count", sa.Integer(), nullable=False),
|
|
60
|
+
sa.Column("locked_until", sa.DateTime(), nullable=True),
|
|
61
|
+
sa.Column("confirmed_at", sa.DateTime(), nullable=True),
|
|
62
|
+
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
|
63
|
+
sa.Column("current_login_at", sa.DateTime(), nullable=True),
|
|
64
|
+
sa.Column("last_login_ip", sa.String(length=255), nullable=True),
|
|
65
|
+
sa.Column("current_login_ip", sa.String(length=255), nullable=True),
|
|
66
|
+
sa.Column("login_count", sa.Integer(), nullable=True),
|
|
67
|
+
sa.Column("fs_webauthn_user_handle", sa.String(length=64), nullable=True),
|
|
68
|
+
sa.Column("tf_phone_number", sa.String(length=64), nullable=True),
|
|
69
|
+
sa.Column("tf_primary_method", sa.String(length=140), nullable=True),
|
|
70
|
+
sa.Column("tf_totp_secret", sa.String(length=255), nullable=True),
|
|
71
|
+
sa.Column("mf_recovery_codes", sa.JSON(), nullable=True),
|
|
72
|
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_user")),
|
|
73
|
+
sa.UniqueConstraint("email", name=op.f("uq_user_email")),
|
|
74
|
+
sa.UniqueConstraint("fs_uniquifier", name=op.f("uq_user_fs_uniquifier")),
|
|
75
|
+
sa.UniqueConstraint(
|
|
76
|
+
"fs_webauthn_user_handle", name=op.f("uq_user_fs_webauthn_user_handle")
|
|
77
|
+
),
|
|
78
|
+
sa.UniqueConstraint("username", name=op.f("uq_user_username")),
|
|
79
|
+
)
|
|
80
|
+
op.create_table(
|
|
81
|
+
"activity",
|
|
82
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
83
|
+
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
84
|
+
sa.Column("action", sa.String(length=255), nullable=False),
|
|
85
|
+
sa.Column("data", sa.JSON(), nullable=True),
|
|
86
|
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
|
87
|
+
sa.ForeignKeyConstraint(
|
|
88
|
+
["user_id"], ["user.id"], name=op.f("fk_activity_user_id")
|
|
89
|
+
),
|
|
90
|
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_activity")),
|
|
91
|
+
)
|
|
92
|
+
with op.batch_alter_table("activity", schema=None) as batch_op:
|
|
93
|
+
batch_op.create_index(
|
|
94
|
+
batch_op.f("ix_activity_created_at"), ["created_at"], unique=False
|
|
95
|
+
)
|
|
96
|
+
batch_op.create_index(
|
|
97
|
+
batch_op.f("ix_activity_user_id"), ["user_id"], unique=False
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
op.create_table(
|
|
101
|
+
"oauth",
|
|
102
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
103
|
+
sa.Column("provider", sa.String(length=50), nullable=False),
|
|
104
|
+
sa.Column("provider_user_id", sa.String(length=256), nullable=False),
|
|
105
|
+
sa.Column("token", sa.JSON(), nullable=True),
|
|
106
|
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
|
107
|
+
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
108
|
+
sa.ForeignKeyConstraint(
|
|
109
|
+
["user_id"], ["user.id"], name=op.f("fk_oauth_user_id")
|
|
110
|
+
),
|
|
111
|
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_oauth")),
|
|
112
|
+
sa.UniqueConstraint(
|
|
113
|
+
"provider", "provider_user_id", name="uq_oauth_provider_user"
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
op.create_table(
|
|
117
|
+
"roles_users",
|
|
118
|
+
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
119
|
+
sa.Column("role_id", sa.Integer(), nullable=False),
|
|
120
|
+
sa.ForeignKeyConstraint(
|
|
121
|
+
["role_id"], ["role.id"], name=op.f("fk_roles_users_role_id")
|
|
122
|
+
),
|
|
123
|
+
sa.ForeignKeyConstraint(
|
|
124
|
+
["user_id"], ["user.id"], name=op.f("fk_roles_users_user_id")
|
|
125
|
+
),
|
|
126
|
+
sa.PrimaryKeyConstraint("user_id", "role_id", name=op.f("pk_roles_users")),
|
|
127
|
+
)
|
|
128
|
+
op.create_table(
|
|
129
|
+
"user_sessions",
|
|
130
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
131
|
+
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
132
|
+
sa.Column("session_token", sa.String(length=255), nullable=False),
|
|
133
|
+
sa.Column("last_active", sa.DateTime(), nullable=True),
|
|
134
|
+
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
|
135
|
+
sa.Column("ip_address", sa.String(length=255), nullable=True),
|
|
136
|
+
sa.Column("meta", sa.JSON(), nullable=True),
|
|
137
|
+
sa.Column("is_active", sa.Boolean(), nullable=True),
|
|
138
|
+
sa.Column("created_at", sa.DateTime(), nullable=False),
|
|
139
|
+
sa.ForeignKeyConstraint(
|
|
140
|
+
["user_id"], ["user.id"], name=op.f("fk_user_sessions_user_id")
|
|
141
|
+
),
|
|
142
|
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_sessions")),
|
|
143
|
+
sa.UniqueConstraint(
|
|
144
|
+
"session_token", name=op.f("uq_user_sessions_session_token")
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
with op.batch_alter_table("user_sessions", schema=None) as batch_op:
|
|
148
|
+
batch_op.create_index(
|
|
149
|
+
batch_op.f("ix_user_sessions_is_active"), ["is_active"], unique=False
|
|
150
|
+
)
|
|
151
|
+
batch_op.create_index(
|
|
152
|
+
batch_op.f("ix_user_sessions_user_id"), ["user_id"], unique=False
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
op.create_table(
|
|
156
|
+
"web_authn",
|
|
157
|
+
sa.Column("id", sa.Integer(), nullable=False),
|
|
158
|
+
sa.Column("credential_id", sa.LargeBinary(length=1024), nullable=False),
|
|
159
|
+
sa.Column("public_key", sa.LargeBinary(length=1024), nullable=False),
|
|
160
|
+
sa.Column("sign_count", sa.Integer(), nullable=False),
|
|
161
|
+
sa.Column("transports", sa.JSON(), nullable=True),
|
|
162
|
+
sa.Column("extensions", sa.String(length=255), nullable=True),
|
|
163
|
+
sa.Column("lastuse_datetime", sa.DateTime(), nullable=False),
|
|
164
|
+
sa.Column("name", sa.String(length=64), nullable=False),
|
|
165
|
+
sa.Column("usage", sa.String(length=64), nullable=False),
|
|
166
|
+
sa.Column("backup_state", sa.Boolean(), nullable=False),
|
|
167
|
+
sa.Column("device_type", sa.String(length=64), nullable=False),
|
|
168
|
+
sa.Column("user_id", sa.String(length=64), nullable=False),
|
|
169
|
+
sa.ForeignKeyConstraint(
|
|
170
|
+
["user_id"],
|
|
171
|
+
["user.fs_webauthn_user_handle"],
|
|
172
|
+
name=op.f("fk_web_authn_user_id"),
|
|
173
|
+
ondelete="CASCADE",
|
|
174
|
+
),
|
|
175
|
+
sa.PrimaryKeyConstraint("id", name=op.f("pk_web_authn")),
|
|
176
|
+
)
|
|
177
|
+
with op.batch_alter_table("web_authn", schema=None) as batch_op:
|
|
178
|
+
batch_op.create_index(
|
|
179
|
+
batch_op.f("ix_web_authn_credential_id"), ["credential_id"], unique=True
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ### end Alembic commands ###
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def downgrade() -> None:
|
|
186
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
187
|
+
with op.batch_alter_table("web_authn", schema=None) as batch_op:
|
|
188
|
+
batch_op.drop_index(batch_op.f("ix_web_authn_credential_id"))
|
|
189
|
+
|
|
190
|
+
op.drop_table("web_authn")
|
|
191
|
+
with op.batch_alter_table("user_sessions", schema=None) as batch_op:
|
|
192
|
+
batch_op.drop_index(batch_op.f("ix_user_sessions_user_id"))
|
|
193
|
+
batch_op.drop_index(batch_op.f("ix_user_sessions_is_active"))
|
|
194
|
+
|
|
195
|
+
op.drop_table("user_sessions")
|
|
196
|
+
op.drop_table("roles_users")
|
|
197
|
+
op.drop_table("oauth")
|
|
198
|
+
with op.batch_alter_table("activity", schema=None) as batch_op:
|
|
199
|
+
batch_op.drop_index(batch_op.f("ix_activity_user_id"))
|
|
200
|
+
batch_op.drop_index(batch_op.f("ix_activity_created_at"))
|
|
201
|
+
|
|
202
|
+
op.drop_table("activity")
|
|
203
|
+
op.drop_table("user")
|
|
204
|
+
op.drop_table("role")
|
|
205
|
+
with op.batch_alter_table("rate_limit_window", schema=None) as batch_op:
|
|
206
|
+
batch_op.drop_index(batch_op.f("ix_rate_limit_window_expires_at"))
|
|
207
|
+
|
|
208
|
+
op.drop_table("rate_limit_window")
|
|
209
|
+
with op.batch_alter_table("quart_security_state", schema=None) as batch_op:
|
|
210
|
+
batch_op.drop_index(batch_op.f("ix_quart_security_state_expires_at"))
|
|
211
|
+
|
|
212
|
+
op.drop_table("quart_security_state")
|
|
213
|
+
# ### end Alembic commands ###
|
stk/portal/views.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from quart import Blueprint, g, render_template
|
|
2
|
+
from quart_security import auth_required
|
|
3
|
+
from sqlalchemy import func, select
|
|
4
|
+
|
|
5
|
+
from stk.user.models import Activity, Role, User
|
|
6
|
+
|
|
7
|
+
portal = Blueprint("portal", __name__, static_folder="../static")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@portal.before_request
|
|
11
|
+
@auth_required("session")
|
|
12
|
+
async def before_request():
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@portal.after_request
|
|
17
|
+
async def add_header(response):
|
|
18
|
+
response.headers["Cache-Control"] = "private, no-store"
|
|
19
|
+
return response
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@portal.route("/dashboard/")
|
|
23
|
+
async def dashboard():
|
|
24
|
+
users_count = (await g.db_session.execute(select(func.count(User.id)))).scalar()
|
|
25
|
+
roles_count = (await g.db_session.execute(select(func.count(Role.id)))).scalar()
|
|
26
|
+
activities_count = (
|
|
27
|
+
await g.db_session.execute(select(func.count(Activity.id)))
|
|
28
|
+
).scalar()
|
|
29
|
+
stats = {
|
|
30
|
+
"users": users_count,
|
|
31
|
+
"roles": roles_count,
|
|
32
|
+
"activities": activities_count,
|
|
33
|
+
}
|
|
34
|
+
return await render_template("dashboard.html", stats=stats)
|
stk/public/__init__.py
ADDED
|
File without changes
|
stk/public/models.py
ADDED
|
File without changes
|