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.
Files changed (48) hide show
  1. flashapi/__init__.py +8 -7
  2. flashapi/adapters/base.py +13 -12
  3. flashapi/adapters/django.py +771 -165
  4. flashapi/adapters/fastapi.py +879 -293
  5. flashapi/adapters/flask.py +728 -232
  6. flashapi/core/__init__.py +11 -3
  7. flashapi/core/custom_routes.py +5 -5
  8. flashapi/core/pluralize.py +80 -80
  9. flashapi/core/relations.py +59 -61
  10. flashapi/core/response.py +36 -33
  11. flashapi/core/schema.py +145 -83
  12. flashapi/core/visibility.py +46 -0
  13. flashapi/django.py +5 -5
  14. flashapi/docs/openapi.py +298 -223
  15. flashapi/fastapi.py +5 -5
  16. flashapi/features/__init__.py +6 -6
  17. flashapi/features/audit.py +84 -0
  18. flashapi/features/auth.py +134 -0
  19. flashapi/features/dashboard.py +412 -0
  20. flashapi/features/export.py +143 -0
  21. flashapi/features/filtering.py +124 -33
  22. flashapi/features/pagination.py +20 -20
  23. flashapi/features/rate_limit.py +45 -0
  24. flashapi/features/search.py +25 -25
  25. flashapi/features/sorting.py +23 -21
  26. flashapi/features/webhooks.py +84 -0
  27. flashapi/features/websocket.py +129 -0
  28. flashapi/flask.py +5 -5
  29. flashapi/inspectors/__init__.py +3 -3
  30. flashapi/inspectors/base.py +13 -11
  31. flashapi/inspectors/dataclass.py +50 -39
  32. flashapi/inspectors/detect.py +54 -48
  33. flashapi/inspectors/django.py +93 -83
  34. flashapi/inspectors/pydantic.py +99 -84
  35. flashapi/inspectors/sqlalchemy.py +83 -75
  36. flashapi/storage/__init__.py +4 -4
  37. flashapi/storage/auto.py +189 -106
  38. flashapi/storage/base.py +32 -26
  39. flashapi/storage/orm.py +125 -85
  40. flashapi/storage/sqlalchemy.py +56 -13
  41. python_flashapi-0.2.0.dist-info/METADATA +314 -0
  42. python_flashapi-0.2.0.dist-info/RECORD +48 -0
  43. python_flashapi-0.2.0.dist-info/licenses/LICENSE +190 -0
  44. python_flashapi-0.2.0.dist-info/licenses/NOTICE +5 -0
  45. python_flashapi-0.1.2.dist-info/METADATA +0 -259
  46. python_flashapi-0.1.2.dist-info/RECORD +0 -39
  47. python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
  48. {python_flashapi-0.1.2.dist-info → python_flashapi-0.2.0.dist-info}/WHEEL +0 -0
@@ -1,165 +1,771 @@
1
- from __future__ import annotations
2
-
3
- from typing import Callable
4
-
5
- from flashapi.core.schema import Model, ModelSchema
6
- from flashapi.core.response import create_list_response, create_item_response
7
- from flashapi.core.custom_routes import (
8
- CustomRoute, custom_routes_to_openapi_paths, discover_django_views,
9
- )
10
- from flashapi.features import paginate, apply_filters, apply_sorting, apply_search
11
- from flashapi.inspectors import inspect_model
12
- from flashapi.storage.orm import DjangoORMStorage
13
- from flashapi.docs.openapi import generate_openapi_schema, get_swagger_html
14
-
15
-
16
- def generate_urls(
17
- models: list[type | Model],
18
- *,
19
- custom_routes: list[CustomRoute] | None = None,
20
- extra_views: list | None = None,
21
- docs: bool = True,
22
- formatter: Callable | None = None,
23
- ):
24
- """Generate Django URL patterns for the given models.
25
-
26
- Custom views decorated with @api_doc are auto-discovered from extra_views
27
- or from all urlpatterns in the same file.
28
- """
29
-
30
- urlpatterns = []
31
- all_schemas: list[ModelSchema] = []
32
-
33
- for model_entry in models:
34
- if isinstance(model_entry, Model):
35
- wrapper = model_entry
36
- else:
37
- wrapper = Model(model_entry)
38
-
39
- schema = inspect_model(wrapper.model_class, plural=wrapper.plural)
40
- schema.permissions = wrapper.permissions
41
- storage = DjangoORMStorage(wrapper.model_class)
42
- all_schemas.append(schema)
43
- patterns = _create_django_views(schema, storage, formatter)
44
- urlpatterns.extend(patterns)
45
-
46
- if docs:
47
- urlpatterns.extend(_create_docs_views(
48
- all_schemas, custom_routes or [], extra_views or [],
49
- ))
50
-
51
- return urlpatterns
52
-
53
-
54
- def _create_docs_views(schemas: list[ModelSchema], custom_routes: list[CustomRoute], extra_views: list):
55
- from django.urls import path
56
- from django.http import JsonResponse, HttpResponse
57
-
58
- openapi_spec = generate_openapi_schema(schemas, trailing_slash=True)
59
-
60
- # Method 1: explicit CustomRoute objects
61
- if custom_routes:
62
- custom_paths = custom_routes_to_openapi_paths(custom_routes, trailing_slash=True)
63
- openapi_spec["paths"].update(custom_paths)
64
-
65
- # Method 2: auto-discover @api_doc decorated views from extra_views (URL patterns)
66
- if extra_views:
67
- discovered = discover_django_views(extra_views, trailing_slash=True)
68
- openapi_spec["paths"].update(discovered)
69
-
70
- def openapi_json(request):
71
- spec = dict(openapi_spec)
72
- base_path = request.path.rsplit("openapi.json", 1)[0]
73
- spec["servers"] = [{"url": base_path}]
74
- return JsonResponse(spec, safe=False)
75
-
76
- def docs_ui(request):
77
- base_path = request.path.rsplit("docs/", 1)[0]
78
- openapi_url = f"{base_path}openapi.json"
79
- html = get_swagger_html(title="FlashAPI", openapi_url=openapi_url)
80
- return HttpResponse(html, content_type="text/html")
81
-
82
- return [
83
- path("openapi.json", openapi_json, name="flashapi_openapi"),
84
- path("docs/", docs_ui, name="flashapi_docs"),
85
- ]
86
-
87
-
88
- def _create_django_views(
89
- schema: ModelSchema,
90
- storage: DjangoORMStorage,
91
- formatter: Callable | None,
92
- ):
93
- from django.urls import path
94
- from django.http import JsonResponse
95
- from django.views.decorators.csrf import csrf_exempt
96
- import json
97
-
98
- table = schema.plural
99
- field_names = {f.name for f in schema.fields if not f.primary_key}
100
- patterns = []
101
-
102
- if "list" in schema.permissions or "create" in schema.permissions:
103
-
104
- def collection_view(request, _table=table, _fields=field_names, _schema=schema):
105
- if request.method == "GET" and "list" in _schema.permissions:
106
- items = storage.list_all(_table)
107
- params = dict(request.GET)
108
- params = {k: v[0] if isinstance(v, list) else v for k, v in params.items()}
109
- try:
110
- page = max(1, int(params.get("page", 1)))
111
- page_size = max(1, min(100, int(params.get("page_size", 20))))
112
- except (ValueError, TypeError):
113
- return JsonResponse({"error": "Invalid page or page_size parameter"}, status=400)
114
- sort = params.get("sort")
115
- search = params.get("search")
116
-
117
- items = apply_filters(items, params, _fields)
118
- items = apply_search(items, search, _fields)
119
- items = apply_sorting(items, sort, _fields)
120
- page_items, total = paginate(items, page, page_size)
121
- return JsonResponse(
122
- create_list_response(page_items, total, page, page_size, formatter)
123
- )
124
-
125
- elif request.method == "POST" and "create" in _schema.permissions:
126
- try:
127
- body = json.loads(request.body)
128
- except (json.JSONDecodeError, ValueError):
129
- return JsonResponse({"error": "Invalid JSON body"}, status=400)
130
- data = {k: v for k, v in body.items() if k in _fields}
131
- item = storage.create(_table, data)
132
- return JsonResponse(create_item_response(item, formatter), status=201)
133
-
134
- return JsonResponse({"error": "Method not allowed"}, status=405)
135
-
136
- patterns.append(path(f"{table}/", csrf_exempt(collection_view), name=f"{table}_collection"))
137
-
138
- if any(op in schema.permissions for op in ["read", "update", "delete"]):
139
-
140
- def detail_view(request, item_id, _table=table, _fields=field_names, _schema=schema):
141
- if request.method == "GET" and "read" in _schema.permissions:
142
- item = storage.get(_table, item_id)
143
- if item is None:
144
- return JsonResponse({"error": "Not found"}, status=404)
145
- return JsonResponse(create_item_response(item, formatter))
146
-
147
- elif request.method == "PUT" and "update" in _schema.permissions:
148
- body = json.loads(request.body)
149
- data = {k: v for k, v in body.items() if k in _fields}
150
- item = storage.update(_table, item_id, data)
151
- if item is None:
152
- return JsonResponse({"error": "Not found"}, status=404)
153
- return JsonResponse(create_item_response(item, formatter))
154
-
155
- elif request.method == "DELETE" and "delete" in _schema.permissions:
156
- deleted = storage.delete(_table, item_id)
157
- if not deleted:
158
- return JsonResponse({"error": "Not found"}, status=404)
159
- return JsonResponse({}, status=204)
160
-
161
- return JsonResponse({"error": "Method not allowed"}, status=405)
162
-
163
- patterns.append(path(f"{table}/<int:item_id>/", csrf_exempt(detail_view), name=f"{table}_detail"))
164
-
165
- return patterns
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