hunt-framework 0.2.2__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 (189) hide show
  1. hunt/__init__.py +15 -0
  2. hunt/admin/__init__.py +53 -0
  3. hunt/admin/action.py +58 -0
  4. hunt/admin/application.py +211 -0
  5. hunt/admin/console/__init__.py +0 -0
  6. hunt/admin/console/make_admin_resource.py +140 -0
  7. hunt/admin/controllers/__init__.py +0 -0
  8. hunt/admin/controllers/action.py +87 -0
  9. hunt/admin/controllers/dashboard.py +21 -0
  10. hunt/admin/controllers/resource.py +298 -0
  11. hunt/admin/controllers/search.py +53 -0
  12. hunt/admin/field.py +123 -0
  13. hunt/admin/fields/__init__.py +30 -0
  14. hunt/admin/fields/badge.py +25 -0
  15. hunt/admin/fields/belongs_to.py +21 -0
  16. hunt/admin/fields/boolean.py +15 -0
  17. hunt/admin/fields/datetime_.py +58 -0
  18. hunt/admin/fields/has_many.py +22 -0
  19. hunt/admin/fields/image.py +51 -0
  20. hunt/admin/fields/number.py +43 -0
  21. hunt/admin/fields/richtext.py +71 -0
  22. hunt/admin/fields/select.py +30 -0
  23. hunt/admin/fields/text.py +24 -0
  24. hunt/admin/fields/textarea.py +16 -0
  25. hunt/admin/filter.py +86 -0
  26. hunt/admin/metrics/__init__.py +5 -0
  27. hunt/admin/metrics/partition.py +44 -0
  28. hunt/admin/metrics/trend.py +33 -0
  29. hunt/admin/metrics/value.py +64 -0
  30. hunt/admin/middleware/__init__.py +0 -0
  31. hunt/admin/middleware/gate.py +42 -0
  32. hunt/admin/navigation.py +79 -0
  33. hunt/admin/resource.py +163 -0
  34. hunt/admin/templates/admin/dashboard.html +87 -0
  35. hunt/admin/templates/admin/layout.html +268 -0
  36. hunt/admin/templates/admin/resource/_form.html +197 -0
  37. hunt/admin/templates/admin/resource/create.html +79 -0
  38. hunt/admin/templates/admin/resource/edit.html +84 -0
  39. hunt/admin/templates/admin/resource/index.html +388 -0
  40. hunt/admin/templates/admin/resource/show.html +167 -0
  41. hunt/application.py +123 -0
  42. hunt/auth/__init__.py +6 -0
  43. hunt/auth/gate.py +155 -0
  44. hunt/auth/manager.py +261 -0
  45. hunt/auth/passwords.py +125 -0
  46. hunt/auth/verification.py +61 -0
  47. hunt/cache/__init__.py +3 -0
  48. hunt/cache/manager.py +171 -0
  49. hunt/config/__init__.py +4 -0
  50. hunt/config/loader.py +28 -0
  51. hunt/config/repository.py +33 -0
  52. hunt/console/__init__.py +3 -0
  53. hunt/console/command.py +9 -0
  54. hunt/console/commands/__init__.py +0 -0
  55. hunt/console/commands/cache.py +35 -0
  56. hunt/console/commands/config_cache.py +62 -0
  57. hunt/console/commands/db/__init__.py +0 -0
  58. hunt/console/commands/db/seed.py +67 -0
  59. hunt/console/commands/key_generate.py +33 -0
  60. hunt/console/commands/make/__init__.py +11 -0
  61. hunt/console/commands/make/command.py +44 -0
  62. hunt/console/commands/make/controller.py +96 -0
  63. hunt/console/commands/make/event.py +29 -0
  64. hunt/console/commands/make/factory.py +37 -0
  65. hunt/console/commands/make/job.py +37 -0
  66. hunt/console/commands/make/listener.py +48 -0
  67. hunt/console/commands/make/mail.py +52 -0
  68. hunt/console/commands/make/middleware.py +33 -0
  69. hunt/console/commands/make/migration.py +92 -0
  70. hunt/console/commands/make/model.py +54 -0
  71. hunt/console/commands/make/notification.py +45 -0
  72. hunt/console/commands/make/observer.py +70 -0
  73. hunt/console/commands/make/policy.py +59 -0
  74. hunt/console/commands/make/request.py +37 -0
  75. hunt/console/commands/make/resource.py +46 -0
  76. hunt/console/commands/make/rule.py +39 -0
  77. hunt/console/commands/make/seeder.py +30 -0
  78. hunt/console/commands/migrate.py +84 -0
  79. hunt/console/commands/new.py +1029 -0
  80. hunt/console/commands/queue_failed.py +74 -0
  81. hunt/console/commands/queue_table.py +54 -0
  82. hunt/console/commands/queue_work.py +133 -0
  83. hunt/console/commands/route_list.py +32 -0
  84. hunt/console/commands/schedule_list.py +42 -0
  85. hunt/console/commands/schedule_run.py +40 -0
  86. hunt/console/commands/serve.py +27 -0
  87. hunt/console/commands/storage_link.py +28 -0
  88. hunt/console/commands/tinker.py +31 -0
  89. hunt/console/commands/upgrade.py +213 -0
  90. hunt/console/commands/view_cache.py +42 -0
  91. hunt/console/kernel.py +105 -0
  92. hunt/container/__init__.py +5 -0
  93. hunt/container/container.py +118 -0
  94. hunt/container/facade.py +40 -0
  95. hunt/container/provider.py +17 -0
  96. hunt/database/__init__.py +5 -0
  97. hunt/database/connection.py +100 -0
  98. hunt/database/factory.py +82 -0
  99. hunt/database/model.py +394 -0
  100. hunt/database/query_builder.py +545 -0
  101. hunt/database/relations/__init__.py +6 -0
  102. hunt/database/relations/belongs_to.py +45 -0
  103. hunt/database/relations/belongs_to_many.py +134 -0
  104. hunt/database/relations/has_many.py +47 -0
  105. hunt/database/relations/has_one.py +43 -0
  106. hunt/database/schema/__init__.py +5 -0
  107. hunt/database/schema/blueprint.py +360 -0
  108. hunt/database/schema/builder.py +250 -0
  109. hunt/database/schema/migration.py +139 -0
  110. hunt/database/seeder.py +13 -0
  111. hunt/events/__init__.py +5 -0
  112. hunt/events/dispatcher.py +59 -0
  113. hunt/events/provider.py +103 -0
  114. hunt/events/queued.py +89 -0
  115. hunt/exceptions/__init__.py +0 -0
  116. hunt/exceptions/handler.py +96 -0
  117. hunt/http/__init__.py +32 -0
  118. hunt/http/client.py +383 -0
  119. hunt/http/controller.py +37 -0
  120. hunt/http/kernel.py +287 -0
  121. hunt/http/middleware/__init__.py +15 -0
  122. hunt/http/middleware/authenticate.py +21 -0
  123. hunt/http/middleware/cors.py +64 -0
  124. hunt/http/middleware/csrf.py +45 -0
  125. hunt/http/middleware/session.py +79 -0
  126. hunt/http/middleware/throttle.py +55 -0
  127. hunt/http/middleware/verified.py +20 -0
  128. hunt/http/request.py +373 -0
  129. hunt/http/response.py +206 -0
  130. hunt/http/route.py +65 -0
  131. hunt/http/router.py +139 -0
  132. hunt/log/__init__.py +3 -0
  133. hunt/log/manager.py +80 -0
  134. hunt/mail/__init__.py +5 -0
  135. hunt/mail/mailable.py +130 -0
  136. hunt/mail/manager.py +312 -0
  137. hunt/mail/message.py +71 -0
  138. hunt/notifications/__init__.py +5 -0
  139. hunt/notifications/channels/__init__.py +0 -0
  140. hunt/notifications/channels/database.py +50 -0
  141. hunt/notifications/channels/mail.py +35 -0
  142. hunt/notifications/fake.py +81 -0
  143. hunt/notifications/notifiable.py +144 -0
  144. hunt/notifications/notification.py +53 -0
  145. hunt/queue/__init__.py +4 -0
  146. hunt/queue/drivers/__init__.py +0 -0
  147. hunt/queue/drivers/database.py +128 -0
  148. hunt/queue/drivers/redis.py +131 -0
  149. hunt/queue/drivers/sync.py +37 -0
  150. hunt/queue/job.py +40 -0
  151. hunt/queue/manager.py +44 -0
  152. hunt/scheduling/__init__.py +3 -0
  153. hunt/scheduling/cron.py +52 -0
  154. hunt/scheduling/scheduler.py +345 -0
  155. hunt/security/__init__.py +0 -0
  156. hunt/security/signing.py +42 -0
  157. hunt/session/__init__.py +3 -0
  158. hunt/session/store.py +144 -0
  159. hunt/storage/__init__.py +5 -0
  160. hunt/storage/local.py +221 -0
  161. hunt/storage/manager.py +59 -0
  162. hunt/storage/s3.py +121 -0
  163. hunt/support/__init__.py +33 -0
  164. hunt/support/collection.py +171 -0
  165. hunt/support/helpers.py +152 -0
  166. hunt/support/str.py +176 -0
  167. hunt/testing/__init__.py +4 -0
  168. hunt/testing/fakes.py +186 -0
  169. hunt/testing/test_case.py +278 -0
  170. hunt/translation/__init__.py +4 -0
  171. hunt/translation/provider.py +21 -0
  172. hunt/translation/translator.py +210 -0
  173. hunt/validation/__init__.py +4 -0
  174. hunt/validation/form_request.py +38 -0
  175. hunt/validation/rules.py +521 -0
  176. hunt/validation/validator.py +137 -0
  177. hunt/view/__init__.py +4 -0
  178. hunt/view/directives.py +568 -0
  179. hunt/view/factory.py +206 -0
  180. hunt/views/auth/forgot_password.html +25 -0
  181. hunt/views/auth/layout.html +39 -0
  182. hunt/views/auth/login.html +35 -0
  183. hunt/views/auth/register.html +33 -0
  184. hunt/views/auth/reset_password.html +29 -0
  185. hunt_framework-0.2.2.dist-info/METADATA +567 -0
  186. hunt_framework-0.2.2.dist-info/RECORD +189 -0
  187. hunt_framework-0.2.2.dist-info/WHEEL +4 -0
  188. hunt_framework-0.2.2.dist-info/entry_points.txt +2 -0
  189. hunt_framework-0.2.2.dist-info/licenses/LICENSE +21 -0
hunt/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ hunt — A Python web framework.
3
+
4
+ Quick start:
5
+ from hunt.application import Application
6
+ from hunt.http.router import Router
7
+ from hunt.http.kernel import HttpKernel
8
+ from hunt.database.model import Model
9
+ from hunt.database.schema.builder import Schema
10
+ from hunt.database.schema.migration import Migration
11
+ from hunt.view.factory import ViewFactory
12
+ from hunt.validation.validator import Validator
13
+ """
14
+
15
+ __version__ = "0.2.2"
hunt/admin/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ from hunt.admin.action import Action, ActionResponse
2
+ from hunt.admin.application import Admin
3
+ from hunt.admin.fields import (
4
+ Badge,
5
+ BelongsTo,
6
+ Boolean,
7
+ Currency,
8
+ Date,
9
+ DateTime,
10
+ Email,
11
+ HasMany,
12
+ Number,
13
+ Password,
14
+ Select,
15
+ Slug,
16
+ Text,
17
+ Textarea,
18
+ )
19
+ from hunt.admin.filter import BooleanFilter, Filter, SelectFilter, TrashedFilter
20
+ from hunt.admin.metrics import PartitionMetric, TrendMetric, ValueMetric
21
+ from hunt.admin.navigation import NavGroup, NavLink, NavResource
22
+ from hunt.admin.resource import AdminResource
23
+
24
+ __all__ = [
25
+ "Action",
26
+ "ActionResponse",
27
+ "Admin",
28
+ "AdminResource",
29
+ "Badge",
30
+ "BelongsTo",
31
+ "Boolean",
32
+ "BooleanFilter",
33
+ "Currency",
34
+ "Date",
35
+ "DateTime",
36
+ "Email",
37
+ "Filter",
38
+ "HasMany",
39
+ "NavGroup",
40
+ "NavLink",
41
+ "NavResource",
42
+ "Number",
43
+ "PartitionMetric",
44
+ "Password",
45
+ "Select",
46
+ "SelectFilter",
47
+ "Slug",
48
+ "Text",
49
+ "Textarea",
50
+ "TrashedFilter",
51
+ "TrendMetric",
52
+ "ValueMetric",
53
+ ]
hunt/admin/action.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from hunt.support.str import Str
4
+
5
+
6
+ class ActionResponse:
7
+ """Result returned from an Action.handle() call."""
8
+
9
+ def __init__(
10
+ self,
11
+ type: str,
12
+ text: str = "",
13
+ url: str = "",
14
+ message_type: str = "success",
15
+ ) -> None:
16
+ self.type = type # "message" | "redirect"
17
+ self.text = text
18
+ self.url = url
19
+ self.message_type = message_type # "success" | "error" | "warning" | "info"
20
+
21
+ @classmethod
22
+ def success(cls, text: str) -> ActionResponse:
23
+ return cls(type="message", text=text, message_type="success")
24
+
25
+ @classmethod
26
+ def error(cls, text: str) -> ActionResponse:
27
+ return cls(type="message", text=text, message_type="error")
28
+
29
+ @classmethod
30
+ def message(cls, text: str, type: str = "success") -> ActionResponse:
31
+ return cls(type="message", text=text, message_type=type)
32
+
33
+ @classmethod
34
+ def redirect(cls, url: str) -> ActionResponse:
35
+ return cls(type="redirect", url=url)
36
+
37
+ def to_dict(self) -> dict:
38
+ return {
39
+ "type": self.type,
40
+ "text": self.text,
41
+ "url": self.url,
42
+ "message_type": self.message_type,
43
+ }
44
+
45
+
46
+ class Action:
47
+ """Base class for admin actions that operate on one or more model instances."""
48
+
49
+ name: str = "Action"
50
+ destructive: bool = False
51
+ confirmation_text: str = ""
52
+
53
+ @classmethod
54
+ def slug(cls) -> str:
55
+ return Str.snake(cls.name).replace(" ", "_").lower()
56
+
57
+ def handle(self, request: object, models: list) -> ActionResponse:
58
+ raise NotImplementedError(f"{type(self).__name__} must implement handle()")
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
10
+
11
+ from hunt.http.response import Response
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def _get_env() -> Environment:
16
+ package_templates = Path(__file__).parent / "templates"
17
+ loaders: list = []
18
+ # App-level overrides: resources/views takes priority over package templates
19
+ app_views = Path.cwd() / "resources" / "views"
20
+ if app_views.is_dir():
21
+ loaders.append(FileSystemLoader(str(app_views)))
22
+ loaders.append(FileSystemLoader(str(package_templates)))
23
+ env = Environment(
24
+ loader=ChoiceLoader(loaders),
25
+ autoescape=select_autoescape(["html", "xml"]),
26
+ )
27
+ env.filters["tojson"] = lambda value, **kw: json.dumps(value, default=str, **kw)
28
+ return env
29
+
30
+
31
+ class _Admin:
32
+ """
33
+ Singleton admin application.
34
+
35
+ Usage::
36
+
37
+ from hunt.admin import Admin
38
+
39
+ @Admin.resource
40
+ class PostResource(AdminResource):
41
+ ...
42
+
43
+ Admin.dashboard(TotalPostsMetric)
44
+ Admin.gate(lambda request: Auth.user() and Auth.user().is_admin)
45
+ Admin.register_to(router)
46
+ """
47
+
48
+ def __init__(self) -> None:
49
+ self._resources: list[type] = []
50
+ self._dashboard_cards: list = []
51
+ self._tools: list[dict] = []
52
+ self._gate: Callable | None = None
53
+ self._nav: list | None = None
54
+ self.prefix: str = "/hunt-admin"
55
+ self.brand_name: str = "Hunt Admin"
56
+
57
+ # ------------------------------------------------------------------
58
+ # Registration API
59
+ # ------------------------------------------------------------------
60
+
61
+ def resource(self, cls: type) -> type:
62
+ """Decorator that registers an AdminResource subclass."""
63
+ self._resources.append(cls)
64
+ return cls
65
+
66
+ def dashboard(self, *cards: Any) -> None:
67
+ """Set the metric cards shown on the dashboard."""
68
+ self._dashboard_cards = list(cards)
69
+
70
+ def gate(self, fn: Callable) -> None:
71
+ """Set a callable that receives the request and returns True/False."""
72
+ self._gate = fn
73
+
74
+ def tool(self, label: str, cls: type) -> None:
75
+ """Register a custom tool page."""
76
+ self._tools.append({"label": label, "cls": cls})
77
+
78
+ def navigation(self, items: list) -> None:
79
+ """Set a custom navigation structure for the admin sidebar."""
80
+ self._nav = items
81
+
82
+ def _build_nav(self) -> list:
83
+ from hunt.admin.navigation import _DEFAULT_TOOL_ICON, NavGroup, NavLink, NavResource
84
+
85
+ if self._nav is not None:
86
+ return self._nav
87
+ items: list = []
88
+ if self._resources:
89
+ items.append(NavGroup("Resources", [NavResource(r) for r in self._resources]))
90
+ if self._tools:
91
+ tool_links = [
92
+ NavLink(
93
+ t["label"],
94
+ f"{self.prefix}/tools/{t['label'].lower().replace(' ', '-')}",
95
+ icon=_DEFAULT_TOOL_ICON,
96
+ )
97
+ for t in self._tools
98
+ ]
99
+ items.append(NavGroup("Tools", tool_links))
100
+ return items
101
+
102
+ # ------------------------------------------------------------------
103
+ # Resource lookup
104
+ # ------------------------------------------------------------------
105
+
106
+ def find_resource(self, key: str) -> type | None:
107
+ """Find a registered AdminResource class by its slug."""
108
+ for cls in self._resources:
109
+ if cls.slug() == key:
110
+ return cls
111
+ return None
112
+
113
+ # ------------------------------------------------------------------
114
+ # Route registration
115
+ # ------------------------------------------------------------------
116
+
117
+ def register_to(self, router: Any) -> None:
118
+ """Register all admin routes under self.prefix with AdminGate middleware."""
119
+ from hunt.admin.controllers import action as action_ctrl
120
+ from hunt.admin.controllers import dashboard as dash_ctrl
121
+ from hunt.admin.controllers import resource as res_ctrl
122
+ from hunt.admin.controllers import search as search_ctrl
123
+ from hunt.admin.middleware.gate import AdminGate
124
+
125
+ with router.group(prefix=self.prefix, middleware=[AdminGate]):
126
+ # Dashboard
127
+ router.get("/", dash_ctrl.index)
128
+
129
+ # Resource CRUD
130
+ router.get("/resources/{resource_key}", res_ctrl.index)
131
+ router.get("/resources/{resource_key}/create", res_ctrl.create)
132
+ router.post("/resources/{resource_key}", res_ctrl.store)
133
+ router.get("/resources/{resource_key}/{id}", res_ctrl.show)
134
+ router.get("/resources/{resource_key}/{id}/edit", res_ctrl.edit)
135
+ # Use POST with hidden _method field for update/delete (HTML form compat)
136
+ router.post("/resources/{resource_key}/{id}", _method_router)
137
+ router.post("/resources/{resource_key}/{id}/delete", res_ctrl.destroy)
138
+
139
+ # Actions
140
+ router.post("/resources/{resource_key}/actions/{action_slug}", action_ctrl.run)
141
+
142
+ # Global search
143
+ router.get("/search", search_ctrl.index)
144
+
145
+ # ------------------------------------------------------------------
146
+ # Rendering
147
+ # ------------------------------------------------------------------
148
+
149
+ def _render(self, template_name: str, context: dict, status: int = 200) -> Response:
150
+ env = _get_env()
151
+ template = env.get_template(template_name)
152
+ html = template.render(**context)
153
+ return Response(html, status=status)
154
+
155
+ def _base_context(self, request: Any) -> dict:
156
+ from hunt.auth.manager import Auth
157
+
158
+ store = getattr(request, "_session", None)
159
+ flash: dict[str, Any] = {}
160
+ if store is not None:
161
+ flash = store.all_flash()
162
+
163
+ auth_user = None
164
+ try:
165
+ auth_user = Auth.user()
166
+ except Exception:
167
+ pass
168
+
169
+ csrf_token = ""
170
+ if store is not None:
171
+ try:
172
+ csrf_token = store.csrf_token()
173
+ except Exception:
174
+ pass
175
+
176
+ return {
177
+ "admin": self,
178
+ "request": request,
179
+ "resources": self._resources,
180
+ "nav": self._build_nav(),
181
+ "prefix": self.prefix,
182
+ "flash": flash,
183
+ "errors": flash.get("_errors", {}),
184
+ "old": flash.get("_old_input", {}),
185
+ "auth_user": auth_user,
186
+ "brand_name": self.brand_name,
187
+ "tools": self._tools,
188
+ "csrf_token": csrf_token,
189
+ }
190
+
191
+
192
+ def _method_router(request: Any, resource_key: str, id: str) -> Any:
193
+ """
194
+ Dispatch POST /resources/{key}/{id} to update or destroy based on _method field.
195
+
196
+ HTML forms only support GET/POST. A hidden `_method` field with value "PUT",
197
+ "PATCH", or "DELETE" signals the intended semantic method.
198
+ """
199
+ from hunt.admin.controllers import resource as res_ctrl
200
+
201
+ method_override = (request.input("_method") or "").upper()
202
+ if method_override in ("PUT", "PATCH"):
203
+ return res_ctrl.update(request, resource_key, id)
204
+ if method_override == "DELETE":
205
+ return res_ctrl.destroy(request, resource_key, id)
206
+ # Default to update if no override (plain POST to /{id})
207
+ return res_ctrl.update(request, resource_key, id)
208
+
209
+
210
+ # Module-level singleton
211
+ Admin = _Admin()
File without changes
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from hunt.support.str import Str
9
+
10
+ _CLASS_RE = re.compile(r"^class ([A-Za-z_][A-Za-z0-9_]*)\b", re.MULTILINE)
11
+
12
+
13
+ @click.command("make:admin-resource")
14
+ @click.argument("model")
15
+ def make_admin_resource_command(model: str) -> None:
16
+ """Generate a stub AdminResource for MODEL (a filename in app/models/)."""
17
+
18
+ model_slug = Str.snake(model.removesuffix(".py")).lower()
19
+ model_file = Path.cwd() / "app" / "models" / f"{model_slug}.py"
20
+
21
+ if not model_file.exists():
22
+ raise click.ClickException(
23
+ f"Model file not found: app/models/{model_slug}.py\n"
24
+ f" Create it first with: hunt make:model {Str.pascal(model_slug)}"
25
+ )
26
+
27
+ # Read the class name directly from the file
28
+ model_source = model_file.read_text()
29
+ match = _CLASS_RE.search(model_source)
30
+ if not match:
31
+ raise click.ClickException(f"No class definition found in app/models/{model_slug}.py")
32
+ model_class = match.group(1)
33
+
34
+ class_name = f"{model_class}Resource"
35
+ snake_name = Str.snake(class_name)
36
+
37
+ # Check not already registered
38
+ routes_file = Path.cwd() / "routes" / "admin.py"
39
+ if routes_file.exists() and f"Admin.resource({class_name})" in routes_file.read_text():
40
+ raise click.ClickException(f"{class_name} is already registered in routes/admin.py")
41
+
42
+ target_dir = Path.cwd() / "app" / "admin"
43
+ target_dir.mkdir(parents=True, exist_ok=True)
44
+
45
+ file_path = target_dir / f"{snake_name}.py"
46
+ if file_path.exists():
47
+ raise click.ClickException(f"File already exists: app/admin/{snake_name}.py")
48
+
49
+ content = (
50
+ "from hunt.admin import AdminResource\n"
51
+ "from hunt.admin.fields import Text, Number, DateTime\n"
52
+ f"from app.models.{model_slug} import {model_class}\n"
53
+ "\n\n"
54
+ f"class {class_name}(AdminResource):\n"
55
+ f" model = {model_class}\n"
56
+ f' label = "{model_class}"\n'
57
+ ' search_columns = ["id"]\n'
58
+ ' default_order = ("id", "desc")\n'
59
+ " per_page = 15\n"
60
+ "\n"
61
+ " def fields(self):\n"
62
+ " return [\n"
63
+ ' Text("Id", attribute="id").sortable().readonly(),\n'
64
+ " # TODO: add your fields here\n"
65
+ ' DateTime("Created At", attribute="created_at").sortable().hide_from_forms(),\n'
66
+ ' DateTime("Updated At", attribute="updated_at").sortable().hide_from_forms(),\n'
67
+ " ]\n"
68
+ "\n"
69
+ " def filters(self):\n"
70
+ " return []\n"
71
+ "\n"
72
+ " def actions(self):\n"
73
+ " return []\n"
74
+ "\n"
75
+ " def metrics(self):\n"
76
+ " return []\n"
77
+ )
78
+
79
+ file_path.write_text(content)
80
+ click.echo(f" AdminResource created: app/admin/{snake_name}.py")
81
+
82
+ _inject_into_routes(model_slug, class_name)
83
+
84
+
85
+ def _inject_into_routes(model_slug: str, class_name: str) -> None:
86
+ routes_file = Path.cwd() / "routes" / "admin.py"
87
+ if not routes_file.exists():
88
+ return
89
+
90
+ source = routes_file.read_text()
91
+
92
+ import_stmt = f"from app.admin.{Str.snake(class_name)} import {class_name}"
93
+ register_stmt = f"Admin.resource({class_name})"
94
+
95
+ if import_stmt in source:
96
+ return
97
+
98
+ lines = source.splitlines()
99
+
100
+ # Insert import after the last `from app.admin.*` line, falling back to
101
+ # the last import line in the file.
102
+ last_app_admin_import = -1
103
+ last_import = -1
104
+ for i, line in enumerate(lines):
105
+ stripped = line.strip()
106
+ if stripped.startswith("from app.admin."):
107
+ last_app_admin_import = i
108
+ if stripped.startswith(("from ", "import ")):
109
+ last_import = i
110
+
111
+ import_insert_at = last_app_admin_import if last_app_admin_import >= 0 else last_import
112
+ if import_insert_at >= 0:
113
+ lines.insert(import_insert_at + 1, import_stmt)
114
+ else:
115
+ lines.insert(0, import_stmt)
116
+
117
+ # Insert Admin.resource() after the last existing call, or before the
118
+ # first non-import statement (Admin.dashboard, def register, etc.).
119
+ last_resource_call = -1
120
+ first_non_resource_stmt = -1
121
+ for i, line in enumerate(lines):
122
+ stripped = line.strip()
123
+ if stripped.startswith("Admin.resource("):
124
+ last_resource_call = i
125
+ elif (
126
+ first_non_resource_stmt < 0
127
+ and stripped
128
+ and not stripped.startswith(("from ", "import ", "#", "Admin.resource("))
129
+ ):
130
+ first_non_resource_stmt = i
131
+
132
+ if last_resource_call >= 0:
133
+ lines.insert(last_resource_call + 1, register_stmt)
134
+ elif first_non_resource_stmt >= 0:
135
+ lines.insert(first_non_resource_stmt, register_stmt)
136
+ else:
137
+ lines.append(register_stmt)
138
+
139
+ routes_file.write_text("\n".join(lines) + "\n")
140
+ click.echo(" Registered in: routes/admin.py")
File without changes
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from hunt.http.request import Request
6
+ from hunt.http.response import HttpException, RedirectResponse, Response
7
+
8
+
9
+ def run(request: Request, resource_key: str, action_slug: str) -> Response:
10
+ from hunt.admin.application import Admin
11
+
12
+ resource_cls = Admin.find_resource(resource_key)
13
+ if resource_cls is None:
14
+ raise HttpException(404, "Resource not found.")
15
+ resource = resource_cls()
16
+
17
+ # Require at least update permission to run any action.
18
+ if not resource.can_update(request):
19
+ raise HttpException(403, "Forbidden.")
20
+
21
+ # Resolve selected IDs — accept comma-separated string or JSON array.
22
+ raw_ids = request.input("ids", "")
23
+ if isinstance(raw_ids, list):
24
+ candidate_ids = [str(i) for i in raw_ids]
25
+ elif raw_ids:
26
+ raw_str = str(raw_ids).strip()
27
+ if raw_str.startswith("["):
28
+ try:
29
+ candidate_ids = [str(i) for i in json.loads(raw_str)]
30
+ except (json.JSONDecodeError, ValueError):
31
+ candidate_ids = [s.strip() for s in raw_str.split(",") if s.strip()]
32
+ else:
33
+ candidate_ids = [s.strip() for s in raw_str.split(",") if s.strip()]
34
+ else:
35
+ candidate_ids = []
36
+
37
+ # Find the action by slug.
38
+ matched_action = None
39
+ for action in resource.actions():
40
+ if action.slug() == action_slug:
41
+ matched_action = action
42
+ break
43
+
44
+ if matched_action is None:
45
+ raise HttpException(404, "Action not found.")
46
+
47
+ # Scope the IDs through index_query so that users cannot act on records
48
+ # outside the scope they can see (prevents IDOR).
49
+ try:
50
+ accessible = {str(item._attributes.get("id")) for item in resource.index_query(request).get()}
51
+ except Exception:
52
+ accessible = set()
53
+
54
+ safe_ids = [i for i in candidate_ids if i in accessible]
55
+
56
+ # Load model instances for the verified IDs.
57
+ instances = []
58
+ for record_id in safe_ids:
59
+ try:
60
+ instance = resource.model.find(record_id)
61
+ if instance is not None:
62
+ instances.append(instance)
63
+ except Exception:
64
+ pass
65
+
66
+ try:
67
+ result = matched_action.handle(request, instances)
68
+ except Exception:
69
+ store = getattr(request, "_session", None)
70
+ if store is not None:
71
+ store.flash("admin_error", "The action could not be completed. Please try again.")
72
+ return RedirectResponse(f"{Admin.prefix}/resources/{resource_key}")
73
+
74
+ store = getattr(request, "_session", None)
75
+
76
+ if result.type == "redirect":
77
+ # Reject external redirects — only allow relative paths.
78
+ url = result.url or ""
79
+ if url.startswith(("http://", "https://", "//")):
80
+ url = f"{Admin.prefix}/resources/{resource_key}"
81
+ return RedirectResponse(url)
82
+
83
+ if store is not None:
84
+ flash_key = "admin_success" if result.message_type == "success" else "admin_error"
85
+ store.flash(flash_key, result.text)
86
+
87
+ return RedirectResponse(f"{Admin.prefix}/resources/{resource_key}")
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from hunt.http.request import Request
4
+ from hunt.http.response import Response
5
+
6
+
7
+ def index(request: Request) -> Response:
8
+ from hunt.admin.application import Admin
9
+
10
+ ctx = Admin._base_context(request)
11
+ metrics = []
12
+ for card in Admin._dashboard_cards:
13
+ try:
14
+ data = card.calculate()
15
+ data["metric_type"] = card.metric_type
16
+ metrics.append(data)
17
+ except Exception:
18
+ pass
19
+ ctx["metrics"] = metrics
20
+ ctx["title"] = "Dashboard"
21
+ return Admin._render("admin/dashboard.html", ctx)