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