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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Filesystem operations for the scaffolder.
|
|
2
|
+
|
|
3
|
+
generate_app() writes a new app package (`stk init`); generate_module() writes
|
|
4
|
+
an extension into an existing app and adds it to the app's extension list
|
|
5
|
+
(`stk new`). Neither touches stk's own files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import stk
|
|
16
|
+
from stk.scaffold.templates import (
|
|
17
|
+
render_init,
|
|
18
|
+
render_models,
|
|
19
|
+
render_template_html,
|
|
20
|
+
render_views,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
APP_TEMPLATE = Path(__file__).parent / "app"
|
|
24
|
+
# Template files stored without a leading dot, so packaging never drops them.
|
|
25
|
+
DOTFILES = {
|
|
26
|
+
"gitignore": ".gitignore",
|
|
27
|
+
"dockerignore": ".dockerignore",
|
|
28
|
+
"env-sample": ".env-sample",
|
|
29
|
+
"claude": ".claude",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Names that conflict with existing blueprints, SQLAlchemy reserved words, or
|
|
33
|
+
# Python builtins that would make imports ambiguous.
|
|
34
|
+
RESERVED = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"user",
|
|
37
|
+
"users",
|
|
38
|
+
"role",
|
|
39
|
+
"public",
|
|
40
|
+
"portal",
|
|
41
|
+
"websocket",
|
|
42
|
+
"ws",
|
|
43
|
+
"activity",
|
|
44
|
+
"session",
|
|
45
|
+
"security",
|
|
46
|
+
"oauth",
|
|
47
|
+
"webauthn",
|
|
48
|
+
"admin",
|
|
49
|
+
"static",
|
|
50
|
+
"templates",
|
|
51
|
+
"migrations",
|
|
52
|
+
"api",
|
|
53
|
+
"app",
|
|
54
|
+
"stk",
|
|
55
|
+
"test",
|
|
56
|
+
"tests",
|
|
57
|
+
"settings",
|
|
58
|
+
# Python builtins
|
|
59
|
+
"type",
|
|
60
|
+
"list",
|
|
61
|
+
"dict",
|
|
62
|
+
"set",
|
|
63
|
+
"tuple",
|
|
64
|
+
"int",
|
|
65
|
+
"str",
|
|
66
|
+
"float",
|
|
67
|
+
"bool",
|
|
68
|
+
"bytes",
|
|
69
|
+
"object",
|
|
70
|
+
"id",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_name(name: str) -> None:
|
|
78
|
+
"""Raise ValueError with a clear message if name is invalid."""
|
|
79
|
+
if not _NAME_RE.match(name):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Name '{name}' is not valid. Use lowercase snake_case identifiers "
|
|
82
|
+
"(letters, digits, underscores; must start with a letter)."
|
|
83
|
+
)
|
|
84
|
+
if name in RESERVED:
|
|
85
|
+
raise ValueError(f"Name '{name}' is reserved. Choose a different name.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _local_source(target: Path, source: str) -> str:
|
|
89
|
+
"""A `[tool.uv.sources]` table that installs stkit from a local path."""
|
|
90
|
+
path = Path(source).resolve()
|
|
91
|
+
if not path.exists():
|
|
92
|
+
raise ValueError(f"--source {source} does not exist")
|
|
93
|
+
relative = Path(os.path.relpath(path, target)).as_posix()
|
|
94
|
+
editable = ", editable = true" if path.is_dir() else ""
|
|
95
|
+
return f'\n[tool.uv.sources]\nstkit = {{ path = "{relative}"{editable} }}\n'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _env(sample: str) -> str:
|
|
99
|
+
"""A development .env from the sample, with fresh secrets."""
|
|
100
|
+
for key in ("SECRET_KEY", "SECURITY_PASSWORD_SALT"):
|
|
101
|
+
sample = sample.replace(
|
|
102
|
+
f"\n{key}=\n", f'\n{key}="{secrets.token_urlsafe(32)}"\n'
|
|
103
|
+
)
|
|
104
|
+
return sample
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def generate_app(name: str, target: Path, source: str | None = None) -> list[str]:
|
|
108
|
+
"""Write a new app package at `target` from the app template."""
|
|
109
|
+
validate_name(name)
|
|
110
|
+
if target.exists():
|
|
111
|
+
raise FileExistsError(f"{target} already exists. Choose another directory.")
|
|
112
|
+
|
|
113
|
+
major = int(stk.__version__.split(".")[0])
|
|
114
|
+
tokens = {
|
|
115
|
+
"__app__": name,
|
|
116
|
+
"__App__": name.replace("_", " ").title(),
|
|
117
|
+
"__stk_version__": stk.__version__,
|
|
118
|
+
"__stk_next__": str(major + 1),
|
|
119
|
+
}
|
|
120
|
+
actions = []
|
|
121
|
+
for template in sorted(APP_TEMPLATE.rglob("*")):
|
|
122
|
+
relative = template.relative_to(APP_TEMPLATE)
|
|
123
|
+
# Tool caches (__pycache__, .ruff_cache) are not part of the template.
|
|
124
|
+
if template.is_dir() or any(
|
|
125
|
+
part.startswith((".", "__pycache__")) for part in relative.parts
|
|
126
|
+
):
|
|
127
|
+
continue
|
|
128
|
+
parts = [
|
|
129
|
+
DOTFILES.get(part, part).replace("__app__", name) for part in relative.parts
|
|
130
|
+
]
|
|
131
|
+
path = target.joinpath(*parts)
|
|
132
|
+
text = template.read_text()
|
|
133
|
+
for token, value in tokens.items():
|
|
134
|
+
text = text.replace(token, value)
|
|
135
|
+
if source is not None and parts == ["pyproject.toml"]:
|
|
136
|
+
text += _local_source(target, source)
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
path.write_text(text)
|
|
139
|
+
shutil.copymode(template, path)
|
|
140
|
+
actions.append(f"created {path.relative_to(target.parent)}")
|
|
141
|
+
|
|
142
|
+
# Claude Code reads CLAUDE.md and .claude/skills; Codex and others read these.
|
|
143
|
+
(target / "AGENTS.md").symlink_to("CLAUDE.md")
|
|
144
|
+
(target / ".agents").mkdir()
|
|
145
|
+
(target / ".agents" / "skills").symlink_to("../.claude/skills")
|
|
146
|
+
(target / ".env").write_text(_env((target / ".env-sample").read_text()))
|
|
147
|
+
actions.append(
|
|
148
|
+
f"created {(target / '.env').relative_to(target.parent)} (fresh secrets)"
|
|
149
|
+
)
|
|
150
|
+
return actions
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _add_extension(source: str, package: str, name: str) -> str:
|
|
154
|
+
"""Import `name` in the app module and append it to `extensions = [...]`."""
|
|
155
|
+
listing = re.search(r"^extensions = \[([^\]]*)\]", source, re.M)
|
|
156
|
+
if listing is None:
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
f"no `extensions = [...]` line in the app module. Add {package}.{name} "
|
|
159
|
+
"to the list you pass to Stk(extensions=...) by hand."
|
|
160
|
+
)
|
|
161
|
+
items = [item.strip() for item in listing.group(1).split(",") if item.strip()]
|
|
162
|
+
if name not in items:
|
|
163
|
+
items.append(name)
|
|
164
|
+
source = source.replace(listing.group(0), f"extensions = [{', '.join(items)}]")
|
|
165
|
+
|
|
166
|
+
imports = re.search(rf"^from {package} import ([\w, ]+)$", source, re.M)
|
|
167
|
+
if imports:
|
|
168
|
+
names = sorted({*imports.group(1).split(", "), name})
|
|
169
|
+
return source.replace(
|
|
170
|
+
imports.group(0), f"from {package} import {', '.join(names)}"
|
|
171
|
+
)
|
|
172
|
+
anchor = re.search(rf"^from {package}\.", source, re.M)
|
|
173
|
+
if anchor is None:
|
|
174
|
+
raise RuntimeError(f"add `from {package} import {name}` to the app module")
|
|
175
|
+
return (
|
|
176
|
+
source[: anchor.start()]
|
|
177
|
+
+ f"from {package} import {name}\n"
|
|
178
|
+
+ source[anchor.start() :]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def generate_module(name: str, app) -> list[str]:
|
|
183
|
+
"""Write an extension package into the app and add it to the app's list."""
|
|
184
|
+
validate_name(name)
|
|
185
|
+
root = Path(app.root_path)
|
|
186
|
+
package = app.import_name.split(".")[0]
|
|
187
|
+
app_module = Path(sys.modules[app.import_name].__file__)
|
|
188
|
+
module = root / name
|
|
189
|
+
if module.exists():
|
|
190
|
+
raise FileExistsError(f"{module} already exists. Remove it first.")
|
|
191
|
+
|
|
192
|
+
files = {
|
|
193
|
+
module / "__init__.py": render_init(name, package),
|
|
194
|
+
module / "models.py": render_models(name),
|
|
195
|
+
module / "views.py": render_views(name, package),
|
|
196
|
+
module / "templates" / name / "index.html": render_template_html(name),
|
|
197
|
+
}
|
|
198
|
+
project = root.parent
|
|
199
|
+
actions = []
|
|
200
|
+
for path, text in files.items():
|
|
201
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
path.write_text(text)
|
|
203
|
+
actions.append(f"created {path.relative_to(project)}")
|
|
204
|
+
|
|
205
|
+
app_module.write_text(_add_extension(app_module.read_text(), package, name))
|
|
206
|
+
actions.append(
|
|
207
|
+
f"added {name} to the extensions in {app_module.relative_to(project)}"
|
|
208
|
+
)
|
|
209
|
+
return actions
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Pure rendering functions for scaffold templates.
|
|
2
|
+
|
|
3
|
+
Each function takes a name (snake_case) and returns the file content as a string.
|
|
4
|
+
No filesystem I/O here -- keeps this fully unit-testable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from string import Template
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _t(src: str, **kwargs) -> str:
|
|
11
|
+
return Template(src).substitute(**kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _cls(name: str) -> str:
|
|
15
|
+
return "".join(w.capitalize() for w in name.split("_"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def plural(name: str) -> str:
|
|
19
|
+
"""English plural of a singular snake_case name: invoice_line -> invoice_lines."""
|
|
20
|
+
if name.endswith(("s", "x", "z", "ch", "sh")):
|
|
21
|
+
return name + "es"
|
|
22
|
+
if name.endswith("y") and name[-2:-1] not in "aeiou":
|
|
23
|
+
return name[:-1] + "ies"
|
|
24
|
+
return name + "s"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_init(name: str, package: str) -> str:
|
|
28
|
+
return _t(
|
|
29
|
+
'''\
|
|
30
|
+
"""$title: blueprint, model, page, and sidebar entry."""
|
|
31
|
+
|
|
32
|
+
from $package.$name.views import bp_$name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def init_app(app):
|
|
36
|
+
app.register_blueprint(bp_$name)
|
|
37
|
+
app.navigation.append(
|
|
38
|
+
{
|
|
39
|
+
"title": "$plural_title",
|
|
40
|
+
"icon": "ti ti-table",
|
|
41
|
+
"to": "/$plural/",
|
|
42
|
+
"role": "admin",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
''',
|
|
46
|
+
name=name,
|
|
47
|
+
package=package,
|
|
48
|
+
title=name.replace("_", " ").title(),
|
|
49
|
+
plural=plural(name),
|
|
50
|
+
plural_title=plural(name).replace("_", " ").title(),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render_models(name: str) -> str:
|
|
55
|
+
cls = _cls(name)
|
|
56
|
+
return _t(
|
|
57
|
+
'''\
|
|
58
|
+
"""Models for $name blueprint."""
|
|
59
|
+
|
|
60
|
+
import dataclasses
|
|
61
|
+
from datetime import datetime
|
|
62
|
+
|
|
63
|
+
from sqlalchemy import Column, DateTime, Integer, String
|
|
64
|
+
from stk.extensions import Base
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclasses.dataclass
|
|
68
|
+
class $cls(Base):
|
|
69
|
+
__tablename__ = "$name"
|
|
70
|
+
|
|
71
|
+
id = Column(Integer, primary_key=True)
|
|
72
|
+
name = Column(String(255), nullable=False)
|
|
73
|
+
description = Column(String(1024), nullable=True)
|
|
74
|
+
created_at = Column(DateTime, default=datetime.now, nullable=False)
|
|
75
|
+
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict:
|
|
78
|
+
return {
|
|
79
|
+
"id": self.id,
|
|
80
|
+
"name": self.name,
|
|
81
|
+
"description": self.description,
|
|
82
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
83
|
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async def from_dict(self, data: dict) -> "$cls":
|
|
87
|
+
self.name = data.get("name", self.name)
|
|
88
|
+
self.description = data.get("description", self.description)
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
return f"<$cls {self.id}: {self.name}>"
|
|
93
|
+
''',
|
|
94
|
+
name=name,
|
|
95
|
+
cls=cls,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_views(name: str, package: str) -> str:
|
|
100
|
+
cls = _cls(name)
|
|
101
|
+
plural_name = plural(name)
|
|
102
|
+
# Pre-compute all function names to avoid string.Template misparses
|
|
103
|
+
fn_page = f"{plural_name}_page"
|
|
104
|
+
fn_list = f"api_{plural_name}_list"
|
|
105
|
+
fn_create = f"api_{name}_create"
|
|
106
|
+
fn_update = f"api_{name}_update"
|
|
107
|
+
fn_delete = f"api_{name}_delete"
|
|
108
|
+
return _t(
|
|
109
|
+
'''\
|
|
110
|
+
"""Views for $name blueprint."""
|
|
111
|
+
|
|
112
|
+
import logging
|
|
113
|
+
|
|
114
|
+
import orjson as json
|
|
115
|
+
from quart import Blueprint, Response, g, render_template, request
|
|
116
|
+
from quart_security import auth_required, current_user, roles_required
|
|
117
|
+
from sqlalchemy import func, select
|
|
118
|
+
from stk.user.models import Activity
|
|
119
|
+
|
|
120
|
+
from $package.$name.models import $cls
|
|
121
|
+
|
|
122
|
+
log = logging.getLogger(__name__)
|
|
123
|
+
|
|
124
|
+
bp_$name = Blueprint("$name", __name__)
|
|
125
|
+
|
|
126
|
+
PER_PAGE = 25
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@bp_$name.before_request
|
|
130
|
+
@auth_required("session")
|
|
131
|
+
@roles_required("admin")
|
|
132
|
+
async def before_request():
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@bp_$name.get("/$plural/")
|
|
137
|
+
async def $fn_page():
|
|
138
|
+
return await render_template("$name/index.html")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@bp_$name.get("/api/$plural")
|
|
142
|
+
async def $fn_list():
|
|
143
|
+
page = request.args.get("page", 1, type=int)
|
|
144
|
+
per_page = request.args.get("per_page", PER_PAGE, type=int)
|
|
145
|
+
search = request.args.get("search", "").strip()
|
|
146
|
+
|
|
147
|
+
query = select($cls)
|
|
148
|
+
count_query = select(func.count()).select_from($cls)
|
|
149
|
+
|
|
150
|
+
if search:
|
|
151
|
+
query = query.where($cls.name.ilike(f"%{search}%"))
|
|
152
|
+
count_query = count_query.where($cls.name.ilike(f"%{search}%"))
|
|
153
|
+
|
|
154
|
+
total = (await g.db_session.execute(count_query)).scalar()
|
|
155
|
+
result = await g.db_session.execute(
|
|
156
|
+
query.offset((page - 1) * per_page).limit(per_page)
|
|
157
|
+
)
|
|
158
|
+
items = [item.to_dict() for item in result.scalars().all()]
|
|
159
|
+
|
|
160
|
+
return Response(
|
|
161
|
+
json.dumps({"items": items, "total": total, "perPage": per_page}),
|
|
162
|
+
content_type="application/json",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@bp_$name.post("/api/$name/")
|
|
167
|
+
async def $fn_create():
|
|
168
|
+
json_data = await request.json
|
|
169
|
+
item_data = json_data.get("item", {})
|
|
170
|
+
item = $cls()
|
|
171
|
+
await item.from_dict(item_data)
|
|
172
|
+
g.db_session.add(item)
|
|
173
|
+
try:
|
|
174
|
+
await g.db_session.flush()
|
|
175
|
+
await Activity.register(current_user.id, "$cls Create", item.to_dict())
|
|
176
|
+
await g.db_session.commit()
|
|
177
|
+
return {"message": "$cls successfully created!"}
|
|
178
|
+
except Exception:
|
|
179
|
+
await g.db_session.rollback()
|
|
180
|
+
log.exception("Error creating $name")
|
|
181
|
+
return {"message": "Error creating $name"}, 412
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@bp_$name.post("/api/$name/<int:id>")
|
|
185
|
+
async def $fn_update(id):
|
|
186
|
+
item = await g.db_session.get($cls, id)
|
|
187
|
+
if item is None:
|
|
188
|
+
return {"message": "$cls not found"}, 404
|
|
189
|
+
json_data = await request.json
|
|
190
|
+
item_data = json_data.get("item", {})
|
|
191
|
+
old_data = item.to_dict()
|
|
192
|
+
try:
|
|
193
|
+
await item.from_dict(item_data)
|
|
194
|
+
await g.db_session.flush()
|
|
195
|
+
await Activity.register(
|
|
196
|
+
current_user.id,
|
|
197
|
+
"$cls Update",
|
|
198
|
+
{"old": old_data, "new": item.to_dict()},
|
|
199
|
+
)
|
|
200
|
+
await g.db_session.commit()
|
|
201
|
+
return {"message": "$cls successfully updated!"}
|
|
202
|
+
except Exception:
|
|
203
|
+
await g.db_session.rollback()
|
|
204
|
+
log.exception("Error updating $name")
|
|
205
|
+
return {"message": "Error updating $name"}, 412
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@bp_$name.route("/api/$name/<int:id>", methods=["DELETE"])
|
|
209
|
+
async def $fn_delete(id):
|
|
210
|
+
item = await g.db_session.get($cls, id)
|
|
211
|
+
if item is None:
|
|
212
|
+
return {"message": "$cls not found"}, 404
|
|
213
|
+
old_data = item.to_dict()
|
|
214
|
+
try:
|
|
215
|
+
await g.db_session.delete(item)
|
|
216
|
+
await Activity.register(current_user.id, "$cls Delete", old_data)
|
|
217
|
+
await g.db_session.commit()
|
|
218
|
+
return {"message": "$cls successfully deleted!"}
|
|
219
|
+
except Exception:
|
|
220
|
+
await g.db_session.rollback()
|
|
221
|
+
log.exception("Error deleting $name")
|
|
222
|
+
return {"message": "Error deleting $name"}, 412
|
|
223
|
+
''',
|
|
224
|
+
name=name,
|
|
225
|
+
package=package,
|
|
226
|
+
cls=cls,
|
|
227
|
+
plural=plural_name,
|
|
228
|
+
fn_page=fn_page,
|
|
229
|
+
fn_list=fn_list,
|
|
230
|
+
fn_create=fn_create,
|
|
231
|
+
fn_update=fn_update,
|
|
232
|
+
fn_delete=fn_delete,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def render_template_html(name: str) -> str:
|
|
237
|
+
cls = _cls(name)
|
|
238
|
+
title = name.replace("_", " ").title()
|
|
239
|
+
plural_name = plural(name)
|
|
240
|
+
plural_title = plural_name.replace("_", " ").title()
|
|
241
|
+
# JS template literals use ${...} -- we need literal dollar-signs for those.
|
|
242
|
+
# string.Template uses $$ for a literal $, so $${ becomes ${.
|
|
243
|
+
return _t(
|
|
244
|
+
"""\
|
|
245
|
+
{% extends 'layout.html' %} {% block css %} {% endblock %}
|
|
246
|
+
{% block content %}
|
|
247
|
+
|
|
248
|
+
<v-card class="ma-2 mt-12 w-100 h-100">
|
|
249
|
+
<v-toolbar>
|
|
250
|
+
<v-toolbar-title>$plural_title</v-toolbar-title>
|
|
251
|
+
<v-spacer></v-spacer>
|
|
252
|
+
</v-toolbar>
|
|
253
|
+
<v-card-text>
|
|
254
|
+
|
|
255
|
+
<v-data-table-server
|
|
256
|
+
:items="items"
|
|
257
|
+
:items-length="itemsLength"
|
|
258
|
+
:headers="headers"
|
|
259
|
+
:page="options.page"
|
|
260
|
+
:items-per-page="options.itemsPerPage"
|
|
261
|
+
@update:options="refresh"
|
|
262
|
+
hover
|
|
263
|
+
>
|
|
264
|
+
|
|
265
|
+
<template v-slot:top>
|
|
266
|
+
<v-toolbar class="mb-4" dense elevation="0" color="transparent">
|
|
267
|
+
<v-text-field
|
|
268
|
+
v-model="search"
|
|
269
|
+
density="compact"
|
|
270
|
+
label="Search"
|
|
271
|
+
prepend-inner-icon="ti ti-search"
|
|
272
|
+
variant="outlined"
|
|
273
|
+
hide-details
|
|
274
|
+
single-line
|
|
275
|
+
class="mr-4"
|
|
276
|
+
style="max-width:320px"
|
|
277
|
+
@update:model-value="refresh()"
|
|
278
|
+
></v-text-field>
|
|
279
|
+
<v-btn class="ml-auto" @click="createItem" size="small" color="primary" variant="elevated">
|
|
280
|
+
<template v-slot:prepend><i class="ti ti-plus"></i></template>
|
|
281
|
+
Add $title
|
|
282
|
+
</v-btn>
|
|
283
|
+
</v-toolbar>
|
|
284
|
+
</template>
|
|
285
|
+
|
|
286
|
+
<template v-slot:item.actions="{ item }">
|
|
287
|
+
<v-icon small class="mr-2" @click="editItem(item)">ti ti-pencil</v-icon>
|
|
288
|
+
<v-icon small @click="deleteItem(item)">ti ti-trash</v-icon>
|
|
289
|
+
</template>
|
|
290
|
+
|
|
291
|
+
</v-data-table-server>
|
|
292
|
+
|
|
293
|
+
</v-card-text>
|
|
294
|
+
</v-card>
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
<!--Edit Dialog-->
|
|
298
|
+
<v-dialog v-model="edialog" width="660">
|
|
299
|
+
<v-card v-if="edialog">
|
|
300
|
+
<v-toolbar>
|
|
301
|
+
<v-toolbar-title>$${eidialog_title}</v-toolbar-title>
|
|
302
|
+
<template v-slot:append>
|
|
303
|
+
<v-btn @click="edialog=false" size="small" icon="ti ti-x" variant="text"></v-btn>
|
|
304
|
+
</template>
|
|
305
|
+
</v-toolbar>
|
|
306
|
+
<v-card-text>
|
|
307
|
+
<v-text-field label="Name" v-model="eitem.name" required></v-text-field>
|
|
308
|
+
<v-textarea label="Description" v-model="eitem.description" rows="3"></v-textarea>
|
|
309
|
+
</v-card-text>
|
|
310
|
+
<v-card-actions>
|
|
311
|
+
<v-spacer></v-spacer>
|
|
312
|
+
<v-btn color="primary" @click="saveItem" variant="elevated">Save</v-btn>
|
|
313
|
+
</v-card-actions>
|
|
314
|
+
</v-card>
|
|
315
|
+
</v-dialog>
|
|
316
|
+
|
|
317
|
+
<v-snackbar size="small" class="d-flex" v-model="snackBar" rounded="pill" elevation="25">
|
|
318
|
+
$${snackMessage}
|
|
319
|
+
<template v-slot:actions>
|
|
320
|
+
<v-btn @click="snackBar=false" icon="ti ti-x" class="ml-auto" size="small" variant="text"></v-btn>
|
|
321
|
+
</template>
|
|
322
|
+
</v-snackbar>
|
|
323
|
+
|
|
324
|
+
{% endblock %} {% block js %}
|
|
325
|
+
|
|
326
|
+
<script>
|
|
327
|
+
const {createApp, toRaw} = Vue;
|
|
328
|
+
const {createVuetify} = Vuetify;
|
|
329
|
+
|
|
330
|
+
const vuetify = createVuetify(config.vuetifyConfig);
|
|
331
|
+
|
|
332
|
+
window.app = createApp({
|
|
333
|
+
mixins: [layoutMixin],
|
|
334
|
+
data() {
|
|
335
|
+
return {
|
|
336
|
+
errors: "",
|
|
337
|
+
snackBar: false,
|
|
338
|
+
snackMessage: "",
|
|
339
|
+
search: "",
|
|
340
|
+
items: [],
|
|
341
|
+
itemsLength: 0,
|
|
342
|
+
options: {
|
|
343
|
+
page: 1,
|
|
344
|
+
itemsPerPage: 25
|
|
345
|
+
},
|
|
346
|
+
headers: [
|
|
347
|
+
{title: 'ID', value: 'id'},
|
|
348
|
+
{title: 'Name', value: 'name'},
|
|
349
|
+
{title: 'Description', value: 'description'},
|
|
350
|
+
{title: 'Actions', value: 'actions', sortable: false}
|
|
351
|
+
],
|
|
352
|
+
edialog: false,
|
|
353
|
+
eitem: {id: "", name: "", description: ""},
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
mounted() {
|
|
358
|
+
// Table triggers refresh via @update:options on load
|
|
359
|
+
},
|
|
360
|
+
delimiters: config.delimiters,
|
|
361
|
+
|
|
362
|
+
computed: {
|
|
363
|
+
eidialog_title() {
|
|
364
|
+
return this.eitem.id ? 'Edit $title' : 'New $title';
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
methods: {
|
|
369
|
+
showSnack(message) {
|
|
370
|
+
this.snackMessage = message;
|
|
371
|
+
this.snackBar = true;
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
refresh(options) {
|
|
375
|
+
if (options) {
|
|
376
|
+
this.options = {
|
|
377
|
+
...this.options,
|
|
378
|
+
page: options.page,
|
|
379
|
+
itemsPerPage: options.itemsPerPage
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
axios.get(`/api/$plural?page=$${this.options.page}&per_page=$${this.options.itemsPerPage}&search=$${this.search}`)
|
|
383
|
+
.then(res => {
|
|
384
|
+
this.items = res.data.items;
|
|
385
|
+
this.itemsLength = res.data.total;
|
|
386
|
+
if (res.data.perPage) this.options.itemsPerPage = res.data.perPage;
|
|
387
|
+
})
|
|
388
|
+
.catch(error => {
|
|
389
|
+
console.error('Error fetching $plural:', error);
|
|
390
|
+
this.showSnack('Failed to load $plural');
|
|
391
|
+
});
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
createItem() {
|
|
395
|
+
this.eitem = {id: "", name: "", description: ""};
|
|
396
|
+
this.edialog = true;
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
editItem(item) {
|
|
400
|
+
this.eitem = toRaw(item);
|
|
401
|
+
this.$$nextTick(() => { this.edialog = true; });
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
saveItem() {
|
|
405
|
+
if (this.eitem.id) {
|
|
406
|
+
axios.post(`/api/$name/$${this.eitem.id}`, {item: this.eitem})
|
|
407
|
+
.then(res => { this.showSnack(res.data?.message); this.refresh(); })
|
|
408
|
+
.catch(err => { this.showSnack(err.response?.data?.message || 'Error'); });
|
|
409
|
+
} else {
|
|
410
|
+
axios.post('/api/$name/', {item: this.eitem})
|
|
411
|
+
.then(res => { this.showSnack(res.data?.message); this.refresh(); })
|
|
412
|
+
.catch(err => { this.showSnack(err.response?.data?.message || 'Error'); });
|
|
413
|
+
}
|
|
414
|
+
this.edialog = false;
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
deleteItem(item) {
|
|
418
|
+
if (confirm('Are you sure?')) {
|
|
419
|
+
axios.delete(`/api/$name/$${item.id}`)
|
|
420
|
+
.then(res => { this.showSnack(res.data?.message); this.refresh(); })
|
|
421
|
+
.catch(err => { this.showSnack(err.response?.data?.message || 'Error'); });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
registerStkComponents(app);
|
|
428
|
+
app.use(vuetify).mount("#app");
|
|
429
|
+
</script>
|
|
430
|
+
{% endblock %}
|
|
431
|
+
""",
|
|
432
|
+
name=name,
|
|
433
|
+
cls=cls,
|
|
434
|
+
title=title,
|
|
435
|
+
plural=plural_name,
|
|
436
|
+
plural_title=plural_title,
|
|
437
|
+
)
|