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.
Files changed (128) hide show
  1. stk/__init__.py +19 -0
  2. stk/__main__.py +6 -0
  3. stk/agent_login.py +78 -0
  4. stk/app.py +323 -0
  5. stk/checks.py +634 -0
  6. stk/cli/__init__.py +49 -0
  7. stk/cli/agent.py +267 -0
  8. stk/cli/base.py +79 -0
  9. stk/cli/database.py +199 -0
  10. stk/cli/develop.py +63 -0
  11. stk/cli/doctor.py +262 -0
  12. stk/cli/main.py +101 -0
  13. stk/cli/reports.py +296 -0
  14. stk/cli/smoke.py +399 -0
  15. stk/cli/users.py +259 -0
  16. stk/cli/watch.py +68 -0
  17. stk/extensions.py +29 -0
  18. stk/migrations/__init__.py +130 -0
  19. stk/migrations/env.py +58 -0
  20. stk/migrations/script.py.mako +17 -0
  21. stk/migrations/versions/stk_0001_initial_schema.py +213 -0
  22. stk/portal/views.py +34 -0
  23. stk/public/__init__.py +0 -0
  24. stk/public/models.py +0 -0
  25. stk/public/views.py +344 -0
  26. stk/scaffold/__init__.py +1 -0
  27. stk/scaffold/app/CLAUDE.md +65 -0
  28. stk/scaffold/app/Dockerfile +41 -0
  29. stk/scaffold/app/README.md +16 -0
  30. stk/scaffold/app/__app__/__init__.py +1 -0
  31. stk/scaffold/app/__app__/app.py +8 -0
  32. stk/scaffold/app/__app__/settings.py +7 -0
  33. stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
  34. stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
  35. stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
  36. stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
  37. stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
  38. stk/scaffold/app/docker-compose.yml +88 -0
  39. stk/scaffold/app/dockerignore +13 -0
  40. stk/scaffold/app/env-sample +78 -0
  41. stk/scaffold/app/gitignore +8 -0
  42. stk/scaffold/app/nginx/nginx.conf +46 -0
  43. stk/scaffold/app/nginx/stk.conf +36 -0
  44. stk/scaffold/app/postgres/init-db.sh +8 -0
  45. stk/scaffold/app/pyproject.toml +23 -0
  46. stk/scaffold/app/tests/__init__.py +0 -0
  47. stk/scaffold/app/tests/test_app.py +23 -0
  48. stk/scaffold/generator.py +209 -0
  49. stk/scaffold/templates.py +437 -0
  50. stk/security.py +145 -0
  51. stk/settings.py +147 -0
  52. stk/shell.py +284 -0
  53. stk/static/VERSIONS.txt +4 -0
  54. stk/static/css/app.css +229 -0
  55. stk/static/css/layout.css +330 -0
  56. stk/static/css/vuetify.min.css +6 -0
  57. stk/static/icons/fonts/tabler-icons.woff2 +0 -0
  58. stk/static/icons/tabler-icons.min.css +4 -0
  59. stk/static/img/auth-bg.webp +0 -0
  60. stk/static/img/favicon.ico +0 -0
  61. stk/static/img/stk.svg +1664 -0
  62. stk/static/js/axios.min.js +5 -0
  63. stk/static/js/components/NotificationDropdown.js +156 -0
  64. stk/static/js/components/ThemeSwitcher.js +68 -0
  65. stk/static/js/components/TransitionExpand.js +64 -0
  66. stk/static/js/components/VerticalNavGroup.js +119 -0
  67. stk/static/js/components/VerticalNavLink.js +36 -0
  68. stk/static/js/components/VerticalNavSectionTitle.js +21 -0
  69. stk/static/js/components/index.js +47 -0
  70. stk/static/js/config.js +150 -0
  71. stk/static/js/vue.min.js +13 -0
  72. stk/static/js/vuetify.min.js +3148 -0
  73. stk/static/robots.txt +1 -0
  74. stk/tasks/__init__.py +78 -0
  75. stk/templates/401.html +14 -0
  76. stk/templates/404.html +12 -0
  77. stk/templates/500.html +13 -0
  78. stk/templates/_brand.html +1 -0
  79. stk/templates/auth_layout.html +26 -0
  80. stk/templates/cms/activities.html +135 -0
  81. stk/templates/cms/roles.html +215 -0
  82. stk/templates/cms/users.html +312 -0
  83. stk/templates/dashboard.html +132 -0
  84. stk/templates/index.html +52 -0
  85. stk/templates/layout.html +328 -0
  86. stk/templates/login-layout.html +54 -0
  87. stk/templates/security/_macros.html +39 -0
  88. stk/templates/security/_messages.html +12 -0
  89. stk/templates/security/change_password.html +100 -0
  90. stk/templates/security/confirm_email.html +44 -0
  91. stk/templates/security/email/change_notice.html +4 -0
  92. stk/templates/security/email/change_notice.txt +5 -0
  93. stk/templates/security/email/confirmation_instructions.html +3 -0
  94. stk/templates/security/email/confirmation_instructions.txt +3 -0
  95. stk/templates/security/email/login_instructions.html +5 -0
  96. stk/templates/security/email/login_instructions.txt +5 -0
  97. stk/templates/security/email/reset_instructions.html +1 -0
  98. stk/templates/security/email/reset_instructions.txt +3 -0
  99. stk/templates/security/email/reset_notice.html +1 -0
  100. stk/templates/security/email/reset_notice.txt +1 -0
  101. stk/templates/security/email/welcome.html +7 -0
  102. stk/templates/security/email/welcome.txt +7 -0
  103. stk/templates/security/forgot_password.html +63 -0
  104. stk/templates/security/login_user.html +206 -0
  105. stk/templates/security/mf_recovery.html +67 -0
  106. stk/templates/security/mf_recovery_codes.html +119 -0
  107. stk/templates/security/register_user.html +263 -0
  108. stk/templates/security/reset_password.html +53 -0
  109. stk/templates/security/send_confirmation.html +48 -0
  110. stk/templates/security/send_login.html +39 -0
  111. stk/templates/security/two_factor_select.html +78 -0
  112. stk/templates/security/two_factor_setup.html +166 -0
  113. stk/templates/security/two_factor_verify_code.html +71 -0
  114. stk/templates/security/wan_register.html +179 -0
  115. stk/templates/security/wan_signin.html +123 -0
  116. stk/templates/security/wan_verify.html +109 -0
  117. stk/user/__init__.py +0 -0
  118. stk/user/forms.py +10 -0
  119. stk/user/models.py +308 -0
  120. stk/user/views.py +339 -0
  121. stk/utils/email.py +32 -0
  122. stk/utils/ratelimit.py +67 -0
  123. stk/websocket.py +133 -0
  124. stkit-17.0.0.dist-info/METADATA +135 -0
  125. stkit-17.0.0.dist-info/RECORD +128 -0
  126. stkit-17.0.0.dist-info/WHEEL +4 -0
  127. stkit-17.0.0.dist-info/entry_points.txt +2 -0
  128. 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