half-orm-gen 1.0.0a1__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.
- half_orm_gen/__init__.py +16 -0
- half_orm_gen/api_routes.py +224 -0
- half_orm_gen/cli_extension.py +170 -0
- half_orm_gen/crud_routes.py +505 -0
- half_orm_gen/gen_app/__init__.py +26 -0
- half_orm_gen/gen_app/angular.py +1727 -0
- half_orm_gen/gen_app/svelte.py +1336 -0
- half_orm_gen/gen_store/__init__.py +34 -0
- half_orm_gen/gen_store/base.py +88 -0
- half_orm_gen/gen_store/svelte.py +282 -0
- half_orm_gen/generate.py +120 -0
- half_orm_gen/scaffold.py +37 -0
- half_orm_gen/scaffolding/api_init.py +1 -0
- half_orm_gen/scaffolding/custom_authorization.py +36 -0
- half_orm_gen/scaffolding/custom_init.py +1 -0
- half_orm_gen/scaffolding/custom_middlewares_init.py +18 -0
- half_orm_gen/scaffolding/custom_routes.py +18 -0
- half_orm_gen/scaffolding/guards.py +40 -0
- half_orm_gen/scaffolding/roles_core.py +62 -0
- half_orm_gen/templates.py +454 -0
- half_orm_gen/templates_fastapi.py +264 -0
- half_orm_gen/tools.py +66 -0
- half_orm_gen/version.txt +1 -0
- half_orm_gen-1.0.0a1.dist-info/METADATA +73 -0
- half_orm_gen-1.0.0a1.dist-info/RECORD +29 -0
- half_orm_gen-1.0.0a1.dist-info/WHEEL +5 -0
- half_orm_gen-1.0.0a1.dist-info/licenses/AUTHORS +3 -0
- half_orm_gen-1.0.0a1.dist-info/licenses/LICENSE +674 -0
- half_orm_gen-1.0.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Role composition decorators for half-orm-litestar.
|
|
3
|
+
|
|
4
|
+
Two decorators are provided:
|
|
5
|
+
|
|
6
|
+
- @authorize_and(role_name): both the decorated function AND the named role
|
|
7
|
+
must return True (use for role refinement: "permanent IS a membre").
|
|
8
|
+
|
|
9
|
+
- @authorize_or(role_name): either the decorated function OR the named role
|
|
10
|
+
must return True (use for alternative access paths).
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
# api/roles/permanent.py
|
|
15
|
+
from api.roles.core import authorize_and
|
|
16
|
+
|
|
17
|
+
@authorize_and("membre")
|
|
18
|
+
async def authorize(path_params, jwt_payload):
|
|
19
|
+
return jwt_payload.is_permanent
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import importlib
|
|
23
|
+
from functools import wraps
|
|
24
|
+
from typing import Awaitable, Callable
|
|
25
|
+
|
|
26
|
+
AuthorizeFunc = Callable[[dict, object], Awaitable[bool]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def authorize_and(role_name: str) -> Callable[[AuthorizeFunc], AuthorizeFunc]:
|
|
30
|
+
"""Compose authorization with AND logic.
|
|
31
|
+
|
|
32
|
+
The named role's authorize() must return True AND the decorated function
|
|
33
|
+
must return True.
|
|
34
|
+
"""
|
|
35
|
+
def decorator(func: AuthorizeFunc) -> AuthorizeFunc:
|
|
36
|
+
@wraps(func)
|
|
37
|
+
async def wrapper(path_params: dict, jwt_payload) -> bool:
|
|
38
|
+
role_module = importlib.import_module(f"api.roles.{role_name}")
|
|
39
|
+
return (
|
|
40
|
+
await role_module.authorize(path_params, jwt_payload)
|
|
41
|
+
and await func(path_params, jwt_payload)
|
|
42
|
+
)
|
|
43
|
+
return wrapper
|
|
44
|
+
return decorator
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def authorize_or(role_name: str) -> Callable[[AuthorizeFunc], AuthorizeFunc]:
|
|
48
|
+
"""Compose authorization with OR logic.
|
|
49
|
+
|
|
50
|
+
The named role's authorize() returning True OR the decorated function
|
|
51
|
+
returning True is sufficient.
|
|
52
|
+
"""
|
|
53
|
+
def decorator(func: AuthorizeFunc) -> AuthorizeFunc:
|
|
54
|
+
@wraps(func)
|
|
55
|
+
async def wrapper(path_params: dict, jwt_payload) -> bool:
|
|
56
|
+
role_module = importlib.import_module(f"api.roles.{role_name}")
|
|
57
|
+
return (
|
|
58
|
+
await role_module.authorize(path_params, jwt_payload)
|
|
59
|
+
or await func(path_params, jwt_payload)
|
|
60
|
+
)
|
|
61
|
+
return wrapper
|
|
62
|
+
return decorator
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Template strings for the generated api/app.py.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
FRAMEWORK = 'litestar'
|
|
6
|
+
|
|
7
|
+
HEADER = """\
|
|
8
|
+
# This file is generated by `half_orm litestar generate`. Do not edit by hand.
|
|
9
|
+
from typing import Union, Any, List, TypedDict
|
|
10
|
+
import datetime
|
|
11
|
+
import uuid
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import typing
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
cur_dir = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
sys.path.insert(0, cur_dir)
|
|
19
|
+
par_dir = os.path.join(cur_dir, os.path.pardir)
|
|
20
|
+
sys.path.insert(0, par_dir)
|
|
21
|
+
|
|
22
|
+
from litestar import Request, Litestar, get, post, patch, put, delete, Response, MediaType
|
|
23
|
+
from litestar.exceptions import HTTPException
|
|
24
|
+
from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
|
|
25
|
+
from litestar.logging import LoggingConfig
|
|
26
|
+
from litestar.openapi import OpenAPIConfig
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
|
|
29
|
+
from {module} import ho_baseclasses
|
|
30
|
+
from {module} import ho_typeddicts
|
|
31
|
+
from {module} import MODEL
|
|
32
|
+
from api import guards
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from api.custom.routes import routes
|
|
36
|
+
except ImportError:
|
|
37
|
+
routes = []
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from api.custom.middlewares import middlewares
|
|
41
|
+
except ImportError:
|
|
42
|
+
middlewares = []
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from api.custom.middlewares.authorization import Authorization
|
|
46
|
+
_auth_middleware = [Authorization]
|
|
47
|
+
except ImportError:
|
|
48
|
+
_auth_middleware = []
|
|
49
|
+
|
|
50
|
+
logging_config = LoggingConfig(
|
|
51
|
+
loggers={{
|
|
52
|
+
"app": {{
|
|
53
|
+
"level": "ERROR",
|
|
54
|
+
"handlers": ["queue_listener"],
|
|
55
|
+
}}
|
|
56
|
+
}}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
FOOTER = '''
|
|
62
|
+
_HO_WARN = """
|
|
63
|
+
======================================================================
|
|
64
|
+
halfORM DEV HELPERS ACTIVE — NOT FOR PRODUCTION
|
|
65
|
+
======================================================================
|
|
66
|
+
/ho_roles : exposes all declared roles (no authentication)
|
|
67
|
+
/ho_access : exposes the full access map filtered by role
|
|
68
|
+
_get_roles : bearer token used directly as a role name
|
|
69
|
+
(no signature verification)
|
|
70
|
+
ho_dev : super-role with full access to all resources
|
|
71
|
+
(Authorization: Bearer ho_dev)
|
|
72
|
+
|
|
73
|
+
Replace the Authorization middleware with a real JWT implementation
|
|
74
|
+
before deploying to production.
|
|
75
|
+
======================================================================
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
_HO_WARN_SHOWN = False
|
|
79
|
+
|
|
80
|
+
async def _ho_startup() -> None:
|
|
81
|
+
global _HO_WARN_SHOWN
|
|
82
|
+
import sys
|
|
83
|
+
await MODEL.aconnect()
|
|
84
|
+
if MODEL._production_mode:
|
|
85
|
+
raise RuntimeError(
|
|
86
|
+
"halfORM DEV HELPERS are active (ho_roles, ho_access, _get_roles fallback). "
|
|
87
|
+
"These routes and the bearer-token-as-role fallback are not safe for production. "
|
|
88
|
+
"Secure or remove them before deploying."
|
|
89
|
+
)
|
|
90
|
+
if not _HO_WARN_SHOWN:
|
|
91
|
+
print(_HO_WARN, file=sys.stderr, flush=True)
|
|
92
|
+
_HO_WARN_SHOWN = True
|
|
93
|
+
|
|
94
|
+
application = Litestar(
|
|
95
|
+
route_handlers=[{route_handlers}] + routes,
|
|
96
|
+
middleware=_auth_middleware + middlewares,
|
|
97
|
+
logging_config=logging_config,
|
|
98
|
+
on_startup=[_ho_startup],{openapi_config}
|
|
99
|
+
)
|
|
100
|
+
'''
|
|
101
|
+
|
|
102
|
+
OPENAPI_CONFIG = """
|
|
103
|
+
openapi_config=OpenAPIConfig(title="{title}", version="{version}"),"""
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# @api_* route templates
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
IMPORT = "\nfrom {schema} import {module_name} as {module_alias}\n"
|
|
110
|
+
|
|
111
|
+
GET = """
|
|
112
|
+
@get({litestar_args})
|
|
113
|
+
async def {full_name}({query_params}) -> typing.List[ho_baseclasses.{dc_name}]:
|
|
114
|
+
return await {module_alias}.{class_name}().{name}({params})
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
POST = """
|
|
118
|
+
@post({litestar_args})
|
|
119
|
+
async def {full_name}({query_params}) -> ho_baseclasses.{dc_name}:
|
|
120
|
+
return await {module_alias}.{class_name}().{name}({params})
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
PATCH = """
|
|
124
|
+
@patch({litestar_args})
|
|
125
|
+
async def {full_name}(request: "Request", data: ho_baseclasses.{dc_name}) -> ho_baseclasses.{dc_name}:
|
|
126
|
+
return await {module_alias}.{class_name}().{name}({params})
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
PUT = """
|
|
130
|
+
@put({litestar_args})
|
|
131
|
+
async def {full_name}(request: "Request", {path_params}) -> ho_baseclasses.{dc_name}:
|
|
132
|
+
return await {module_alias}.{class_name}().{name}({params})
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
DELETE = """
|
|
136
|
+
@delete({litestar_args})
|
|
137
|
+
async def {full_name}(request: "Request", {path_params}) -> None:
|
|
138
|
+
return await {module_alias}.{class_name}().{name}({params})
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
DIRECT_API = """
|
|
142
|
+
from {module_str} import {function} as {function_alias}
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
HTTP = {
|
|
146
|
+
'GET': GET,
|
|
147
|
+
'POST': POST,
|
|
148
|
+
'PATCH': PATCH,
|
|
149
|
+
'PUT': PUT,
|
|
150
|
+
'DELETE': DELETE,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Auto-CRUD templates
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
CRUD_HELPERS = """
|
|
158
|
+
def _get_roles(request):
|
|
159
|
+
'''Return authorized roles: from middleware state, or bearer token as role (dev fallback).'''
|
|
160
|
+
roles = getattr(request.state, 'authorized_roles', None)
|
|
161
|
+
if roles is not None:
|
|
162
|
+
return roles
|
|
163
|
+
token = request.headers.get('Authorization', '').removeprefix('Bearer ').strip()
|
|
164
|
+
return [token] if token else ['public']
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_role_filter(crud_access, verb, authorized_roles):
|
|
168
|
+
'''Return mandatory filter kwargs for authorized roles (row-level security).'''
|
|
169
|
+
role_map = crud_access.get(verb, {})
|
|
170
|
+
combined = {}
|
|
171
|
+
for role in authorized_roles:
|
|
172
|
+
rv = role_map.get(role)
|
|
173
|
+
if isinstance(rv, dict) and 'filter' in rv:
|
|
174
|
+
combined.update(rv['filter'])
|
|
175
|
+
return combined
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _effective_out_fields(crud_access, verb, authorized_roles, api_excluded=None):
|
|
179
|
+
api_excluded = api_excluded or []
|
|
180
|
+
if 'ho_dev' in authorized_roles:
|
|
181
|
+
return [] # ho_dev: unrestricted access to all fields
|
|
182
|
+
role_map = crud_access.get(verb, {})
|
|
183
|
+
get_map = crud_access.get('GET', {})
|
|
184
|
+
fields = []
|
|
185
|
+
for role in authorized_roles:
|
|
186
|
+
if role not in role_map:
|
|
187
|
+
return None # role not authorized for this verb
|
|
188
|
+
rv = role_map[role]
|
|
189
|
+
if isinstance(rv, dict):
|
|
190
|
+
if 'out' in rv:
|
|
191
|
+
out = rv['out']
|
|
192
|
+
else:
|
|
193
|
+
get_rv = get_map.get(role)
|
|
194
|
+
out = get_rv if not isinstance(get_rv, dict) else get_rv.get('out')
|
|
195
|
+
else:
|
|
196
|
+
out = rv
|
|
197
|
+
if out is None:
|
|
198
|
+
return [] # role authorized, all fields
|
|
199
|
+
fields.extend(out)
|
|
200
|
+
return [f for f in dict.fromkeys(fields) if f not in api_excluded]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _effective_in_fields(crud_access, verb, authorized_roles, api_excluded=None):
|
|
204
|
+
api_excluded = api_excluded or []
|
|
205
|
+
role_map = crud_access.get(verb, {})
|
|
206
|
+
fields = []
|
|
207
|
+
for role in authorized_roles:
|
|
208
|
+
rv = role_map.get(role)
|
|
209
|
+
if rv is None or not isinstance(rv, dict):
|
|
210
|
+
return []
|
|
211
|
+
in_val = rv.get('in')
|
|
212
|
+
if in_val is None:
|
|
213
|
+
return []
|
|
214
|
+
fields.extend(in_val)
|
|
215
|
+
return [f for f in dict.fromkeys(fields) if f not in api_excluded]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_access_map():
|
|
219
|
+
result = {}
|
|
220
|
+
for resource, verbs in _STATIC_ACCESS_MAP.items():
|
|
221
|
+
entry = {}
|
|
222
|
+
for verb, roles in verbs.items():
|
|
223
|
+
entry[verb] = dict(roles)
|
|
224
|
+
result[resource] = entry
|
|
225
|
+
if not MODEL._production_mode:
|
|
226
|
+
for resource, ho_verbs in _HO_DEV_MAP.items():
|
|
227
|
+
entry = result.setdefault(resource, {})
|
|
228
|
+
for verb, ho_val in ho_verbs.items():
|
|
229
|
+
verb_entry = dict(entry.get(verb, {}))
|
|
230
|
+
verb_entry['ho_dev'] = ho_val
|
|
231
|
+
entry[verb] = verb_entry
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _filter_access_for_roles(access_map, authorized_roles):
|
|
236
|
+
result = {}
|
|
237
|
+
for resource, verbs in access_map.items():
|
|
238
|
+
resource_entry = {}
|
|
239
|
+
for verb, roles in verbs.items():
|
|
240
|
+
if verb == 'DELETE':
|
|
241
|
+
if any(r in roles and roles[r] == 'allowed' for r in authorized_roles):
|
|
242
|
+
resource_entry[verb] = True
|
|
243
|
+
else:
|
|
244
|
+
active = {r: roles[r] for r in authorized_roles if r in roles}
|
|
245
|
+
if not active:
|
|
246
|
+
continue
|
|
247
|
+
if verb == 'GET':
|
|
248
|
+
out = []
|
|
249
|
+
for v in active.values():
|
|
250
|
+
out.extend(v.get('out', []))
|
|
251
|
+
resource_entry[verb] = {'out': list(dict.fromkeys(out))}
|
|
252
|
+
else:
|
|
253
|
+
in_f, out_f = [], []
|
|
254
|
+
for v in active.values():
|
|
255
|
+
in_f.extend(v.get('in', []))
|
|
256
|
+
out_f.extend(v.get('out', []))
|
|
257
|
+
resource_entry[verb] = {
|
|
258
|
+
'in': list(dict.fromkeys(in_f)),
|
|
259
|
+
'out': list(dict.fromkeys(out_f)),
|
|
260
|
+
}
|
|
261
|
+
if resource_entry:
|
|
262
|
+
result[resource] = resource_entry
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
CRUD_TYPEDICT = """
|
|
268
|
+
class {class_name}(TypedDict, total=False):
|
|
269
|
+
{fields}
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
CRUD_MODULE_IMPORT = "\nfrom {schema} import {module_name} as {module_alias}\n"
|
|
273
|
+
|
|
274
|
+
HO_ACCESS_ROUTE = (
|
|
275
|
+
'\n_STATIC_ACCESS_MAP = {json_str}\n'
|
|
276
|
+
'\n_ACCESS_MAP = get_access_map()\n\n'
|
|
277
|
+
'@get("{version_prefix}/ho_access", guards=[guards.public])\n'
|
|
278
|
+
'async def _crud_access_map(request: Request) -> dict:\n'
|
|
279
|
+
' authorized_roles = _get_roles(request)\n'
|
|
280
|
+
' return _filter_access_for_roles(_ACCESS_MAP, authorized_roles)\n'
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
HO_ROLES_ROUTE = (
|
|
284
|
+
'\n_ROLES = {roles_json}\n\n'
|
|
285
|
+
'@get("{version_prefix}/ho_roles", guards=[guards.public])\n'
|
|
286
|
+
'async def _crud_roles_list(request: Request) -> list:\n'
|
|
287
|
+
' return _ROLES\n'
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def typedict_block(class_name: str, field_names: list, all_fields: dict) -> str:
|
|
292
|
+
from half_orm_gen.crud_routes import _py_type_str
|
|
293
|
+
lines = [f'class {class_name}(TypedDict, total=False):']
|
|
294
|
+
valid = [(f, all_fields[f]) for f in field_names if f in all_fields]
|
|
295
|
+
if not valid:
|
|
296
|
+
lines.append(' pass')
|
|
297
|
+
else:
|
|
298
|
+
for fname, fobj in valid:
|
|
299
|
+
lines.append(f' {fname}: Optional[{_py_type_str(fobj.py_type)}]')
|
|
300
|
+
return '\n'.join(lines)
|
|
301
|
+
|
|
302
|
+
WS_HELPERS = """
|
|
303
|
+
import json as _json
|
|
304
|
+
from litestar import WebSocket, websocket
|
|
305
|
+
|
|
306
|
+
class _ConnectionManager:
|
|
307
|
+
def __init__(self):
|
|
308
|
+
self._sockets: set = set()
|
|
309
|
+
|
|
310
|
+
async def connect(self, socket: WebSocket) -> None:
|
|
311
|
+
await socket.accept()
|
|
312
|
+
self._sockets.add(socket)
|
|
313
|
+
|
|
314
|
+
def disconnect(self, socket: WebSocket) -> None:
|
|
315
|
+
self._sockets.discard(socket)
|
|
316
|
+
|
|
317
|
+
async def broadcast(self, message: dict) -> None:
|
|
318
|
+
dead = set()
|
|
319
|
+
for s in set(self._sockets):
|
|
320
|
+
try:
|
|
321
|
+
await s.send_data(_json.dumps(message, default=str))
|
|
322
|
+
except Exception:
|
|
323
|
+
dead.add(s)
|
|
324
|
+
self._sockets -= dead
|
|
325
|
+
|
|
326
|
+
_manager = _ConnectionManager()
|
|
327
|
+
|
|
328
|
+
@websocket("{version_prefix}/ws")
|
|
329
|
+
async def _ws_handler(socket: WebSocket) -> None:
|
|
330
|
+
await _manager.connect(socket)
|
|
331
|
+
try:
|
|
332
|
+
while True:
|
|
333
|
+
await socket.receive_data(mode="text")
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
336
|
+
finally:
|
|
337
|
+
_manager.disconnect(socket)
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
CRUD_GET_LIST = """
|
|
341
|
+
@get("{path}", description="{access_description}")
|
|
342
|
+
async def {handler_name}(
|
|
343
|
+
request: Request,
|
|
344
|
+
{filter_params} fields: Optional[List[str]] = None,
|
|
345
|
+
limit: Optional[int] = None,
|
|
346
|
+
offset: Optional[int] = None,
|
|
347
|
+
) -> list[{out_typedict}]:
|
|
348
|
+
api_excluded = getattr({module_alias}, 'API_EXCLUDED_FIELDS', [])
|
|
349
|
+
roles = _get_roles(request)
|
|
350
|
+
filter_kwargs = {{{filter_dict}}}
|
|
351
|
+
role_filter = _get_role_filter(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "GET", roles)
|
|
352
|
+
authorized = _effective_out_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "GET", roles, api_excluded)
|
|
353
|
+
if authorized is None:
|
|
354
|
+
return []
|
|
355
|
+
if fields:
|
|
356
|
+
projection = [f for f in fields if not authorized or f in authorized]
|
|
357
|
+
else:
|
|
358
|
+
projection = authorized
|
|
359
|
+
return await {module_alias}.{class_name}(**{{**filter_kwargs, **role_filter}}).ho_aselect(
|
|
360
|
+
*projection, limit=limit, offset=offset
|
|
361
|
+
)
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
CRUD_GET_ONE = """
|
|
365
|
+
@get("{path}/{{id: {pk_path_type}}}", description="{access_description}")
|
|
366
|
+
async def {handler_name}_get(
|
|
367
|
+
request: Request,
|
|
368
|
+
id: {pk_py_type},
|
|
369
|
+
) -> {out_typedict}:
|
|
370
|
+
api_excluded = getattr({module_alias}, 'API_EXCLUDED_FIELDS', [])
|
|
371
|
+
roles = _get_roles(request)
|
|
372
|
+
role_filter = _get_role_filter(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "GET", roles)
|
|
373
|
+
authorized = _effective_out_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "GET", roles, api_excluded)
|
|
374
|
+
if authorized is None:
|
|
375
|
+
raise HTTPException(status_code=403)
|
|
376
|
+
rows = await {module_alias}.{class_name}({pk_instance_filter}, **role_filter).ho_aselect(*authorized)
|
|
377
|
+
if not rows:
|
|
378
|
+
raise HTTPException(status_code=404)
|
|
379
|
+
return rows[0]
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
CRUD_POST = """
|
|
383
|
+
@post("{path}", description="{access_description}")
|
|
384
|
+
async def {handler_name}_create(
|
|
385
|
+
request: Request,
|
|
386
|
+
data: {in_typedict},
|
|
387
|
+
) -> {out_typedict}:
|
|
388
|
+
api_excluded = getattr({module_alias}, 'API_EXCLUDED_FIELDS', [])
|
|
389
|
+
in_fields = _effective_in_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "POST", _get_roles(request), api_excluded)
|
|
390
|
+
payload = {{k: v for k, v in dict(data).items() if v is not None and (not in_fields or k in in_fields)}}
|
|
391
|
+
result = await {module_alias}.{class_name}(**payload).ho_ainsert()
|
|
392
|
+
await _manager.broadcast({{"event": "create", "resource": "{resource}", "id": {pk_broadcast_expr}}})
|
|
393
|
+
return result
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
CRUD_PUT = """
|
|
397
|
+
@put("{path}/{{id: {pk_path_type}}}", description="{access_description}")
|
|
398
|
+
async def {handler_name}_update(
|
|
399
|
+
request: Request,
|
|
400
|
+
id: {pk_py_type},
|
|
401
|
+
data: {in_typedict},
|
|
402
|
+
) -> {out_typedict}:
|
|
403
|
+
api_excluded = getattr({module_alias}, 'API_EXCLUDED_FIELDS', [])
|
|
404
|
+
in_fields = _effective_in_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "PUT", _get_roles(request), api_excluded)
|
|
405
|
+
payload = {{k: v for k, v in dict(data).items() if v is not None and (not in_fields or k in in_fields)}}
|
|
406
|
+
authorized = _effective_out_fields(getattr({module_alias}, 'CRUD_ACCESS', {{}}), "PUT", _get_roles(request), api_excluded)
|
|
407
|
+
result = await {module_alias}.{class_name}({pk_instance_filter}).ho_aupdate(*(authorized or ['*']), **payload)
|
|
408
|
+
if not result:
|
|
409
|
+
raise HTTPException(status_code=404)
|
|
410
|
+
await _manager.broadcast({{"event": "update", "resource": "{resource}", "id": id}})
|
|
411
|
+
return result[0]
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
CRUD_DELETE = """
|
|
415
|
+
@delete("{path}/{{id: {pk_path_type}}}", description="{access_description}")
|
|
416
|
+
async def {handler_name}_delete(
|
|
417
|
+
request: Request,
|
|
418
|
+
id: {pk_py_type},
|
|
419
|
+
) -> None:
|
|
420
|
+
await _ws_broadcast_cascade(
|
|
421
|
+
{module_alias}.{class_name}({pk_instance_filter}), "{resource}", id
|
|
422
|
+
)
|
|
423
|
+
result = await {module_alias}.{class_name}({pk_instance_filter}).ho_adelete('*')
|
|
424
|
+
if not result:
|
|
425
|
+
raise HTTPException(status_code=404)
|
|
426
|
+
await _manager.broadcast({{"event": "delete", "resource": "{resource}", "id": id}})
|
|
427
|
+
"""
|
|
428
|
+
|
|
429
|
+
WS_CASCADE_HELPER = """
|
|
430
|
+
_WS_RMAP: dict = {{
|
|
431
|
+
{resource_entries}
|
|
432
|
+
}}
|
|
433
|
+
|
|
434
|
+
async def _ws_broadcast_cascade(inst, resource: str, pk_val, _seen: set | None = None) -> None:
|
|
435
|
+
if _seen is None:
|
|
436
|
+
_seen = set()
|
|
437
|
+
_key = (resource, str(pk_val))
|
|
438
|
+
if _key in _seen:
|
|
439
|
+
return
|
|
440
|
+
_seen.add(_key)
|
|
441
|
+
for _fk in inst._ho_fkeys.values():
|
|
442
|
+
if not _fk.is_reverse or len(_fk.fk_names) != 1:
|
|
443
|
+
continue
|
|
444
|
+
_fk_field = _fk.fk_names[0]
|
|
445
|
+
_fqtn = _fk.remote['fqtn']
|
|
446
|
+
_r = f"{{_fqtn[0].replace('.', '_')}}/{{_fqtn[1]}}"
|
|
447
|
+
if _r not in _WS_RMAP:
|
|
448
|
+
continue
|
|
449
|
+
_cls, _pk = _WS_RMAP[_r]
|
|
450
|
+
for _row in await _cls(**{{_fk_field: pk_val}}).ho_aselect(_pk):
|
|
451
|
+
_rid = _row[_pk]
|
|
452
|
+
await _ws_broadcast_cascade(_cls(**{{_pk: _rid}}), _r, _rid, _seen)
|
|
453
|
+
await _manager.broadcast({{"event": "delete", "resource": _r, "id": _rid}})
|
|
454
|
+
"""
|