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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from hunt.http.request import Request
|
|
6
|
+
from hunt.http.response import HttpException, RedirectResponse, Response
|
|
7
|
+
from hunt.validation.validator import Validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _get_resource(resource_key: str) -> Any:
|
|
11
|
+
from hunt.admin.application import Admin
|
|
12
|
+
|
|
13
|
+
resource_cls = Admin.find_resource(resource_key)
|
|
14
|
+
if resource_cls is None:
|
|
15
|
+
raise HttpException(404, "Resource not found.")
|
|
16
|
+
return resource_cls()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_instance(resource: Any, id: str) -> Any:
|
|
20
|
+
try:
|
|
21
|
+
instance = resource.model.find(id)
|
|
22
|
+
except Exception:
|
|
23
|
+
instance = None
|
|
24
|
+
if instance is None:
|
|
25
|
+
raise HttpException(404, "Record not found.")
|
|
26
|
+
return instance
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _flash_and_redirect(request: Request, key: str, message: str, url: str) -> RedirectResponse:
|
|
30
|
+
store = getattr(request, "_session", None)
|
|
31
|
+
if store is not None:
|
|
32
|
+
store.flash(key, message)
|
|
33
|
+
return RedirectResponse(url)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def index(request: Request, resource_key: str) -> Response:
|
|
37
|
+
from hunt.admin.application import Admin
|
|
38
|
+
|
|
39
|
+
resource = _get_resource(resource_key)
|
|
40
|
+
if not resource.can_view_any(request):
|
|
41
|
+
raise HttpException(403, "Forbidden.")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
page = int(request.query("page", "1"))
|
|
45
|
+
except (ValueError, TypeError):
|
|
46
|
+
page = 1
|
|
47
|
+
|
|
48
|
+
_options = resource.per_page_options or [10, 25, 50, 100]
|
|
49
|
+
_default = resource.per_page if resource.per_page in _options else _options[0]
|
|
50
|
+
try:
|
|
51
|
+
per_page = int(request.query("per_page", str(_default)))
|
|
52
|
+
if per_page not in _options:
|
|
53
|
+
per_page = _default
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
per_page = _default
|
|
56
|
+
|
|
57
|
+
query = resource.index_query(request)
|
|
58
|
+
paginate_result = query.paginate(per_page, page)
|
|
59
|
+
|
|
60
|
+
index_fields = [f for f in resource.fields() if f._show_on_index]
|
|
61
|
+
ctx = Admin._base_context(request)
|
|
62
|
+
ctx.update(
|
|
63
|
+
{
|
|
64
|
+
"title": resource.get_label_plural(),
|
|
65
|
+
"resource": resource,
|
|
66
|
+
"resource_key": resource_key,
|
|
67
|
+
"items": paginate_result["data"],
|
|
68
|
+
"pagination": paginate_result,
|
|
69
|
+
"fields": index_fields,
|
|
70
|
+
"search": request.query("search", ""),
|
|
71
|
+
"sort": request.query("sort", ""),
|
|
72
|
+
"dir": request.query("dir", "desc"),
|
|
73
|
+
"filters": resource.filters(),
|
|
74
|
+
"actions": resource.actions(),
|
|
75
|
+
"metrics": [],
|
|
76
|
+
"per_page": per_page,
|
|
77
|
+
"per_page_options": _options,
|
|
78
|
+
"can_create": resource.can_create(request),
|
|
79
|
+
"can_update": resource.can_update(request),
|
|
80
|
+
"can_delete": resource.can_delete(request),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Calculate per-resource metrics
|
|
85
|
+
for card in resource.metrics():
|
|
86
|
+
try:
|
|
87
|
+
data = card.calculate()
|
|
88
|
+
data["metric_type"] = card.metric_type
|
|
89
|
+
ctx["metrics"].append(data)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
return Admin._render("admin/resource/index.html", ctx)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def show(request: Request, resource_key: str, id: str) -> Response:
|
|
97
|
+
from hunt.admin.application import Admin
|
|
98
|
+
from hunt.admin.fields.has_many import HasMany
|
|
99
|
+
|
|
100
|
+
resource = _get_resource(resource_key)
|
|
101
|
+
if not resource.can_view_any(request):
|
|
102
|
+
raise HttpException(403, "Forbidden.")
|
|
103
|
+
|
|
104
|
+
instance = _get_instance(resource, id)
|
|
105
|
+
detail_fields = [f for f in resource.fields() if f._show_on_detail]
|
|
106
|
+
has_many_panels = [f for f in resource.fields() if isinstance(f, HasMany)]
|
|
107
|
+
|
|
108
|
+
related_data = {}
|
|
109
|
+
for panel in has_many_panels:
|
|
110
|
+
try:
|
|
111
|
+
related_resource_inst = panel.related_resource_class()
|
|
112
|
+
fk = panel.foreign_key or f"{type(instance).__name__.lower()}_id"
|
|
113
|
+
rel_query = related_resource_inst.model.query().where(fk, instance._attributes.get("id"))
|
|
114
|
+
related_data[panel.attribute] = rel_query.limit(20).get()
|
|
115
|
+
except Exception:
|
|
116
|
+
related_data[panel.attribute] = []
|
|
117
|
+
|
|
118
|
+
ctx = Admin._base_context(request)
|
|
119
|
+
ctx.update(
|
|
120
|
+
{
|
|
121
|
+
"title": resource.title(instance),
|
|
122
|
+
"resource": resource,
|
|
123
|
+
"resource_key": resource_key,
|
|
124
|
+
"instance": instance,
|
|
125
|
+
"fields": detail_fields,
|
|
126
|
+
"has_many_panels": has_many_panels,
|
|
127
|
+
"related_data": related_data,
|
|
128
|
+
"record_id": id,
|
|
129
|
+
"can_update": resource.can_update(request, instance),
|
|
130
|
+
"can_delete": resource.can_delete(request, instance),
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
return Admin._render("admin/resource/show.html", ctx)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def create(request: Request, resource_key: str) -> Response:
|
|
137
|
+
from hunt.admin.application import Admin
|
|
138
|
+
|
|
139
|
+
resource = _get_resource(resource_key)
|
|
140
|
+
if not resource.can_create(request):
|
|
141
|
+
raise HttpException(403, "Forbidden.")
|
|
142
|
+
|
|
143
|
+
create_fields = [f for f in resource.fields() if f._show_on_create]
|
|
144
|
+
ctx = Admin._base_context(request)
|
|
145
|
+
ctx.update(
|
|
146
|
+
{
|
|
147
|
+
"title": f"Create {resource.get_label()}",
|
|
148
|
+
"resource": resource,
|
|
149
|
+
"resource_key": resource_key,
|
|
150
|
+
"fields": create_fields,
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
return Admin._render("admin/resource/create.html", ctx)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _collect_data(request: Request, fields: list) -> dict:
|
|
157
|
+
"""Merge form text values with uploaded files for the given field list."""
|
|
158
|
+
from hunt.admin.fields.image import Image as ImageField
|
|
159
|
+
from hunt.validation.validator import ValidationException
|
|
160
|
+
|
|
161
|
+
allowed_attrs = {f.attribute for f in fields if not f._readonly}
|
|
162
|
+
raw_data = request.all()
|
|
163
|
+
data = {k: v for k, v in raw_data.items() if k in allowed_attrs}
|
|
164
|
+
size_errors: dict[str, list[str]] = {}
|
|
165
|
+
for field in fields:
|
|
166
|
+
if isinstance(field, ImageField) and not field._readonly:
|
|
167
|
+
uploaded = request.file(field.attribute)
|
|
168
|
+
if uploaded is not None and uploaded.size > 0:
|
|
169
|
+
if uploaded.size > field._max_kb * 1024:
|
|
170
|
+
size_errors[field.attribute] = [f"The file may not be greater than {field._max_kb} KB."]
|
|
171
|
+
else:
|
|
172
|
+
data[field.attribute] = uploaded
|
|
173
|
+
if size_errors:
|
|
174
|
+
raise ValidationException(size_errors)
|
|
175
|
+
return data
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _store_image_fields(data: dict, fields: list) -> dict:
|
|
179
|
+
"""Replace UploadedFile values with stored paths; skip if no file submitted."""
|
|
180
|
+
from hunt.admin.fields.image import Image as ImageField
|
|
181
|
+
from hunt.http.request import UploadedFile
|
|
182
|
+
|
|
183
|
+
result = {}
|
|
184
|
+
field_map = {f.attribute: f for f in fields if not f._readonly}
|
|
185
|
+
for key, value in data.items():
|
|
186
|
+
if isinstance(value, UploadedFile):
|
|
187
|
+
field = field_map.get(key)
|
|
188
|
+
disk = field._disk if field and isinstance(field, ImageField) else "public"
|
|
189
|
+
path = field._path if field and isinstance(field, ImageField) else "uploads"
|
|
190
|
+
result[key] = value.store(path, disk)
|
|
191
|
+
else:
|
|
192
|
+
result[key] = value
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def store(request: Request, resource_key: str) -> Response:
|
|
197
|
+
from hunt.admin.application import Admin
|
|
198
|
+
|
|
199
|
+
resource = _get_resource(resource_key)
|
|
200
|
+
if not resource.can_create(request):
|
|
201
|
+
raise HttpException(403, "Forbidden.")
|
|
202
|
+
|
|
203
|
+
create_fields = [f for f in resource.fields() if f._show_on_create]
|
|
204
|
+
rules = {f.attribute: "|".join(f._rules) for f in create_fields if f._rules}
|
|
205
|
+
data = _collect_data(request, create_fields)
|
|
206
|
+
|
|
207
|
+
if rules:
|
|
208
|
+
Validator.make(data, rules).validate()
|
|
209
|
+
|
|
210
|
+
stored = _store_image_fields(data, create_fields)
|
|
211
|
+
resource.model.create(stored)
|
|
212
|
+
|
|
213
|
+
return _flash_and_redirect(
|
|
214
|
+
request,
|
|
215
|
+
"admin_success",
|
|
216
|
+
f"{resource.get_label()} created successfully.",
|
|
217
|
+
f"{Admin.prefix}/resources/{resource_key}",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def edit(request: Request, resource_key: str, id: str) -> Response:
|
|
222
|
+
from hunt.admin.application import Admin
|
|
223
|
+
|
|
224
|
+
resource = _get_resource(resource_key)
|
|
225
|
+
instance = _get_instance(resource, id)
|
|
226
|
+
if not resource.can_update(request, instance):
|
|
227
|
+
raise HttpException(403, "Forbidden.")
|
|
228
|
+
|
|
229
|
+
edit_fields = [f for f in resource.fields() if f._show_on_edit]
|
|
230
|
+
ctx = Admin._base_context(request)
|
|
231
|
+
ctx.update(
|
|
232
|
+
{
|
|
233
|
+
"title": f"Edit {resource.title(instance)}",
|
|
234
|
+
"resource": resource,
|
|
235
|
+
"resource_key": resource_key,
|
|
236
|
+
"instance": instance,
|
|
237
|
+
"fields": edit_fields,
|
|
238
|
+
"old": ctx["old"] or instance._attributes,
|
|
239
|
+
"record_id": id,
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
return Admin._render("admin/resource/edit.html", ctx)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def update(request: Request, resource_key: str, id: str) -> Response:
|
|
246
|
+
from hunt.admin.application import Admin
|
|
247
|
+
|
|
248
|
+
resource = _get_resource(resource_key)
|
|
249
|
+
instance = _get_instance(resource, id)
|
|
250
|
+
if not resource.can_update(request, instance):
|
|
251
|
+
raise HttpException(403, "Forbidden.")
|
|
252
|
+
|
|
253
|
+
edit_fields = [f for f in resource.fields() if f._show_on_edit]
|
|
254
|
+
rules = {f.attribute: "|".join(f._rules) for f in edit_fields if f._rules}
|
|
255
|
+
data = _collect_data(request, edit_fields)
|
|
256
|
+
|
|
257
|
+
if rules:
|
|
258
|
+
Validator.make(data, rules).validate()
|
|
259
|
+
|
|
260
|
+
stored = _store_image_fields(data, edit_fields)
|
|
261
|
+
from hunt.http.request import UploadedFile
|
|
262
|
+
|
|
263
|
+
final = {k: v for k, v in stored.items() if not isinstance(v, UploadedFile)}
|
|
264
|
+
instance.fill(final)
|
|
265
|
+
instance.save()
|
|
266
|
+
|
|
267
|
+
return _flash_and_redirect(
|
|
268
|
+
request,
|
|
269
|
+
"admin_success",
|
|
270
|
+
f"{resource.get_label()} updated successfully.",
|
|
271
|
+
f"{Admin.prefix}/resources/{resource_key}/{id}",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def destroy(request: Request, resource_key: str, id: str) -> Response:
|
|
276
|
+
from hunt.admin.application import Admin
|
|
277
|
+
|
|
278
|
+
resource = _get_resource(resource_key)
|
|
279
|
+
instance = _get_instance(resource, id)
|
|
280
|
+
if not resource.can_delete(request, instance):
|
|
281
|
+
raise HttpException(403, "Forbidden.")
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
instance.delete()
|
|
285
|
+
except Exception:
|
|
286
|
+
return _flash_and_redirect(
|
|
287
|
+
request,
|
|
288
|
+
"admin_error",
|
|
289
|
+
"Could not delete record. Please try again.",
|
|
290
|
+
f"{Admin.prefix}/resources/{resource_key}",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return _flash_and_redirect(
|
|
294
|
+
request,
|
|
295
|
+
"admin_success",
|
|
296
|
+
f"{resource.get_label()} deleted successfully.",
|
|
297
|
+
f"{Admin.prefix}/resources/{resource_key}",
|
|
298
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hunt.http.request import Request
|
|
4
|
+
from hunt.http.response import JsonResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def index(request: Request) -> JsonResponse:
|
|
8
|
+
from hunt.admin.application import Admin
|
|
9
|
+
|
|
10
|
+
q = str(request.query("q", "") or "").strip()
|
|
11
|
+
if not q:
|
|
12
|
+
return JsonResponse({"groups": []})
|
|
13
|
+
q = q[:200] # cap to avoid pathological LIKE patterns
|
|
14
|
+
groups = []
|
|
15
|
+
|
|
16
|
+
for resource_cls in Admin._resources:
|
|
17
|
+
resource = resource_cls()
|
|
18
|
+
if not resource.search_columns:
|
|
19
|
+
continue
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
query = resource.model.query()
|
|
23
|
+
first_col = resource.search_columns[0]
|
|
24
|
+
query = query.where(first_col, "LIKE", f"%{q}%")
|
|
25
|
+
for col in resource.search_columns[1:]:
|
|
26
|
+
try:
|
|
27
|
+
query = query.or_where(col, "LIKE", f"%{q}%")
|
|
28
|
+
except ValueError:
|
|
29
|
+
pass
|
|
30
|
+
items = query.limit(5).get()
|
|
31
|
+
|
|
32
|
+
if not items:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
groups.append(
|
|
36
|
+
{
|
|
37
|
+
"resource": resource_cls.get_label(),
|
|
38
|
+
"resource_key": resource_cls.slug(),
|
|
39
|
+
"resource_url": f"{Admin.prefix}/resources/{resource_cls.slug()}",
|
|
40
|
+
"items": [
|
|
41
|
+
{
|
|
42
|
+
"id": item._attributes.get("id"),
|
|
43
|
+
"title": resource.title(item),
|
|
44
|
+
"url": f"{Admin.prefix}/resources/{resource_cls.slug()}/{item._attributes.get('id')}",
|
|
45
|
+
}
|
|
46
|
+
for item in items
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
except Exception:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
return JsonResponse({"groups": groups})
|
hunt/admin/field.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from hunt.support.str import Str
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Field:
|
|
9
|
+
"""Base field for AdminResource definitions."""
|
|
10
|
+
|
|
11
|
+
field_type: str = "text"
|
|
12
|
+
|
|
13
|
+
def __init__(self, name: str, attribute: str | None = None) -> None:
|
|
14
|
+
self.name = name
|
|
15
|
+
self.attribute = attribute if attribute is not None else Str.snake(name)
|
|
16
|
+
self.label = name
|
|
17
|
+
self.field_type = self.__class__.field_type
|
|
18
|
+
|
|
19
|
+
self._show_on_index: bool = True
|
|
20
|
+
self._show_on_detail: bool = True
|
|
21
|
+
self._show_on_create: bool = True
|
|
22
|
+
self._show_on_edit: bool = True
|
|
23
|
+
self._sortable: bool = False
|
|
24
|
+
self._readonly: bool = False
|
|
25
|
+
self._help_text: str = ""
|
|
26
|
+
self._nullable: bool = False
|
|
27
|
+
self._rules: list[str] = []
|
|
28
|
+
self._panel: str | None = None
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Fluent visibility / behaviour setters
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def hide_from_index(self) -> Field:
|
|
35
|
+
self._show_on_index = False
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def hide_from_detail(self) -> Field:
|
|
39
|
+
self._show_on_detail = False
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def hide_from_forms(self) -> Field:
|
|
43
|
+
self._show_on_create = False
|
|
44
|
+
self._show_on_edit = False
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def hide_from_create(self) -> Field:
|
|
48
|
+
self._show_on_create = False
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def hide_from_edit(self) -> Field:
|
|
52
|
+
self._show_on_edit = False
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def only_on_index(self) -> Field:
|
|
56
|
+
self._show_on_detail = False
|
|
57
|
+
self._show_on_create = False
|
|
58
|
+
self._show_on_edit = False
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def only_on_forms(self) -> Field:
|
|
62
|
+
self._show_on_index = False
|
|
63
|
+
self._show_on_detail = False
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def only_on_detail(self) -> Field:
|
|
67
|
+
self._show_on_index = False
|
|
68
|
+
self._show_on_create = False
|
|
69
|
+
self._show_on_edit = False
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def show_on_index(self) -> Field:
|
|
73
|
+
self._show_on_index = True
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def show_on_detail(self) -> Field:
|
|
77
|
+
self._show_on_detail = True
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def show_on_create(self) -> Field:
|
|
81
|
+
self._show_on_create = True
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def show_on_edit(self) -> Field:
|
|
85
|
+
self._show_on_edit = True
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def sortable(self) -> Field:
|
|
89
|
+
self._sortable = True
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def readonly(self) -> Field:
|
|
93
|
+
self._readonly = True
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def help(self, text: str) -> Field:
|
|
97
|
+
self._help_text = text
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def rules(self, *r: str) -> Field:
|
|
101
|
+
self._rules = list(r)
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def nullable(self) -> Field:
|
|
105
|
+
self._nullable = True
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def panel(self, name: str) -> Field:
|
|
109
|
+
self._panel = name
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Value resolution
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def value_for(self, instance: Any) -> Any:
|
|
117
|
+
return instance._attributes.get(self.attribute)
|
|
118
|
+
|
|
119
|
+
def display_value(self, instance: Any) -> str:
|
|
120
|
+
val = self.value_for(instance)
|
|
121
|
+
if val is None:
|
|
122
|
+
return ""
|
|
123
|
+
return str(val)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from hunt.admin.fields.badge import Badge
|
|
2
|
+
from hunt.admin.fields.belongs_to import BelongsTo
|
|
3
|
+
from hunt.admin.fields.boolean import Boolean
|
|
4
|
+
from hunt.admin.fields.datetime_ import Date, DateTime
|
|
5
|
+
from hunt.admin.fields.has_many import HasMany
|
|
6
|
+
from hunt.admin.fields.image import Image
|
|
7
|
+
from hunt.admin.fields.number import Currency, Number
|
|
8
|
+
from hunt.admin.fields.richtext import RichText
|
|
9
|
+
from hunt.admin.fields.select import Select
|
|
10
|
+
from hunt.admin.fields.text import Email, Password, Slug, Text
|
|
11
|
+
from hunt.admin.fields.textarea import Textarea
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Badge",
|
|
15
|
+
"BelongsTo",
|
|
16
|
+
"Boolean",
|
|
17
|
+
"Currency",
|
|
18
|
+
"Date",
|
|
19
|
+
"DateTime",
|
|
20
|
+
"Email",
|
|
21
|
+
"HasMany",
|
|
22
|
+
"Image",
|
|
23
|
+
"Number",
|
|
24
|
+
"Password",
|
|
25
|
+
"RichText",
|
|
26
|
+
"Select",
|
|
27
|
+
"Slug",
|
|
28
|
+
"Text",
|
|
29
|
+
"Textarea",
|
|
30
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from hunt.admin.field import Field
|
|
6
|
+
|
|
7
|
+
_DEFAULT_COLOUR = "gray"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Badge(Field):
|
|
11
|
+
field_type: str = "badge"
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
name: str,
|
|
16
|
+
colour_map: dict | None = None,
|
|
17
|
+
attribute: str | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(name, attribute)
|
|
20
|
+
self._colour_map: dict = colour_map or {}
|
|
21
|
+
|
|
22
|
+
def get_colour(self, value: Any) -> str:
|
|
23
|
+
if value is None:
|
|
24
|
+
return _DEFAULT_COLOUR
|
|
25
|
+
return self._colour_map.get(str(value), _DEFAULT_COLOUR)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hunt.admin.field import Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BelongsTo(Field):
|
|
7
|
+
field_type: str = "belongs_to"
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
name: str,
|
|
12
|
+
related_resource_class: type,
|
|
13
|
+
attribute: str | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(name, attribute)
|
|
16
|
+
self.related_resource_class = related_resource_class
|
|
17
|
+
self._searchable: bool = False
|
|
18
|
+
|
|
19
|
+
def searchable(self) -> BelongsTo:
|
|
20
|
+
self._searchable = True
|
|
21
|
+
return self
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from hunt.admin.field import Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Boolean(Field):
|
|
9
|
+
field_type: str = "boolean"
|
|
10
|
+
|
|
11
|
+
def display_value(self, instance: Any) -> str:
|
|
12
|
+
val = self.value_for(instance)
|
|
13
|
+
if val is None:
|
|
14
|
+
return ""
|
|
15
|
+
return "Yes" if val else "No"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from hunt.admin.field import Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_and_format(value: Any, fmt: str) -> str:
|
|
10
|
+
if value is None:
|
|
11
|
+
return ""
|
|
12
|
+
if isinstance(value, (datetime.datetime, datetime.date)):
|
|
13
|
+
return value.strftime(fmt)
|
|
14
|
+
if isinstance(value, (int, float)):
|
|
15
|
+
# Unix timestamp
|
|
16
|
+
try:
|
|
17
|
+
return datetime.datetime.fromtimestamp(value).strftime(fmt)
|
|
18
|
+
except (OSError, OverflowError, ValueError):
|
|
19
|
+
return str(value)
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
# Try common ISO formats
|
|
22
|
+
for parse_fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
|
|
23
|
+
try:
|
|
24
|
+
return datetime.datetime.strptime(value, parse_fmt).strftime(fmt)
|
|
25
|
+
except ValueError:
|
|
26
|
+
continue
|
|
27
|
+
return value
|
|
28
|
+
return str(value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DateTime(Field):
|
|
32
|
+
field_type: str = "datetime"
|
|
33
|
+
|
|
34
|
+
def __init__(self, name: str, attribute: str | None = None) -> None:
|
|
35
|
+
super().__init__(name, attribute)
|
|
36
|
+
self._format: str = "%Y-%m-%d %H:%M"
|
|
37
|
+
|
|
38
|
+
def format(self, fmt: str) -> DateTime:
|
|
39
|
+
self._format = fmt
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def display_value(self, instance: Any) -> str:
|
|
43
|
+
return _parse_and_format(self.value_for(instance), self._format)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Date(Field):
|
|
47
|
+
field_type: str = "date"
|
|
48
|
+
|
|
49
|
+
def __init__(self, name: str, attribute: str | None = None) -> None:
|
|
50
|
+
super().__init__(name, attribute)
|
|
51
|
+
self._format: str = "%Y-%m-%d"
|
|
52
|
+
|
|
53
|
+
def format(self, fmt: str) -> Date:
|
|
54
|
+
self._format = fmt
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def display_value(self, instance: Any) -> str:
|
|
58
|
+
return _parse_and_format(self.value_for(instance), self._format)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from hunt.admin.field import Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HasMany(Field):
|
|
7
|
+
field_type: str = "has_many"
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
name: str,
|
|
12
|
+
related_resource_class: type,
|
|
13
|
+
foreign_key: str | None = None,
|
|
14
|
+
attribute: str | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
super().__init__(name, attribute)
|
|
17
|
+
self.related_resource_class = related_resource_class
|
|
18
|
+
self.foreign_key = foreign_key
|
|
19
|
+
# HasMany panels are never displayed in these views
|
|
20
|
+
self.hide_from_index()
|
|
21
|
+
self.hide_from_create()
|
|
22
|
+
self.hide_from_edit()
|