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