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.
- hunt/__init__.py +15 -0
- hunt/admin/__init__.py +53 -0
- hunt/admin/action.py +58 -0
- hunt/admin/application.py +211 -0
- hunt/admin/console/__init__.py +0 -0
- hunt/admin/console/make_admin_resource.py +140 -0
- hunt/admin/controllers/__init__.py +0 -0
- hunt/admin/controllers/action.py +87 -0
- hunt/admin/controllers/dashboard.py +21 -0
- hunt/admin/controllers/resource.py +298 -0
- hunt/admin/controllers/search.py +53 -0
- hunt/admin/field.py +123 -0
- hunt/admin/fields/__init__.py +30 -0
- hunt/admin/fields/badge.py +25 -0
- hunt/admin/fields/belongs_to.py +21 -0
- hunt/admin/fields/boolean.py +15 -0
- hunt/admin/fields/datetime_.py +58 -0
- hunt/admin/fields/has_many.py +22 -0
- hunt/admin/fields/image.py +51 -0
- hunt/admin/fields/number.py +43 -0
- hunt/admin/fields/richtext.py +71 -0
- hunt/admin/fields/select.py +30 -0
- hunt/admin/fields/text.py +24 -0
- hunt/admin/fields/textarea.py +16 -0
- hunt/admin/filter.py +86 -0
- hunt/admin/metrics/__init__.py +5 -0
- hunt/admin/metrics/partition.py +44 -0
- hunt/admin/metrics/trend.py +33 -0
- hunt/admin/metrics/value.py +64 -0
- hunt/admin/middleware/__init__.py +0 -0
- hunt/admin/middleware/gate.py +42 -0
- hunt/admin/navigation.py +79 -0
- hunt/admin/resource.py +163 -0
- hunt/admin/templates/admin/dashboard.html +87 -0
- hunt/admin/templates/admin/layout.html +268 -0
- hunt/admin/templates/admin/resource/_form.html +197 -0
- hunt/admin/templates/admin/resource/create.html +79 -0
- hunt/admin/templates/admin/resource/edit.html +84 -0
- hunt/admin/templates/admin/resource/index.html +388 -0
- hunt/admin/templates/admin/resource/show.html +167 -0
- hunt/application.py +123 -0
- hunt/auth/__init__.py +6 -0
- hunt/auth/gate.py +155 -0
- hunt/auth/manager.py +261 -0
- hunt/auth/passwords.py +125 -0
- hunt/auth/verification.py +61 -0
- hunt/cache/__init__.py +3 -0
- hunt/cache/manager.py +171 -0
- hunt/config/__init__.py +4 -0
- hunt/config/loader.py +28 -0
- hunt/config/repository.py +33 -0
- hunt/console/__init__.py +3 -0
- hunt/console/command.py +9 -0
- hunt/console/commands/__init__.py +0 -0
- hunt/console/commands/cache.py +35 -0
- hunt/console/commands/config_cache.py +62 -0
- hunt/console/commands/db/__init__.py +0 -0
- hunt/console/commands/db/seed.py +67 -0
- hunt/console/commands/key_generate.py +33 -0
- hunt/console/commands/make/__init__.py +11 -0
- hunt/console/commands/make/command.py +44 -0
- hunt/console/commands/make/controller.py +96 -0
- hunt/console/commands/make/event.py +29 -0
- hunt/console/commands/make/factory.py +37 -0
- hunt/console/commands/make/job.py +37 -0
- hunt/console/commands/make/listener.py +48 -0
- hunt/console/commands/make/mail.py +52 -0
- hunt/console/commands/make/middleware.py +33 -0
- hunt/console/commands/make/migration.py +92 -0
- hunt/console/commands/make/model.py +54 -0
- hunt/console/commands/make/notification.py +45 -0
- hunt/console/commands/make/observer.py +70 -0
- hunt/console/commands/make/policy.py +59 -0
- hunt/console/commands/make/request.py +37 -0
- hunt/console/commands/make/resource.py +46 -0
- hunt/console/commands/make/rule.py +39 -0
- hunt/console/commands/make/seeder.py +30 -0
- hunt/console/commands/migrate.py +84 -0
- hunt/console/commands/new.py +1029 -0
- hunt/console/commands/queue_failed.py +74 -0
- hunt/console/commands/queue_table.py +54 -0
- hunt/console/commands/queue_work.py +133 -0
- hunt/console/commands/route_list.py +32 -0
- hunt/console/commands/schedule_list.py +42 -0
- hunt/console/commands/schedule_run.py +40 -0
- hunt/console/commands/serve.py +27 -0
- hunt/console/commands/storage_link.py +28 -0
- hunt/console/commands/tinker.py +31 -0
- hunt/console/commands/upgrade.py +213 -0
- hunt/console/commands/view_cache.py +42 -0
- hunt/console/kernel.py +105 -0
- hunt/container/__init__.py +5 -0
- hunt/container/container.py +118 -0
- hunt/container/facade.py +40 -0
- hunt/container/provider.py +17 -0
- hunt/database/__init__.py +5 -0
- hunt/database/connection.py +100 -0
- hunt/database/factory.py +82 -0
- hunt/database/model.py +394 -0
- hunt/database/query_builder.py +545 -0
- hunt/database/relations/__init__.py +6 -0
- hunt/database/relations/belongs_to.py +45 -0
- hunt/database/relations/belongs_to_many.py +134 -0
- hunt/database/relations/has_many.py +47 -0
- hunt/database/relations/has_one.py +43 -0
- hunt/database/schema/__init__.py +5 -0
- hunt/database/schema/blueprint.py +360 -0
- hunt/database/schema/builder.py +250 -0
- hunt/database/schema/migration.py +139 -0
- hunt/database/seeder.py +13 -0
- hunt/events/__init__.py +5 -0
- hunt/events/dispatcher.py +59 -0
- hunt/events/provider.py +103 -0
- hunt/events/queued.py +89 -0
- hunt/exceptions/__init__.py +0 -0
- hunt/exceptions/handler.py +96 -0
- hunt/http/__init__.py +32 -0
- hunt/http/client.py +383 -0
- hunt/http/controller.py +37 -0
- hunt/http/kernel.py +287 -0
- hunt/http/middleware/__init__.py +15 -0
- hunt/http/middleware/authenticate.py +21 -0
- hunt/http/middleware/cors.py +64 -0
- hunt/http/middleware/csrf.py +45 -0
- hunt/http/middleware/session.py +79 -0
- hunt/http/middleware/throttle.py +55 -0
- hunt/http/middleware/verified.py +20 -0
- hunt/http/request.py +373 -0
- hunt/http/response.py +206 -0
- hunt/http/route.py +65 -0
- hunt/http/router.py +139 -0
- hunt/log/__init__.py +3 -0
- hunt/log/manager.py +80 -0
- hunt/mail/__init__.py +5 -0
- hunt/mail/mailable.py +130 -0
- hunt/mail/manager.py +312 -0
- hunt/mail/message.py +71 -0
- hunt/notifications/__init__.py +5 -0
- hunt/notifications/channels/__init__.py +0 -0
- hunt/notifications/channels/database.py +50 -0
- hunt/notifications/channels/mail.py +35 -0
- hunt/notifications/fake.py +81 -0
- hunt/notifications/notifiable.py +144 -0
- hunt/notifications/notification.py +53 -0
- hunt/queue/__init__.py +4 -0
- hunt/queue/drivers/__init__.py +0 -0
- hunt/queue/drivers/database.py +128 -0
- hunt/queue/drivers/redis.py +131 -0
- hunt/queue/drivers/sync.py +37 -0
- hunt/queue/job.py +40 -0
- hunt/queue/manager.py +44 -0
- hunt/scheduling/__init__.py +3 -0
- hunt/scheduling/cron.py +52 -0
- hunt/scheduling/scheduler.py +345 -0
- hunt/security/__init__.py +0 -0
- hunt/security/signing.py +42 -0
- hunt/session/__init__.py +3 -0
- hunt/session/store.py +144 -0
- hunt/storage/__init__.py +5 -0
- hunt/storage/local.py +221 -0
- hunt/storage/manager.py +59 -0
- hunt/storage/s3.py +121 -0
- hunt/support/__init__.py +33 -0
- hunt/support/collection.py +171 -0
- hunt/support/helpers.py +152 -0
- hunt/support/str.py +176 -0
- hunt/testing/__init__.py +4 -0
- hunt/testing/fakes.py +186 -0
- hunt/testing/test_case.py +278 -0
- hunt/translation/__init__.py +4 -0
- hunt/translation/provider.py +21 -0
- hunt/translation/translator.py +210 -0
- hunt/validation/__init__.py +4 -0
- hunt/validation/form_request.py +38 -0
- hunt/validation/rules.py +521 -0
- hunt/validation/validator.py +137 -0
- hunt/view/__init__.py +4 -0
- hunt/view/directives.py +568 -0
- hunt/view/factory.py +206 -0
- hunt/views/auth/forgot_password.html +25 -0
- hunt/views/auth/layout.html +39 -0
- hunt/views/auth/login.html +35 -0
- hunt/views/auth/register.html +33 -0
- hunt/views/auth/reset_password.html +29 -0
- hunt_framework-0.2.2.dist-info/METADATA +567 -0
- hunt_framework-0.2.2.dist-info/RECORD +189 -0
- hunt_framework-0.2.2.dist-info/WHEEL +4 -0
- hunt_framework-0.2.2.dist-info/entry_points.txt +2 -0
- 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)
|