python-flashapi 0.1.2__py3-none-any.whl → 0.2.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.
- flashapi/__init__.py +8 -7
- flashapi/adapters/base.py +13 -12
- flashapi/adapters/django.py +771 -165
- flashapi/adapters/fastapi.py +879 -293
- flashapi/adapters/flask.py +728 -232
- flashapi/core/__init__.py +11 -3
- flashapi/core/custom_routes.py +5 -5
- flashapi/core/pluralize.py +80 -80
- flashapi/core/relations.py +59 -61
- flashapi/core/response.py +36 -33
- flashapi/core/schema.py +145 -83
- flashapi/core/visibility.py +46 -0
- flashapi/django.py +5 -5
- flashapi/docs/openapi.py +298 -223
- flashapi/fastapi.py +5 -5
- flashapi/features/__init__.py +6 -6
- flashapi/features/audit.py +84 -0
- flashapi/features/auth.py +134 -0
- flashapi/features/dashboard.py +412 -0
- flashapi/features/export.py +143 -0
- flashapi/features/filtering.py +124 -33
- flashapi/features/pagination.py +20 -20
- flashapi/features/rate_limit.py +45 -0
- flashapi/features/search.py +25 -25
- flashapi/features/sorting.py +23 -21
- flashapi/features/webhooks.py +84 -0
- flashapi/features/websocket.py +129 -0
- flashapi/flask.py +5 -5
- flashapi/inspectors/__init__.py +3 -3
- flashapi/inspectors/base.py +13 -11
- flashapi/inspectors/dataclass.py +50 -39
- flashapi/inspectors/detect.py +54 -48
- flashapi/inspectors/django.py +93 -83
- flashapi/inspectors/pydantic.py +99 -84
- flashapi/inspectors/sqlalchemy.py +83 -75
- flashapi/storage/__init__.py +4 -4
- flashapi/storage/auto.py +189 -106
- flashapi/storage/base.py +32 -26
- flashapi/storage/orm.py +125 -85
- flashapi/storage/sqlalchemy.py +56 -13
- python_flashapi-0.2.0.dist-info/METADATA +314 -0
- python_flashapi-0.2.0.dist-info/RECORD +48 -0
- python_flashapi-0.2.0.dist-info/licenses/LICENSE +190 -0
- python_flashapi-0.2.0.dist-info/licenses/NOTICE +5 -0
- python_flashapi-0.1.2.dist-info/METADATA +0 -259
- python_flashapi-0.1.2.dist-info/RECORD +0 -39
- python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
- {python_flashapi-0.1.2.dist-info → python_flashapi-0.2.0.dist-info}/WHEEL +0 -0
flashapi/adapters/django.py
CHANGED
|
@@ -1,165 +1,771 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import
|
|
4
|
-
|
|
5
|
-
from flashapi.core.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
)
|
|
10
|
-
from flashapi.
|
|
11
|
-
from flashapi.
|
|
12
|
-
from flashapi.
|
|
13
|
-
from flashapi.docs.openapi import generate_openapi_schema, get_swagger_html
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from flashapi.core.custom_routes import (
|
|
6
|
+
CustomRoute,
|
|
7
|
+
custom_routes_to_openapi_paths,
|
|
8
|
+
discover_django_views,
|
|
9
|
+
)
|
|
10
|
+
from flashapi.core.response import create_error_response, create_item_response, create_list_response
|
|
11
|
+
from flashapi.core.schema import Model, ModelSchema
|
|
12
|
+
from flashapi.core.visibility import export_fields, filter_response, writable_fields
|
|
13
|
+
from flashapi.docs.openapi import generate_openapi_schema, get_swagger_html
|
|
14
|
+
from flashapi.features import apply_filters, apply_search, apply_sorting, paginate
|
|
15
|
+
from flashapi.inspectors import inspect_model
|
|
16
|
+
from flashapi.storage.orm import DjangoORMStorage
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_PATH = "/api"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_urls(
|
|
25
|
+
models: list[type | Model],
|
|
26
|
+
*,
|
|
27
|
+
custom_routes: list[CustomRoute] | None = None,
|
|
28
|
+
extra_views: list | None = None,
|
|
29
|
+
base_path: str = DEFAULT_BASE_PATH,
|
|
30
|
+
docs: bool = True,
|
|
31
|
+
formatter: Callable | None = None,
|
|
32
|
+
webhook_urls: list[str] | None = None,
|
|
33
|
+
rate_limit: int | None = None,
|
|
34
|
+
rate_window: int = 60,
|
|
35
|
+
auth_backend=None,
|
|
36
|
+
):
|
|
37
|
+
"""Generate Django URL patterns for the given models (spec v1 compliant)."""
|
|
38
|
+
urlpatterns = []
|
|
39
|
+
all_schemas: list[ModelSchema] = []
|
|
40
|
+
|
|
41
|
+
# Webhooks
|
|
42
|
+
webhook = None
|
|
43
|
+
if webhook_urls:
|
|
44
|
+
from flashapi.features.webhooks import WebhookDispatcher
|
|
45
|
+
webhook = WebhookDispatcher(webhook_urls)
|
|
46
|
+
|
|
47
|
+
# Rate limiting
|
|
48
|
+
rate_limiter = None
|
|
49
|
+
if rate_limit:
|
|
50
|
+
from flashapi.features.rate_limit import RateLimiter
|
|
51
|
+
rate_limiter = RateLimiter(limit=rate_limit, window=rate_window)
|
|
52
|
+
|
|
53
|
+
# Metrics
|
|
54
|
+
from flashapi.features.dashboard import MetricsCollector
|
|
55
|
+
metrics = MetricsCollector()
|
|
56
|
+
|
|
57
|
+
# Audit (in-memory SQLite for Django adapter)
|
|
58
|
+
audit_log = None
|
|
59
|
+
try:
|
|
60
|
+
import sqlite3
|
|
61
|
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
|
62
|
+
conn.row_factory = sqlite3.Row
|
|
63
|
+
from flashapi.features.audit import AuditLog
|
|
64
|
+
audit_log = AuditLog(conn)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
for model_entry in models:
|
|
69
|
+
wrapper = model_entry if isinstance(model_entry, Model) else Model(model_entry)
|
|
70
|
+
|
|
71
|
+
schema = inspect_model(wrapper.model_class, plural=wrapper.plural)
|
|
72
|
+
schema.permissions = wrapper.permissions
|
|
73
|
+
schema.soft_delete = wrapper.soft_delete
|
|
74
|
+
schema.audit = wrapper.audit
|
|
75
|
+
schema.lookup_field = wrapper.lookup_field
|
|
76
|
+
schema.access = wrapper.access
|
|
77
|
+
schema.scope = wrapper.scope
|
|
78
|
+
schema.tenant_field = wrapper.tenant_field
|
|
79
|
+
schema.owner_field = wrapper.owner_field
|
|
80
|
+
|
|
81
|
+
from flashapi.core.schema import validate_soft_delete
|
|
82
|
+
validate_soft_delete(wrapper.model_class, wrapper.soft_delete)
|
|
83
|
+
|
|
84
|
+
storage = DjangoORMStorage(wrapper.model_class)
|
|
85
|
+
all_schemas.append(schema)
|
|
86
|
+
|
|
87
|
+
metrics.register_entity(
|
|
88
|
+
schema.name,
|
|
89
|
+
soft_delete=schema.soft_delete,
|
|
90
|
+
audit=schema.audit,
|
|
91
|
+
webhook=bool(webhook_urls),
|
|
92
|
+
rate_limited=bool(rate_limit),
|
|
93
|
+
multi_tenant=schema.scope in ("tenant", "both"),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
patterns = _create_django_views(
|
|
97
|
+
schema, storage, formatter,
|
|
98
|
+
audit_log=audit_log, webhook=webhook, metrics=metrics,
|
|
99
|
+
auth_backend=auth_backend,
|
|
100
|
+
)
|
|
101
|
+
urlpatterns.extend(patterns)
|
|
102
|
+
|
|
103
|
+
# Dashboard
|
|
104
|
+
urlpatterns.extend(_create_dashboard_views(metrics, webhook))
|
|
105
|
+
|
|
106
|
+
# Rate limiting middleware class (user must add to MIDDLEWARE)
|
|
107
|
+
if rate_limiter:
|
|
108
|
+
_register_rate_limit_middleware(rate_limiter)
|
|
109
|
+
|
|
110
|
+
if docs:
|
|
111
|
+
urlpatterns.extend(_create_docs_views(
|
|
112
|
+
all_schemas, custom_routes or [], extra_views or [],
|
|
113
|
+
))
|
|
114
|
+
|
|
115
|
+
urlpatterns.extend(_create_api_root_view(all_schemas, docs))
|
|
116
|
+
|
|
117
|
+
return urlpatterns
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _create_api_root_view(schemas: list[ModelSchema], docs: bool):
|
|
121
|
+
from django.http import JsonResponse
|
|
122
|
+
from django.urls import path
|
|
123
|
+
|
|
124
|
+
def api_root(request):
|
|
125
|
+
resources = {}
|
|
126
|
+
base = request.build_absolute_uri(request.path)
|
|
127
|
+
if not base.endswith("/"):
|
|
128
|
+
base += "/"
|
|
129
|
+
for schema in schemas:
|
|
130
|
+
resources[schema.plural] = base + schema.plural + "/"
|
|
131
|
+
links = {}
|
|
132
|
+
if docs:
|
|
133
|
+
links["docs"] = base + "docs/"
|
|
134
|
+
links["openapi"] = base + "openapi.json"
|
|
135
|
+
links["dashboard"] = base + "dashboard/"
|
|
136
|
+
return JsonResponse({"resources": resources, "links": links})
|
|
137
|
+
|
|
138
|
+
return [path("", api_root, name="flashapi_root")]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _register_rate_limit_middleware(rate_limiter) -> None:
|
|
142
|
+
"""Store rate_limiter globally for FlashAPIRateLimitMiddleware to pick up."""
|
|
143
|
+
global _RATE_LIMITER
|
|
144
|
+
_RATE_LIMITER = rate_limiter
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_RATE_LIMITER = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class FlashAPIRateLimitMiddleware:
|
|
151
|
+
"""Django middleware for rate limiting. Add 'flashapi.adapters.django.FlashAPIRateLimitMiddleware' to MIDDLEWARE."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, get_response) -> None:
|
|
154
|
+
self.get_response = get_response
|
|
155
|
+
|
|
156
|
+
def __call__(self, request):
|
|
157
|
+
from django.http import JsonResponse
|
|
158
|
+
|
|
159
|
+
if _RATE_LIMITER is None:
|
|
160
|
+
return self.get_response(request)
|
|
161
|
+
|
|
162
|
+
client_ip = self._get_client_ip(request)
|
|
163
|
+
allowed, remaining, reset = _RATE_LIMITER.check(client_ip)
|
|
164
|
+
|
|
165
|
+
if not allowed:
|
|
166
|
+
response = JsonResponse(
|
|
167
|
+
{"error": "Rate limit exceeded", "status": 429, "retryAfter": reset},
|
|
168
|
+
status=429,
|
|
169
|
+
)
|
|
170
|
+
response["X-RateLimit-Limit"] = str(_RATE_LIMITER.limit)
|
|
171
|
+
response["X-RateLimit-Remaining"] = "0"
|
|
172
|
+
response["X-RateLimit-Reset"] = str(reset)
|
|
173
|
+
return response
|
|
174
|
+
|
|
175
|
+
response = self.get_response(request)
|
|
176
|
+
response["X-RateLimit-Limit"] = str(_RATE_LIMITER.limit)
|
|
177
|
+
response["X-RateLimit-Remaining"] = str(remaining)
|
|
178
|
+
response["X-RateLimit-Reset"] = str(reset)
|
|
179
|
+
return response
|
|
180
|
+
|
|
181
|
+
def _get_client_ip(self, request):
|
|
182
|
+
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
183
|
+
if x_forwarded_for:
|
|
184
|
+
return x_forwarded_for.split(",")[0].strip()
|
|
185
|
+
return request.META.get("REMOTE_ADDR", "unknown")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _create_dashboard_views(metrics, webhook):
|
|
189
|
+
from django.http import HttpResponse, JsonResponse
|
|
190
|
+
from django.urls import path
|
|
191
|
+
|
|
192
|
+
from flashapi.features.dashboard import DASHBOARD_HTML
|
|
193
|
+
|
|
194
|
+
def dashboard_html(request):
|
|
195
|
+
return HttpResponse(DASHBOARD_HTML, content_type="text/html")
|
|
196
|
+
|
|
197
|
+
def dashboard_metrics(request):
|
|
198
|
+
return JsonResponse(metrics.get_metrics(webhook))
|
|
199
|
+
|
|
200
|
+
return [
|
|
201
|
+
path("dashboard/", dashboard_html, name="flashapi_dashboard"),
|
|
202
|
+
path("dashboard/metrics.json", dashboard_metrics, name="flashapi_dashboard_metrics"),
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _create_docs_views(schemas: list[ModelSchema], custom_routes: list[CustomRoute], extra_views: list):
|
|
207
|
+
from django.http import HttpResponse, JsonResponse
|
|
208
|
+
from django.urls import path
|
|
209
|
+
|
|
210
|
+
openapi_spec = generate_openapi_schema(schemas, trailing_slash=True)
|
|
211
|
+
|
|
212
|
+
if custom_routes:
|
|
213
|
+
custom_paths = custom_routes_to_openapi_paths(custom_routes, trailing_slash=True)
|
|
214
|
+
openapi_spec["paths"].update(custom_paths)
|
|
215
|
+
|
|
216
|
+
if extra_views:
|
|
217
|
+
discovered = discover_django_views(extra_views, trailing_slash=True)
|
|
218
|
+
openapi_spec["paths"].update(discovered)
|
|
219
|
+
|
|
220
|
+
def openapi_json(request):
|
|
221
|
+
spec = dict(openapi_spec)
|
|
222
|
+
base_path = request.path.rsplit("openapi.json", 1)[0]
|
|
223
|
+
spec["servers"] = [{"url": base_path}]
|
|
224
|
+
return JsonResponse(spec, safe=False)
|
|
225
|
+
|
|
226
|
+
def docs_ui(request):
|
|
227
|
+
base_path = request.path.rsplit("docs/", 1)[0]
|
|
228
|
+
openapi_url = f"{base_path}openapi.json"
|
|
229
|
+
html = get_swagger_html(title="FlashAPI", openapi_url=openapi_url)
|
|
230
|
+
return HttpResponse(html, content_type="text/html")
|
|
231
|
+
|
|
232
|
+
return [
|
|
233
|
+
path("openapi.json", openapi_json, name="flashapi_openapi"),
|
|
234
|
+
path("docs/", docs_ui, name="flashapi_docs"),
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _create_django_views(
|
|
239
|
+
schema: ModelSchema,
|
|
240
|
+
storage: DjangoORMStorage,
|
|
241
|
+
formatter: Callable | None,
|
|
242
|
+
*,
|
|
243
|
+
audit_log=None,
|
|
244
|
+
webhook=None,
|
|
245
|
+
metrics=None,
|
|
246
|
+
auth_backend=None,
|
|
247
|
+
):
|
|
248
|
+
import json
|
|
249
|
+
|
|
250
|
+
from django.http import HttpResponse, JsonResponse
|
|
251
|
+
from django.urls import path
|
|
252
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
253
|
+
|
|
254
|
+
from flashapi.features.auth import check_access, get_scope_filter
|
|
255
|
+
|
|
256
|
+
table = schema.plural
|
|
257
|
+
field_names = {f.name for f in schema.fields if not f.primary_key}
|
|
258
|
+
input_fields = writable_fields(schema)
|
|
259
|
+
lookup_field = schema.lookup_field
|
|
260
|
+
entity_name = schema.name
|
|
261
|
+
entity_audit = schema.audit
|
|
262
|
+
supports_soft_delete = schema.soft_delete
|
|
263
|
+
model_access = schema.access
|
|
264
|
+
model_scope = schema.scope
|
|
265
|
+
model_tenant_field = schema.tenant_field
|
|
266
|
+
model_owner_field = schema.owner_field
|
|
267
|
+
patterns = []
|
|
268
|
+
|
|
269
|
+
def _check_auth(request, operation):
|
|
270
|
+
"""Returns (user, role, error_response). error_response is None if access granted."""
|
|
271
|
+
if auth_backend is None:
|
|
272
|
+
return None, "admin", None
|
|
273
|
+
|
|
274
|
+
if model_access is None or model_access == "public" or model_access is True:
|
|
275
|
+
if isinstance(model_access, dict):
|
|
276
|
+
op_access = model_access.get(operation, "public")
|
|
277
|
+
if op_access == "public":
|
|
278
|
+
return None, "public", None
|
|
279
|
+
else:
|
|
280
|
+
return None, "public", None
|
|
281
|
+
|
|
282
|
+
user = auth_backend.authenticate(request)
|
|
283
|
+
if user is None:
|
|
284
|
+
if isinstance(model_access, dict):
|
|
285
|
+
op_access = model_access.get(operation, "authenticated")
|
|
286
|
+
if op_access == "public":
|
|
287
|
+
return None, "public", None
|
|
288
|
+
return None, "public", JsonResponse(
|
|
289
|
+
create_error_response("Authentication required", 401), status=401,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
role = auth_backend.get_role(user)
|
|
293
|
+
if not check_access(role, model_access, operation):
|
|
294
|
+
return user, role, JsonResponse(
|
|
295
|
+
create_error_response("Forbidden", 403), status=403,
|
|
296
|
+
)
|
|
297
|
+
return user, role, None
|
|
298
|
+
|
|
299
|
+
def _get_scope(user, role):
|
|
300
|
+
"""Returns the scope filter dict or None."""
|
|
301
|
+
if auth_backend is None or user is None:
|
|
302
|
+
return None
|
|
303
|
+
return get_scope_filter(user, auth_backend, model_scope, model_tenant_field, model_owner_field, role)
|
|
304
|
+
|
|
305
|
+
def _get_performer(user):
|
|
306
|
+
if auth_backend is None or user is None:
|
|
307
|
+
return ""
|
|
308
|
+
return auth_backend.get_user_identifier(user)
|
|
309
|
+
|
|
310
|
+
def _broadcast(entity: str, action: str, data: dict | None = None) -> None:
|
|
311
|
+
from flashapi.features.websocket import EVENT_MAP, broadcast_event
|
|
312
|
+
event_type = EVENT_MAP.get(action)
|
|
313
|
+
if event_type:
|
|
314
|
+
broadcast_event(entity, event_type, data)
|
|
315
|
+
|
|
316
|
+
# --- List + Create ---
|
|
317
|
+
if "list" in schema.permissions or "create" in schema.permissions:
|
|
318
|
+
|
|
319
|
+
def collection_view(request, _table=table, _fields=field_names, _schema=schema,
|
|
320
|
+
_input=input_fields, _lookup=lookup_field):
|
|
321
|
+
if request.method == "GET" and "list" in _schema.permissions:
|
|
322
|
+
user, role, err = _check_auth(request, "list")
|
|
323
|
+
if err:
|
|
324
|
+
return err
|
|
325
|
+
|
|
326
|
+
params = dict(request.GET)
|
|
327
|
+
params = {k: v[0] if isinstance(v, list) else v for k, v in params.items()}
|
|
328
|
+
try:
|
|
329
|
+
page = max(0, int(params.get("page", 0)))
|
|
330
|
+
size = max(1, min(100, int(params.get("size", 20))))
|
|
331
|
+
except (ValueError, TypeError):
|
|
332
|
+
return JsonResponse(create_error_response("Invalid page or size parameter", 400), status=400)
|
|
333
|
+
sort = params.get("sort")
|
|
334
|
+
search = params.get("search")
|
|
335
|
+
deleted_param = params.get("deleted", "false").lower() == "true"
|
|
336
|
+
|
|
337
|
+
only_deleted = deleted_param and supports_soft_delete
|
|
338
|
+
items = storage.list_all(_table, only_deleted=only_deleted)
|
|
339
|
+
|
|
340
|
+
scope_filter = _get_scope(user, role)
|
|
341
|
+
if scope_filter:
|
|
342
|
+
items = [i for i in items if all(i.get(k) == v for k, v in scope_filter.items())]
|
|
343
|
+
|
|
344
|
+
items = apply_filters(items, params, _fields)
|
|
345
|
+
if search and metrics:
|
|
346
|
+
metrics.record("SEARCH", entity_name)
|
|
347
|
+
items = apply_search(items, search, _fields)
|
|
348
|
+
items = apply_sorting(items, sort, _fields)
|
|
349
|
+
page_items, total = paginate(items, page, size)
|
|
350
|
+
page_items = [filter_response(item, _schema) for item in page_items]
|
|
351
|
+
if metrics:
|
|
352
|
+
metrics.record("READ", entity_name)
|
|
353
|
+
return JsonResponse(
|
|
354
|
+
create_list_response(page_items, total, page, size, formatter),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if request.method == "POST" and "create" in _schema.permissions:
|
|
358
|
+
user, role, err = _check_auth(request, "create")
|
|
359
|
+
if err:
|
|
360
|
+
return err
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
body = json.loads(request.body)
|
|
364
|
+
except (json.JSONDecodeError, ValueError):
|
|
365
|
+
return JsonResponse(create_error_response("Invalid JSON body", 400), status=400)
|
|
366
|
+
data = {k: v for k, v in body.items() if k in _input}
|
|
367
|
+
|
|
368
|
+
scope_filter = _get_scope(user, role)
|
|
369
|
+
if scope_filter:
|
|
370
|
+
data.update(scope_filter)
|
|
371
|
+
|
|
372
|
+
item = storage.create(_table, data)
|
|
373
|
+
if metrics:
|
|
374
|
+
metrics.record("CREATE", entity_name, str(item.get("id", "")))
|
|
375
|
+
if audit_log and entity_audit:
|
|
376
|
+
audit_log.record("CREATE", entity_name, item.get("id", ""), performed_by=_get_performer(user))
|
|
377
|
+
if webhook:
|
|
378
|
+
webhook.dispatch("CREATE", entity_name, item.get("id", ""), item)
|
|
379
|
+
_broadcast(entity_name, "CREATE", item)
|
|
380
|
+
item = filter_response(item, _schema)
|
|
381
|
+
return JsonResponse(create_item_response(item, formatter), status=201)
|
|
382
|
+
|
|
383
|
+
return JsonResponse(create_error_response("Method not allowed", 405), status=405)
|
|
384
|
+
|
|
385
|
+
patterns.append(path(f"{table}/", csrf_exempt(collection_view), name=f"{table}_collection"))
|
|
386
|
+
|
|
387
|
+
# --- Bulk create ---
|
|
388
|
+
if "create" in schema.permissions:
|
|
389
|
+
|
|
390
|
+
def bulk_create_view(request, _table=table, _input=input_fields, _schema=schema):
|
|
391
|
+
if request.method != "POST":
|
|
392
|
+
return JsonResponse(create_error_response("Method not allowed", 405), status=405)
|
|
393
|
+
|
|
394
|
+
user, role, err = _check_auth(request, "create")
|
|
395
|
+
if err:
|
|
396
|
+
return err
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
body = json.loads(request.body)
|
|
400
|
+
except (json.JSONDecodeError, ValueError):
|
|
401
|
+
return JsonResponse(create_error_response("Invalid JSON body", 400), status=400)
|
|
402
|
+
if not isinstance(body, list):
|
|
403
|
+
return JsonResponse(create_error_response("Request body must be a JSON array", 400), status=400)
|
|
404
|
+
|
|
405
|
+
scope_filter = _get_scope(user, role)
|
|
406
|
+
succeeded = 0
|
|
407
|
+
failed = 0
|
|
408
|
+
results = []
|
|
409
|
+
for item_data in body:
|
|
410
|
+
try:
|
|
411
|
+
data = {k: v for k, v in item_data.items() if k in _input}
|
|
412
|
+
if scope_filter:
|
|
413
|
+
data.update(scope_filter)
|
|
414
|
+
item = storage.create(_table, data)
|
|
415
|
+
item = filter_response(item, _schema)
|
|
416
|
+
results.append(item)
|
|
417
|
+
succeeded += 1
|
|
418
|
+
except Exception:
|
|
419
|
+
failed += 1
|
|
420
|
+
return JsonResponse({
|
|
421
|
+
"data": results,
|
|
422
|
+
"meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
|
|
423
|
+
}, status=201)
|
|
424
|
+
|
|
425
|
+
def bulk_view(request, _table=table, _input=input_fields, _schema=schema, _lf=lookup_field):
|
|
426
|
+
if request.method == "POST":
|
|
427
|
+
return bulk_create_view(request)
|
|
428
|
+
if request.method == "PUT":
|
|
429
|
+
user, role, err = _check_auth(request, "update")
|
|
430
|
+
if err:
|
|
431
|
+
return err
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
body = json.loads(request.body)
|
|
435
|
+
except (json.JSONDecodeError, ValueError):
|
|
436
|
+
return JsonResponse(create_error_response("Invalid JSON body", 400), status=400)
|
|
437
|
+
if not isinstance(body, list):
|
|
438
|
+
return JsonResponse(create_error_response("Request body must be a JSON array", 400), status=400)
|
|
439
|
+
|
|
440
|
+
scope_filter = _get_scope(user, role)
|
|
441
|
+
succeeded = 0
|
|
442
|
+
failed = 0
|
|
443
|
+
results = []
|
|
444
|
+
for item_data in body:
|
|
445
|
+
try:
|
|
446
|
+
item_id = item_data.get(_lf)
|
|
447
|
+
if item_id is None:
|
|
448
|
+
failed += 1
|
|
449
|
+
continue
|
|
450
|
+
existing = storage.get(_table, item_id, lookup_field=_lf)
|
|
451
|
+
if existing is None:
|
|
452
|
+
failed += 1
|
|
453
|
+
continue
|
|
454
|
+
if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
|
|
455
|
+
failed += 1
|
|
456
|
+
continue
|
|
457
|
+
data = {k: v for k, v in item_data.items() if k in _input and k != _lf}
|
|
458
|
+
item = storage.update(_table, item_id, data, lookup_field=_lf)
|
|
459
|
+
if item:
|
|
460
|
+
item = filter_response(item, _schema)
|
|
461
|
+
results.append(item)
|
|
462
|
+
succeeded += 1
|
|
463
|
+
else:
|
|
464
|
+
failed += 1
|
|
465
|
+
except Exception:
|
|
466
|
+
failed += 1
|
|
467
|
+
return JsonResponse({
|
|
468
|
+
"data": results,
|
|
469
|
+
"meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
|
|
470
|
+
}, status=200)
|
|
471
|
+
|
|
472
|
+
if request.method == "DELETE":
|
|
473
|
+
user, role, err = _check_auth(request, "delete")
|
|
474
|
+
if err:
|
|
475
|
+
return err
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
body = json.loads(request.body)
|
|
479
|
+
except (json.JSONDecodeError, ValueError):
|
|
480
|
+
return JsonResponse(create_error_response("Invalid JSON body", 400), status=400)
|
|
481
|
+
if not isinstance(body, list):
|
|
482
|
+
return JsonResponse(create_error_response("Request body must be a JSON array", 400), status=400)
|
|
483
|
+
|
|
484
|
+
scope_filter = _get_scope(user, role)
|
|
485
|
+
succeeded = 0
|
|
486
|
+
failed = 0
|
|
487
|
+
for item_id in body:
|
|
488
|
+
try:
|
|
489
|
+
existing = storage.get(_table, item_id, lookup_field=_lf)
|
|
490
|
+
if existing is None:
|
|
491
|
+
failed += 1
|
|
492
|
+
continue
|
|
493
|
+
if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
|
|
494
|
+
failed += 1
|
|
495
|
+
continue
|
|
496
|
+
deleted = storage.delete(_table, item_id, soft=supports_soft_delete, lookup_field=_lf)
|
|
497
|
+
if deleted:
|
|
498
|
+
succeeded += 1
|
|
499
|
+
else:
|
|
500
|
+
failed += 1
|
|
501
|
+
except Exception:
|
|
502
|
+
failed += 1
|
|
503
|
+
return JsonResponse({
|
|
504
|
+
"data": [],
|
|
505
|
+
"meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
|
|
506
|
+
}, status=200)
|
|
507
|
+
|
|
508
|
+
return JsonResponse(create_error_response("Method not allowed", 405), status=405)
|
|
509
|
+
|
|
510
|
+
patterns.append(path(f"{table}/bulk/", csrf_exempt(bulk_view), name=f"{table}_bulk"))
|
|
511
|
+
|
|
512
|
+
elif "update" in schema.permissions or "delete" in schema.permissions:
|
|
513
|
+
def bulk_view(request, _table=table, _input=input_fields, _schema=schema, _lf=lookup_field):
|
|
514
|
+
if request.method == "PUT" and "update" in schema.permissions:
|
|
515
|
+
user, role, err = _check_auth(request, "update")
|
|
516
|
+
if err:
|
|
517
|
+
return err
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
body = json.loads(request.body)
|
|
521
|
+
except (json.JSONDecodeError, ValueError):
|
|
522
|
+
return JsonResponse(create_error_response("Invalid JSON body", 400), status=400)
|
|
523
|
+
if not isinstance(body, list):
|
|
524
|
+
return JsonResponse(create_error_response("Request body must be a JSON array", 400), status=400)
|
|
525
|
+
|
|
526
|
+
scope_filter = _get_scope(user, role)
|
|
527
|
+
succeeded = 0
|
|
528
|
+
failed = 0
|
|
529
|
+
results = []
|
|
530
|
+
for item_data in body:
|
|
531
|
+
try:
|
|
532
|
+
item_id = item_data.get(_lf)
|
|
533
|
+
if item_id is None:
|
|
534
|
+
failed += 1
|
|
535
|
+
continue
|
|
536
|
+
existing = storage.get(_table, item_id, lookup_field=_lf)
|
|
537
|
+
if existing is None:
|
|
538
|
+
failed += 1
|
|
539
|
+
continue
|
|
540
|
+
if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
|
|
541
|
+
failed += 1
|
|
542
|
+
continue
|
|
543
|
+
data = {k: v for k, v in item_data.items() if k in _input and k != _lf}
|
|
544
|
+
item = storage.update(_table, item_id, data, lookup_field=_lf)
|
|
545
|
+
if item:
|
|
546
|
+
item = filter_response(item, _schema)
|
|
547
|
+
results.append(item)
|
|
548
|
+
succeeded += 1
|
|
549
|
+
else:
|
|
550
|
+
failed += 1
|
|
551
|
+
except Exception:
|
|
552
|
+
failed += 1
|
|
553
|
+
return JsonResponse({
|
|
554
|
+
"data": results,
|
|
555
|
+
"meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
|
|
556
|
+
}, status=200)
|
|
557
|
+
|
|
558
|
+
if request.method == "DELETE" and "delete" in schema.permissions:
|
|
559
|
+
user, role, err = _check_auth(request, "delete")
|
|
560
|
+
if err:
|
|
561
|
+
return err
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
body = json.loads(request.body)
|
|
565
|
+
except (json.JSONDecodeError, ValueError):
|
|
566
|
+
return JsonResponse(create_error_response("Invalid JSON body", 400), status=400)
|
|
567
|
+
if not isinstance(body, list):
|
|
568
|
+
return JsonResponse(create_error_response("Request body must be a JSON array", 400), status=400)
|
|
569
|
+
|
|
570
|
+
scope_filter = _get_scope(user, role)
|
|
571
|
+
succeeded = 0
|
|
572
|
+
failed = 0
|
|
573
|
+
for item_id in body:
|
|
574
|
+
try:
|
|
575
|
+
existing = storage.get(_table, item_id, lookup_field=_lf)
|
|
576
|
+
if existing is None:
|
|
577
|
+
failed += 1
|
|
578
|
+
continue
|
|
579
|
+
if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
|
|
580
|
+
failed += 1
|
|
581
|
+
continue
|
|
582
|
+
deleted = storage.delete(_table, item_id, soft=supports_soft_delete, lookup_field=_lf)
|
|
583
|
+
if deleted:
|
|
584
|
+
succeeded += 1
|
|
585
|
+
else:
|
|
586
|
+
failed += 1
|
|
587
|
+
except Exception:
|
|
588
|
+
failed += 1
|
|
589
|
+
return JsonResponse({
|
|
590
|
+
"data": [],
|
|
591
|
+
"meta": {"total": len(body), "succeeded": succeeded, "failed": failed},
|
|
592
|
+
}, status=200)
|
|
593
|
+
|
|
594
|
+
return JsonResponse(create_error_response("Method not allowed", 405), status=405)
|
|
595
|
+
|
|
596
|
+
patterns.append(path(f"{table}/bulk/", csrf_exempt(bulk_view), name=f"{table}_bulk"))
|
|
597
|
+
|
|
598
|
+
# --- Export ---
|
|
599
|
+
if "list" in schema.permissions:
|
|
600
|
+
from flashapi.features.export import CONTENT_TYPES, EXPORTERS
|
|
601
|
+
|
|
602
|
+
def export_view(request, _table=table, _schema=schema):
|
|
603
|
+
if request.method != "GET":
|
|
604
|
+
return JsonResponse(create_error_response("Method not allowed", 405), status=405)
|
|
605
|
+
|
|
606
|
+
user, role, err = _check_auth(request, "list")
|
|
607
|
+
if err:
|
|
608
|
+
return err
|
|
609
|
+
|
|
610
|
+
fmt = request.GET.get("format", "csv").lower()
|
|
611
|
+
if fmt not in EXPORTERS:
|
|
612
|
+
return JsonResponse(
|
|
613
|
+
create_error_response(f"Unsupported format: {fmt}. Use csv, xlsx, or pdf", 400), status=400,
|
|
614
|
+
)
|
|
615
|
+
items = storage.list_all(_table)
|
|
616
|
+
|
|
617
|
+
scope_filter = _get_scope(user, role)
|
|
618
|
+
if scope_filter:
|
|
619
|
+
items = [i for i in items if all(i.get(k) == v for k, v in scope_filter.items())]
|
|
620
|
+
|
|
621
|
+
all_fields = sorted(export_fields(_schema))
|
|
622
|
+
requested = request.GET.get("fields", "")
|
|
623
|
+
if requested:
|
|
624
|
+
fields = [f for f in requested.split(",") if f in all_fields]
|
|
625
|
+
if not fields:
|
|
626
|
+
return JsonResponse(
|
|
627
|
+
create_error_response(f"No valid fields. Available: {', '.join(all_fields)}", 400), status=400,
|
|
628
|
+
)
|
|
629
|
+
else:
|
|
630
|
+
fields = all_fields
|
|
631
|
+
try:
|
|
632
|
+
content = EXPORTERS[fmt](items, fields)
|
|
633
|
+
except ImportError as e:
|
|
634
|
+
return JsonResponse(
|
|
635
|
+
create_error_response(str(e), 400), status=400,
|
|
636
|
+
)
|
|
637
|
+
response = HttpResponse(content, content_type=CONTENT_TYPES[fmt])
|
|
638
|
+
response["Content-Disposition"] = f'attachment; filename="{_table}.{fmt}"'
|
|
639
|
+
return response
|
|
640
|
+
|
|
641
|
+
patterns.append(path(f"{table}/export/", export_view, name=f"{table}_export"))
|
|
642
|
+
|
|
643
|
+
# --- Detail (read, update, delete) ---
|
|
644
|
+
if any(op in schema.permissions for op in ["read", "update", "delete"]):
|
|
645
|
+
|
|
646
|
+
def detail_view(request, item_id, _table=table, _fields=field_names, _schema=schema,
|
|
647
|
+
_input=input_fields, _lookup=lookup_field):
|
|
648
|
+
if request.method == "GET" and "read" in _schema.permissions:
|
|
649
|
+
user, role, err = _check_auth(request, "read")
|
|
650
|
+
if err:
|
|
651
|
+
return err
|
|
652
|
+
|
|
653
|
+
item = storage.get(_table, item_id, lookup_field=_lookup)
|
|
654
|
+
if item is None:
|
|
655
|
+
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
656
|
+
|
|
657
|
+
scope_filter = _get_scope(user, role)
|
|
658
|
+
if scope_filter and not all(item.get(k) == v for k, v in scope_filter.items()):
|
|
659
|
+
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
660
|
+
|
|
661
|
+
item = filter_response(item, _schema)
|
|
662
|
+
return JsonResponse(create_item_response(item, formatter))
|
|
663
|
+
|
|
664
|
+
if request.method == "PUT" and "update" in _schema.permissions:
|
|
665
|
+
user, role, err = _check_auth(request, "update")
|
|
666
|
+
if err:
|
|
667
|
+
return err
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
body = json.loads(request.body)
|
|
671
|
+
except (json.JSONDecodeError, ValueError):
|
|
672
|
+
return JsonResponse(create_error_response("Invalid JSON body", 400), status=400)
|
|
673
|
+
|
|
674
|
+
old_item = storage.get(_table, item_id, lookup_field=_lookup)
|
|
675
|
+
if old_item is None:
|
|
676
|
+
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
677
|
+
|
|
678
|
+
scope_filter = _get_scope(user, role)
|
|
679
|
+
if scope_filter and not all(old_item.get(k) == v for k, v in scope_filter.items()):
|
|
680
|
+
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
681
|
+
|
|
682
|
+
data = {k: v for k, v in body.items() if k in _input}
|
|
683
|
+
item = storage.update(_table, item_id, data, lookup_field=_lookup)
|
|
684
|
+
if item is None:
|
|
685
|
+
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
686
|
+
if metrics:
|
|
687
|
+
metrics.record("UPDATE", entity_name, str(item_id))
|
|
688
|
+
if audit_log and entity_audit:
|
|
689
|
+
audit_log.record("UPDATE", entity_name, item_id, performed_by=_get_performer(user), old_data=old_item, new_data=item)
|
|
690
|
+
if webhook:
|
|
691
|
+
webhook.dispatch("UPDATE", entity_name, item_id, item)
|
|
692
|
+
_broadcast(entity_name, "UPDATE", item)
|
|
693
|
+
item = filter_response(item, _schema)
|
|
694
|
+
return JsonResponse(create_item_response(item, formatter))
|
|
695
|
+
|
|
696
|
+
if request.method == "DELETE" and "delete" in _schema.permissions:
|
|
697
|
+
user, role, err = _check_auth(request, "delete")
|
|
698
|
+
if err:
|
|
699
|
+
return err
|
|
700
|
+
|
|
701
|
+
existing = storage.get(_table, item_id, lookup_field=_lookup)
|
|
702
|
+
if existing is None:
|
|
703
|
+
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
704
|
+
|
|
705
|
+
scope_filter = _get_scope(user, role)
|
|
706
|
+
if scope_filter and not all(existing.get(k) == v for k, v in scope_filter.items()):
|
|
707
|
+
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
708
|
+
|
|
709
|
+
deleted = storage.delete(_table, item_id, soft=supports_soft_delete, lookup_field=_lookup)
|
|
710
|
+
if not deleted:
|
|
711
|
+
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
712
|
+
if metrics:
|
|
713
|
+
metrics.record("DELETE", entity_name, str(item_id))
|
|
714
|
+
if audit_log and entity_audit:
|
|
715
|
+
audit_log.record("DELETE", entity_name, item_id, performed_by=_get_performer(user))
|
|
716
|
+
if webhook:
|
|
717
|
+
webhook.dispatch("DELETE", entity_name, item_id, {})
|
|
718
|
+
_broadcast(entity_name, "DELETE", {"id": str(item_id)})
|
|
719
|
+
return HttpResponse(status=204)
|
|
720
|
+
|
|
721
|
+
return JsonResponse(create_error_response("Method not allowed", 405), status=405)
|
|
722
|
+
|
|
723
|
+
if lookup_field == "id":
|
|
724
|
+
patterns.append(path(f"{table}/<int:item_id>/", csrf_exempt(detail_view), name=f"{table}_detail"))
|
|
725
|
+
else:
|
|
726
|
+
patterns.append(path(f"{table}/<str:item_id>/", csrf_exempt(detail_view), name=f"{table}_detail"))
|
|
727
|
+
|
|
728
|
+
# --- Restore (only if soft_delete) ---
|
|
729
|
+
if "delete" in schema.permissions and supports_soft_delete:
|
|
730
|
+
|
|
731
|
+
def restore_view(request, item_id, _table=table, _lookup=lookup_field):
|
|
732
|
+
if request.method != "POST":
|
|
733
|
+
return JsonResponse(create_error_response("Method not allowed", 405), status=405)
|
|
734
|
+
|
|
735
|
+
_user, _role, err = _check_auth(request, "delete")
|
|
736
|
+
if err:
|
|
737
|
+
return err
|
|
738
|
+
|
|
739
|
+
restored = storage.restore(_table, item_id, lookup_field=_lookup)
|
|
740
|
+
if not restored:
|
|
741
|
+
return JsonResponse(create_error_response("Not found", 404), status=404)
|
|
742
|
+
_broadcast(entity_name, "RESTORE", {"id": str(item_id)})
|
|
743
|
+
return HttpResponse(status=204)
|
|
744
|
+
|
|
745
|
+
if lookup_field == "id":
|
|
746
|
+
patterns.append(path(f"{table}/<int:item_id>/restore/", csrf_exempt(restore_view), name=f"{table}_restore"))
|
|
747
|
+
else:
|
|
748
|
+
patterns.append(path(f"{table}/<str:item_id>/restore/", csrf_exempt(restore_view), name=f"{table}_restore"))
|
|
749
|
+
|
|
750
|
+
# --- History (only if audit) ---
|
|
751
|
+
if "read" in schema.permissions and entity_audit:
|
|
752
|
+
|
|
753
|
+
def history_view(request, item_id, _table=table, _entity=entity_name, _audit=audit_log):
|
|
754
|
+
if request.method != "GET":
|
|
755
|
+
return JsonResponse(create_error_response("Method not allowed", 405), status=405)
|
|
756
|
+
|
|
757
|
+
_user, _role, err = _check_auth(request, "read")
|
|
758
|
+
if err:
|
|
759
|
+
return err
|
|
760
|
+
|
|
761
|
+
if _audit is None:
|
|
762
|
+
return JsonResponse(create_error_response("Audit not enabled", 404), status=404)
|
|
763
|
+
history = _audit.get_history(_entity, str(item_id))
|
|
764
|
+
return JsonResponse({"data": history}, safe=False)
|
|
765
|
+
|
|
766
|
+
if lookup_field == "id":
|
|
767
|
+
patterns.append(path(f"{table}/<int:item_id>/history/", history_view, name=f"{table}_history"))
|
|
768
|
+
else:
|
|
769
|
+
patterns.append(path(f"{table}/<str:item_id>/history/", history_view, name=f"{table}_history"))
|
|
770
|
+
|
|
771
|
+
return patterns
|