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,1727 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Angular 22 backoffice generator.
|
|
3
|
+
|
|
4
|
+
Signal-based state (no NgRx), standalone components, Tailwind CSS.
|
|
5
|
+
- src/app/generated/stores/ — regenerable stores
|
|
6
|
+
- src/app/generated/components/ — regenerable List/Create/Detail components
|
|
7
|
+
- src/app/core/auth.guard.ts — route guard (token required)
|
|
8
|
+
- routes use canActivate: [authGuard] for all resource pages
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from half_orm_gen.crud_routes import (
|
|
16
|
+
_gen_out_fields,
|
|
17
|
+
_gen_in_fields,
|
|
18
|
+
_simple_pk,
|
|
19
|
+
_instance,
|
|
20
|
+
_py_type_str,
|
|
21
|
+
)
|
|
22
|
+
from half_orm_gen.gen_store.base import StoreGenerator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Naming helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def _cname(schema_name: str, table_name: str) -> str:
|
|
30
|
+
"""PascalCase — BlogAuthor"""
|
|
31
|
+
return ''.join(p.capitalize() for p in f'{schema_name}_{table_name}'.split('_'))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _selector(schema_name: str, table_name: str, suffix: str) -> str:
|
|
35
|
+
"""app-blog-author-list"""
|
|
36
|
+
slug = f'{schema_name}_{table_name}'.replace('_', '-')
|
|
37
|
+
return f'app-{slug}-{suffix}'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _title(schema_name: str, table_name: str) -> str:
|
|
41
|
+
return f'{schema_name}.{table_name}'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _store_import_path(schema_name: str, table_name: str, depth: int) -> str:
|
|
45
|
+
prefix = '../' * depth
|
|
46
|
+
return f"{prefix}stores/{schema_name}_{table_name}.store"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _core_path(depth: int) -> str:
|
|
50
|
+
return '../' * depth + 'core'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Static file templates
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
_PACKAGE_JSON = """\
|
|
58
|
+
{{
|
|
59
|
+
"name": "{project_name}",
|
|
60
|
+
"version": "0.0.1",
|
|
61
|
+
"private": true,
|
|
62
|
+
"scripts": {{
|
|
63
|
+
"start": "ng serve",
|
|
64
|
+
"build": "ng build",
|
|
65
|
+
"watch": "ng build --watch --configuration development"
|
|
66
|
+
}},
|
|
67
|
+
"dependencies": {{
|
|
68
|
+
"@angular/animations": "^22.0.0",
|
|
69
|
+
"@angular/common": "^22.0.0",
|
|
70
|
+
"@angular/compiler": "^22.0.0",
|
|
71
|
+
"@angular/core": "^22.0.0",
|
|
72
|
+
"@angular/forms": "^22.0.0",
|
|
73
|
+
"@angular/platform-browser": "^22.0.0",
|
|
74
|
+
"@angular/platform-browser-dynamic": "^22.0.0",
|
|
75
|
+
"@angular/router": "^22.0.0",
|
|
76
|
+
"rxjs": "~7.8.0",
|
|
77
|
+
"tslib": "^2.3.0",
|
|
78
|
+
"zone.js": "~0.15.0"
|
|
79
|
+
}},
|
|
80
|
+
"devDependencies": {{
|
|
81
|
+
"@angular/build": "^22.0.0",
|
|
82
|
+
"@angular/cli": "^22.0.0",
|
|
83
|
+
"@angular/compiler-cli": "^22.0.0",
|
|
84
|
+
"autoprefixer": "^10.4.0",
|
|
85
|
+
"postcss": "^8.4.0",
|
|
86
|
+
"tailwindcss": "^3.4.0",
|
|
87
|
+
"typescript": "~6.0.0"
|
|
88
|
+
}}
|
|
89
|
+
}}
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
_ANGULAR_JSON = """\
|
|
93
|
+
{{
|
|
94
|
+
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
|
95
|
+
"version": 1,
|
|
96
|
+
"projects": {{
|
|
97
|
+
"{project_name}": {{
|
|
98
|
+
"projectType": "application",
|
|
99
|
+
"root": "",
|
|
100
|
+
"sourceRoot": "src",
|
|
101
|
+
"architect": {{
|
|
102
|
+
"build": {{
|
|
103
|
+
"builder": "@angular/build:application",
|
|
104
|
+
"options": {{
|
|
105
|
+
"outputPath": "dist/{project_name}",
|
|
106
|
+
"index": "src/index.html",
|
|
107
|
+
"browser": "src/main.ts",
|
|
108
|
+
"polyfills": ["zone.js"],
|
|
109
|
+
"tsConfig": "tsconfig.app.json",
|
|
110
|
+
"assets": [{{"glob": "**/*", "input": "public"}}],
|
|
111
|
+
"styles": ["src/styles.css"],
|
|
112
|
+
"scripts": []
|
|
113
|
+
}},
|
|
114
|
+
"configurations": {{
|
|
115
|
+
"production": {{
|
|
116
|
+
"budgets": [
|
|
117
|
+
{{"type": "initial", "maximumWarning": "500kB", "maximumError": "1MB"}},
|
|
118
|
+
{{"type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB"}}
|
|
119
|
+
],
|
|
120
|
+
"outputHashing": "all"
|
|
121
|
+
}},
|
|
122
|
+
"development": {{
|
|
123
|
+
"optimization": false,
|
|
124
|
+
"extractLicenses": false,
|
|
125
|
+
"sourceMap": true
|
|
126
|
+
}}
|
|
127
|
+
}},
|
|
128
|
+
"defaultConfiguration": "production"
|
|
129
|
+
}},
|
|
130
|
+
"serve": {{
|
|
131
|
+
"builder": "@angular/build:dev-server",
|
|
132
|
+
"configurations": {{
|
|
133
|
+
"production": {{"buildTarget": "{project_name}:build:production"}},
|
|
134
|
+
"development": {{
|
|
135
|
+
"buildTarget": "{project_name}:build:development",
|
|
136
|
+
"proxyConfig": "proxy.conf.json"
|
|
137
|
+
}}
|
|
138
|
+
}},
|
|
139
|
+
"defaultConfiguration": "development"
|
|
140
|
+
}}
|
|
141
|
+
}}
|
|
142
|
+
}}
|
|
143
|
+
}}
|
|
144
|
+
}}
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
_TSCONFIG = """\
|
|
148
|
+
{
|
|
149
|
+
"compileOnSave": false,
|
|
150
|
+
"compilerOptions": {
|
|
151
|
+
"outDir": "./dist/out-tsc",
|
|
152
|
+
"strict": true,
|
|
153
|
+
"noImplicitOverride": true,
|
|
154
|
+
"noPropertyAccessFromIndexSignature": true,
|
|
155
|
+
"noImplicitReturns": true,
|
|
156
|
+
"noFallthroughCasesInSwitch": true,
|
|
157
|
+
"skipLibCheck": true,
|
|
158
|
+
"isolatedModules": true,
|
|
159
|
+
"esModuleInterop": true,
|
|
160
|
+
"moduleResolution": "bundler",
|
|
161
|
+
"importHelpers": true,
|
|
162
|
+
"target": "ES2022",
|
|
163
|
+
"module": "ES2022",
|
|
164
|
+
"lib": ["ES2022", "dom"]
|
|
165
|
+
},
|
|
166
|
+
"angularCompilerOptions": {
|
|
167
|
+
"enableI18nLegacyMessageIdFormat": false,
|
|
168
|
+
"strictInjectionParameters": true,
|
|
169
|
+
"strictInputAccessModifiers": true,
|
|
170
|
+
"strictTemplates": true
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
_TSCONFIG_APP = """\
|
|
176
|
+
{
|
|
177
|
+
"extends": "./tsconfig.json",
|
|
178
|
+
"compilerOptions": {
|
|
179
|
+
"outDir": "./out-tsc/app",
|
|
180
|
+
"types": []
|
|
181
|
+
},
|
|
182
|
+
"files": ["src/main.ts"],
|
|
183
|
+
"include": ["src/**/*.d.ts"]
|
|
184
|
+
}
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
_INDEX_HTML = """\
|
|
188
|
+
<!doctype html>
|
|
189
|
+
<html lang="en">
|
|
190
|
+
<head>
|
|
191
|
+
<meta charset="utf-8">
|
|
192
|
+
<title>{project_title}</title>
|
|
193
|
+
<base href="/">
|
|
194
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
195
|
+
</head>
|
|
196
|
+
<body>
|
|
197
|
+
<app-root></app-root>
|
|
198
|
+
</body>
|
|
199
|
+
</html>
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
_STYLES_CSS = """\
|
|
203
|
+
@tailwind base;
|
|
204
|
+
@tailwind components;
|
|
205
|
+
@tailwind utilities;
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
_TAILWIND_CONFIG = """\
|
|
209
|
+
/** @type {import('tailwindcss').Config} */
|
|
210
|
+
module.exports = {
|
|
211
|
+
content: ['./src/**/*.{html,ts}'],
|
|
212
|
+
theme: { extend: {} },
|
|
213
|
+
plugins: []
|
|
214
|
+
};
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
_POSTCSS_CONFIG = """\
|
|
218
|
+
module.exports = {
|
|
219
|
+
plugins: { tailwindcss: {}, autoprefixer: {} }
|
|
220
|
+
};
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
_MAIN_TS = """\
|
|
224
|
+
import { bootstrapApplication } from '@angular/platform-browser';
|
|
225
|
+
import { appConfig } from './app/app.config';
|
|
226
|
+
import { AppComponent } from './app/app.component';
|
|
227
|
+
|
|
228
|
+
bootstrapApplication(AppComponent, appConfig)
|
|
229
|
+
.catch(err => console.error(err));
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
_APP_CONFIG_TS = """\
|
|
233
|
+
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
|
234
|
+
import { provideRouter } from '@angular/router';
|
|
235
|
+
import { provideHttpClient } from '@angular/common/http';
|
|
236
|
+
import { routes } from './app.routes';
|
|
237
|
+
|
|
238
|
+
export const appConfig: ApplicationConfig = {
|
|
239
|
+
providers: [
|
|
240
|
+
provideZoneChangeDetection({ eventCoalescing: true }),
|
|
241
|
+
provideRouter(routes),
|
|
242
|
+
provideHttpClient(),
|
|
243
|
+
]
|
|
244
|
+
};
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
_STATE_REGISTRY = """\
|
|
248
|
+
const _fns: Array<() => void> = [];
|
|
249
|
+
export function registerClear(fn: () => void): void { _fns.push(fn); }
|
|
250
|
+
export function clearAllStates(): void { _fns.forEach(fn => fn()); }
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Dynamic templates
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def _proxy_conf(version_prefix: str) -> str:
|
|
259
|
+
prefix = version_prefix or '/api'
|
|
260
|
+
return (
|
|
261
|
+
'{\n'
|
|
262
|
+
f' "{prefix}": {{\n'
|
|
263
|
+
' "target": "http://localhost:8000",\n'
|
|
264
|
+
' "secure": false,\n'
|
|
265
|
+
' "ws": true\n'
|
|
266
|
+
' }\n'
|
|
267
|
+
'}\n'
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _auth_service(version_prefix: str) -> str:
|
|
272
|
+
return f"""\
|
|
273
|
+
import {{ Injectable, signal }} from '@angular/core';
|
|
274
|
+
import {{ Subject }} from 'rxjs';
|
|
275
|
+
import {{ clearAllStates }} from './state-registry';
|
|
276
|
+
|
|
277
|
+
export interface WsEvent {{
|
|
278
|
+
event: 'create' | 'update' | 'delete';
|
|
279
|
+
resource: string;
|
|
280
|
+
id: unknown;
|
|
281
|
+
}}
|
|
282
|
+
|
|
283
|
+
@Injectable({{ providedIn: 'root' }})
|
|
284
|
+
export class AuthService {{
|
|
285
|
+
readonly token = signal<string | null>(
|
|
286
|
+
typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('ho_token') : null
|
|
287
|
+
);
|
|
288
|
+
readonly access = signal<Record<string, any>>({{}});
|
|
289
|
+
readonly wsEvent$ = new Subject<WsEvent>();
|
|
290
|
+
readonly fetchedRoutes = new Set<string>();
|
|
291
|
+
|
|
292
|
+
login(t: string): void {{
|
|
293
|
+
sessionStorage.setItem('ho_token', t);
|
|
294
|
+
this.token.set(t);
|
|
295
|
+
this.fetchedRoutes.clear();
|
|
296
|
+
clearAllStates();
|
|
297
|
+
void this._fetchAccess();
|
|
298
|
+
}}
|
|
299
|
+
|
|
300
|
+
logout(): void {{
|
|
301
|
+
sessionStorage.removeItem('ho_token');
|
|
302
|
+
this.token.set(null);
|
|
303
|
+
this.fetchedRoutes.clear();
|
|
304
|
+
clearAllStates();
|
|
305
|
+
void this._fetchAccess();
|
|
306
|
+
}}
|
|
307
|
+
|
|
308
|
+
async _fetchAccess(): Promise<void> {{
|
|
309
|
+
const hdrs: Record<string, string> = this.token()
|
|
310
|
+
? {{ Authorization: `Bearer ${{this.token()}}` }}
|
|
311
|
+
: {{}};
|
|
312
|
+
try {{
|
|
313
|
+
const res = await fetch('{version_prefix}/ho_access', {{ headers: hdrs }});
|
|
314
|
+
this.access.set(res.ok ? await res.json() : {{}});
|
|
315
|
+
}} catch {{
|
|
316
|
+
this.access.set({{}});
|
|
317
|
+
}}
|
|
318
|
+
}}
|
|
319
|
+
|
|
320
|
+
connectWs(): void {{
|
|
321
|
+
const proto = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
322
|
+
const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000';
|
|
323
|
+
const ws = new WebSocket(`${{proto}}://${{host}}{version_prefix}/ws`);
|
|
324
|
+
ws.onmessage = (e) => {{
|
|
325
|
+
try {{ this.wsEvent$.next(JSON.parse(e.data) as WsEvent); }} catch {{}}
|
|
326
|
+
}};
|
|
327
|
+
ws.onclose = () => {{ setTimeout(() => this.connectWs(), 2000); }};
|
|
328
|
+
ws.onerror = () => ws.close();
|
|
329
|
+
}}
|
|
330
|
+
}}
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _app_component(resources: list) -> str:
|
|
335
|
+
nav_items_js = ',\n '.join(
|
|
336
|
+
f'{{ href: "/ho_bo/{sn}/{tn}", label: "{_title(sn, tn)}" }}'
|
|
337
|
+
for sn, tn, *_ in resources
|
|
338
|
+
)
|
|
339
|
+
return f"""\
|
|
340
|
+
import {{ Component, computed, inject, OnInit, signal }} from '@angular/core';
|
|
341
|
+
import {{ RouterLink, RouterLinkActive, RouterOutlet, NavigationEnd, Router }} from '@angular/router';
|
|
342
|
+
import {{ takeUntilDestroyed }} from '@angular/core/rxjs-interop';
|
|
343
|
+
import {{ filter }} from 'rxjs';
|
|
344
|
+
import {{ AuthService }} from './core/auth.service';
|
|
345
|
+
|
|
346
|
+
@Component({{
|
|
347
|
+
selector: 'app-root',
|
|
348
|
+
standalone: true,
|
|
349
|
+
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
|
350
|
+
template: `
|
|
351
|
+
<div class="h-screen flex bg-gray-50 overflow-hidden">
|
|
352
|
+
@if (!isHome()) {{
|
|
353
|
+
<aside class="w-56 shrink-0 bg-white border-r flex flex-col">
|
|
354
|
+
<div class="px-4 py-4 border-b">
|
|
355
|
+
<span class="font-bold text-gray-800">API Browser</span>
|
|
356
|
+
</div>
|
|
357
|
+
<div class="px-2 pt-2 pb-1">
|
|
358
|
+
<input [value]="navFilter()" (input)="navFilter.set($any($event).target.value)"
|
|
359
|
+
placeholder="Filter…"
|
|
360
|
+
class="w-full text-xs border rounded px-2 py-1 text-gray-700"/>
|
|
361
|
+
</div>
|
|
362
|
+
<nav class="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
|
363
|
+
@for (item of filteredNav(); track item.href) {{
|
|
364
|
+
<a [routerLink]="item.href" routerLinkActive="bg-gray-100 font-semibold"
|
|
365
|
+
class="block px-3 py-2 rounded hover:bg-gray-100 text-sm text-gray-700">
|
|
366
|
+
{{{{ item.label }}}}
|
|
367
|
+
</a>
|
|
368
|
+
}}
|
|
369
|
+
</nav>
|
|
370
|
+
<div class="px-2 py-3 border-t">
|
|
371
|
+
<a routerLink="/access" class="block px-3 py-2 rounded hover:bg-gray-100">
|
|
372
|
+
<div class="text-xs text-gray-400 mb-0.5">Role</div>
|
|
373
|
+
<div class="text-sm font-medium" [class]="auth.token() ? 'text-blue-700' : 'text-gray-400'">
|
|
374
|
+
{{{{ auth.token() ?? 'public' }}}}
|
|
375
|
+
</div>
|
|
376
|
+
</a>
|
|
377
|
+
</div>
|
|
378
|
+
</aside>
|
|
379
|
+
}}
|
|
380
|
+
<main class="flex-1 overflow-y-auto p-6">
|
|
381
|
+
<router-outlet />
|
|
382
|
+
</main>
|
|
383
|
+
</div>
|
|
384
|
+
`
|
|
385
|
+
}})
|
|
386
|
+
export class AppComponent implements OnInit {{
|
|
387
|
+
protected auth = inject(AuthService);
|
|
388
|
+
private router = inject(Router);
|
|
389
|
+
|
|
390
|
+
readonly isHome = signal(this.router.url === '/');
|
|
391
|
+
navFilter = signal('');
|
|
392
|
+
readonly navItems = [
|
|
393
|
+
{nav_items_js}
|
|
394
|
+
];
|
|
395
|
+
readonly filteredNav = computed(() =>
|
|
396
|
+
this.navFilter()
|
|
397
|
+
? this.navItems.filter(i => i.label.toLowerCase().includes(this.navFilter().toLowerCase()))
|
|
398
|
+
: this.navItems
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
constructor() {{
|
|
402
|
+
this.router.events.pipe(
|
|
403
|
+
filter(e => e instanceof NavigationEnd),
|
|
404
|
+
takeUntilDestroyed(),
|
|
405
|
+
).subscribe(e => this.isHome.set((e as NavigationEnd).urlAfterRedirects === '/'));
|
|
406
|
+
}}
|
|
407
|
+
|
|
408
|
+
ngOnInit(): void {{
|
|
409
|
+
void this.auth._fetchAccess();
|
|
410
|
+
this.auth.connectWs();
|
|
411
|
+
}}
|
|
412
|
+
}}
|
|
413
|
+
"""
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _auth_guard_ts() -> str:
|
|
417
|
+
return """\
|
|
418
|
+
import { inject } from '@angular/core';
|
|
419
|
+
import { Router } from '@angular/router';
|
|
420
|
+
import { AuthService } from './auth.service';
|
|
421
|
+
|
|
422
|
+
export function authGuard(): boolean {
|
|
423
|
+
const auth = inject(AuthService);
|
|
424
|
+
const router = inject(Router);
|
|
425
|
+
if (auth.token()) return true;
|
|
426
|
+
void router.navigate(['/login']);
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _home_component_ts(first_route: str) -> str:
|
|
433
|
+
return f"""\
|
|
434
|
+
import {{ Component }} from '@angular/core';
|
|
435
|
+
import {{ RouterLink }} from '@angular/router';
|
|
436
|
+
|
|
437
|
+
@Component({{
|
|
438
|
+
selector: 'app-home',
|
|
439
|
+
standalone: true,
|
|
440
|
+
imports: [RouterLink],
|
|
441
|
+
template: `
|
|
442
|
+
<div class="flex flex-col items-center justify-center h-full bg-gray-50 py-16">
|
|
443
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250" width="80" height="80" class="mb-6">
|
|
444
|
+
<path d="M125 30L31.9 63.2l14.2 123.1L125 230l78.9-43.7 14.2-123.1z" fill="#DD0031"/>
|
|
445
|
+
<path d="M125 30v22.2-.1V230l78.9-43.7 14.2-123.1L125 30z" fill="#C3002F"/>
|
|
446
|
+
<path d="M125 52.1L66.8 182.6h21.7l11.7-29.2h49.4l11.7 29.2H173L125 52.1zm17 83.3h-34l17-40.9 17 40.9z" fill="#fff"/>
|
|
447
|
+
</svg>
|
|
448
|
+
<h1 class="text-3xl font-bold text-gray-800 mb-2">halfORM Backoffice</h1>
|
|
449
|
+
<p class="text-gray-500 mb-8">Powered by Angular</p>
|
|
450
|
+
<a [routerLink]="['{first_route}']"
|
|
451
|
+
class="bg-red-600 text-white px-6 py-3 rounded-lg hover:bg-red-700 font-medium transition-colors">
|
|
452
|
+
Open Backoffice →
|
|
453
|
+
</a>
|
|
454
|
+
</div>
|
|
455
|
+
`
|
|
456
|
+
}})
|
|
457
|
+
export class HomeComponent {{}}
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _app_routes(resources: list, first_route: str) -> str:
|
|
462
|
+
lines = [
|
|
463
|
+
"import { Routes } from '@angular/router';",
|
|
464
|
+
"import { authGuard } from './core/auth.guard';",
|
|
465
|
+
'',
|
|
466
|
+
'export const routes: Routes = [',
|
|
467
|
+
" { path: '', loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent) },",
|
|
468
|
+
" { path: 'login', loadComponent: () => import('./pages/login/login.component').then(m => m.LoginComponent) },",
|
|
469
|
+
" { path: 'access', loadComponent: () => import('./pages/access/access.component').then(m => m.AccessComponent) },",
|
|
470
|
+
]
|
|
471
|
+
for sn, tn, _, has_post, _, pk_info, *__ in resources:
|
|
472
|
+
cn = _cname(sn, tn)
|
|
473
|
+
stem = f'{sn}_{tn}'
|
|
474
|
+
base = f'./generated/components/{stem}'
|
|
475
|
+
lines.append(
|
|
476
|
+
f" {{ path: 'ho_bo/{sn}/{tn}', canActivate: [authGuard], loadComponent: () => import('{base}/list.component').then(m => m.{cn}ListComponent) }},"
|
|
477
|
+
)
|
|
478
|
+
if has_post:
|
|
479
|
+
lines.append(
|
|
480
|
+
f" {{ path: 'ho_bo/{sn}/{tn}/new', canActivate: [authGuard], loadComponent: () => import('{base}/create.component').then(m => m.{cn}CreateComponent) }},"
|
|
481
|
+
)
|
|
482
|
+
if pk_info:
|
|
483
|
+
lines.append(
|
|
484
|
+
f" {{ path: 'ho_bo/{sn}/{tn}/:id', canActivate: [authGuard], loadComponent: () => import('{base}/detail.component').then(m => m.{cn}DetailComponent) }},"
|
|
485
|
+
)
|
|
486
|
+
lines += ['];', '']
|
|
487
|
+
return '\n'.join(lines)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _login_component(version_prefix: str) -> str:
|
|
491
|
+
return f"""\
|
|
492
|
+
import {{ Component, inject, OnInit, signal }} from '@angular/core';
|
|
493
|
+
import {{ Router }} from '@angular/router';
|
|
494
|
+
import {{ AuthService }} from '../../core/auth.service';
|
|
495
|
+
|
|
496
|
+
@Component({{
|
|
497
|
+
selector: 'app-login',
|
|
498
|
+
standalone: true,
|
|
499
|
+
template: `
|
|
500
|
+
<div class="max-w-sm mx-auto mt-16 p-6 bg-white rounded-lg shadow">
|
|
501
|
+
<h1 class="text-xl font-bold mb-2">Select a role</h1>
|
|
502
|
+
<p class="text-xs text-gray-400 mb-6">Dev mode — the role name is used as bearer token.</p>
|
|
503
|
+
@if (loading()) {{
|
|
504
|
+
<p class="text-gray-400 text-sm">Loading roles…</p>
|
|
505
|
+
}} @else if (error()) {{
|
|
506
|
+
<p class="text-red-500 text-sm">{{{{ error() }}}}</p>
|
|
507
|
+
}} @else if (roles().length === 0) {{
|
|
508
|
+
<p class="text-gray-500 text-sm">No roles found.</p>
|
|
509
|
+
}} @else {{
|
|
510
|
+
<div class="space-y-2">
|
|
511
|
+
@for (role of roles(); track role) {{
|
|
512
|
+
<button (click)="selectRole(role)"
|
|
513
|
+
class="w-full text-left px-4 py-3 border rounded hover:bg-blue-50
|
|
514
|
+
hover:border-blue-300 transition-colors text-sm font-medium">
|
|
515
|
+
{{{{ role }}}}
|
|
516
|
+
</button>
|
|
517
|
+
}}
|
|
518
|
+
</div>
|
|
519
|
+
}}
|
|
520
|
+
</div>
|
|
521
|
+
`
|
|
522
|
+
}})
|
|
523
|
+
export class LoginComponent implements OnInit {{
|
|
524
|
+
private auth = inject(AuthService);
|
|
525
|
+
private router = inject(Router);
|
|
526
|
+
|
|
527
|
+
readonly roles = signal<string[]>([]);
|
|
528
|
+
readonly loading = signal(true);
|
|
529
|
+
readonly error = signal('');
|
|
530
|
+
|
|
531
|
+
ngOnInit(): void {{
|
|
532
|
+
fetch('{version_prefix}/ho_roles')
|
|
533
|
+
.then(r => {{ if (!r.ok) throw new Error(r.statusText); return r.json(); }})
|
|
534
|
+
.then((d: string[]) => {{ this.roles.set(d); this.loading.set(false); }})
|
|
535
|
+
.catch((e: Error) => {{ this.error.set(e.message); this.loading.set(false); }});
|
|
536
|
+
}}
|
|
537
|
+
|
|
538
|
+
selectRole(role: string): void {{
|
|
539
|
+
this.auth.login(role);
|
|
540
|
+
void this.router.navigate(['/']);
|
|
541
|
+
}}
|
|
542
|
+
}}
|
|
543
|
+
"""
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _access_component(version_prefix: str) -> str:
|
|
547
|
+
return f"""\
|
|
548
|
+
import {{ Component, computed, inject, OnInit, signal }} from '@angular/core';
|
|
549
|
+
import {{ AuthService }} from '../../core/auth.service';
|
|
550
|
+
|
|
551
|
+
const VERB_COLOR: Record<string, string> = {{
|
|
552
|
+
GET: 'bg-blue-100 text-blue-700',
|
|
553
|
+
POST: 'bg-green-100 text-green-700',
|
|
554
|
+
PUT: 'bg-yellow-100 text-yellow-700',
|
|
555
|
+
DELETE: 'bg-red-100 text-red-700',
|
|
556
|
+
}};
|
|
557
|
+
|
|
558
|
+
@Component({{
|
|
559
|
+
selector: 'app-access',
|
|
560
|
+
standalone: true,
|
|
561
|
+
template: `
|
|
562
|
+
<div class="flex h-full gap-6">
|
|
563
|
+
<div class="w-44 shrink-0">
|
|
564
|
+
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Roles</h2>
|
|
565
|
+
@if (rolesLoading()) {{
|
|
566
|
+
<p class="text-gray-400 text-sm">Loading…</p>
|
|
567
|
+
}} @else {{
|
|
568
|
+
<div class="space-y-1">
|
|
569
|
+
@for (role of roles(); track role) {{
|
|
570
|
+
<button (click)="selectRole(role)"
|
|
571
|
+
class="w-full text-left px-3 py-2 rounded text-sm transition-colors"
|
|
572
|
+
[class]="activeRole() === role
|
|
573
|
+
? 'bg-blue-600 text-white font-semibold'
|
|
574
|
+
: 'text-gray-700 hover:bg-gray-100'">
|
|
575
|
+
{{{{ role }}}}
|
|
576
|
+
</button>
|
|
577
|
+
}}
|
|
578
|
+
</div>
|
|
579
|
+
}}
|
|
580
|
+
</div>
|
|
581
|
+
|
|
582
|
+
<div class="flex-1 min-w-0">
|
|
583
|
+
<h1 class="text-2xl font-bold mb-6">
|
|
584
|
+
Authorizations
|
|
585
|
+
<span class="text-base font-normal text-gray-500">— {{{{ activeRole() }}}}</span>
|
|
586
|
+
</h1>
|
|
587
|
+
@if (accessEntries().length === 0) {{
|
|
588
|
+
<p class="text-gray-500 text-sm">No access granted for this role.</p>
|
|
589
|
+
}} @else {{
|
|
590
|
+
<div class="space-y-4">
|
|
591
|
+
@for (entry of accessEntries(); track entry[0]) {{
|
|
592
|
+
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
593
|
+
<div class="px-4 py-2 bg-gray-100 font-semibold text-gray-700 text-sm">
|
|
594
|
+
{{{{ entry[0] }}}}
|
|
595
|
+
</div>
|
|
596
|
+
<div class="divide-y">
|
|
597
|
+
@for (verb of objectEntries(entry[1]); track verb[0]) {{
|
|
598
|
+
<div class="px-4 py-3 flex gap-4 items-start text-sm">
|
|
599
|
+
<span class="inline-block px-2 py-0.5 rounded font-mono text-xs font-bold w-16 text-center"
|
|
600
|
+
[class]="verbColor(verb[0])">
|
|
601
|
+
{{{{ verb[0] }}}}
|
|
602
|
+
</span>
|
|
603
|
+
<div class="text-gray-700">
|
|
604
|
+
@if (verb[0] === 'DELETE') {{
|
|
605
|
+
<span class="text-green-600">allowed</span>
|
|
606
|
+
}} @else if (verb[0] === 'GET') {{
|
|
607
|
+
<span class="text-gray-400">out: </span>{{{{ asGet(verb[1]).join(', ') }}}}
|
|
608
|
+
}} @else {{
|
|
609
|
+
<div><span class="text-gray-400">in: </span>{{{{ asInOut(verb[1]).in.join(', ') }}}}</div>
|
|
610
|
+
<div><span class="text-gray-400">out: </span>{{{{ asInOut(verb[1]).out.join(', ') }}}}</div>
|
|
611
|
+
}}
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
}}
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
}}
|
|
618
|
+
</div>
|
|
619
|
+
}}
|
|
620
|
+
</div>
|
|
621
|
+
</div>
|
|
622
|
+
`
|
|
623
|
+
}})
|
|
624
|
+
export class AccessComponent implements OnInit {{
|
|
625
|
+
protected auth = inject(AuthService);
|
|
626
|
+
|
|
627
|
+
readonly roles = signal<string[]>([]);
|
|
628
|
+
readonly rolesLoading = signal(true);
|
|
629
|
+
readonly activeRole = computed(() => this.auth.token() ?? 'public');
|
|
630
|
+
readonly accessEntries = computed(() => Object.entries(this.auth.access()));
|
|
631
|
+
|
|
632
|
+
ngOnInit(): void {{
|
|
633
|
+
fetch('{version_prefix}/ho_roles')
|
|
634
|
+
.then(r => r.json())
|
|
635
|
+
.then((d: string[]) => {{ this.roles.set(d); this.rolesLoading.set(false); }});
|
|
636
|
+
}}
|
|
637
|
+
|
|
638
|
+
selectRole(role: string): void {{
|
|
639
|
+
if (role === 'public') this.auth.logout();
|
|
640
|
+
else this.auth.login(role);
|
|
641
|
+
}}
|
|
642
|
+
|
|
643
|
+
objectEntries(obj: any): [string, any][] {{ return Object.entries(obj ?? {{}}); }}
|
|
644
|
+
verbColor(verb: string): string {{ return VERB_COLOR[verb] ?? 'bg-gray-100 text-gray-600'; }}
|
|
645
|
+
asGet(v: any): string[] {{ return v?.out ?? []; }}
|
|
646
|
+
asInOut(v: any): {{in: string[]; out: string[]}} {{ return {{ in: v?.in ?? [], out: v?.out ?? [] }}; }}
|
|
647
|
+
}}
|
|
648
|
+
"""
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# ---------------------------------------------------------------------------
|
|
652
|
+
# Per-resource store
|
|
653
|
+
# ---------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
def _store(
|
|
656
|
+
schema_name: str, table_name: str, base_path: str,
|
|
657
|
+
iname: str,
|
|
658
|
+
out_names: list, all_fields: dict, pk_field: str | None, pk_ts_type: str,
|
|
659
|
+
has_post: bool, has_put: bool, has_del: bool,
|
|
660
|
+
post_in_names: list, put_in_names: list,
|
|
661
|
+
) -> str:
|
|
662
|
+
lines = []
|
|
663
|
+
|
|
664
|
+
lines.append("import { Injectable, signal } from '@angular/core';")
|
|
665
|
+
lines.append("import { HttpClient, HttpHeaders } from '@angular/common/http';")
|
|
666
|
+
lines.append("import { inject } from '@angular/core';")
|
|
667
|
+
lines.append("import { catchError, of, tap } from 'rxjs';")
|
|
668
|
+
lines.append("import { AuthService } from '../../core/auth.service';")
|
|
669
|
+
lines.append("import { registerClear } from '../../core/state-registry';")
|
|
670
|
+
lines.append('')
|
|
671
|
+
|
|
672
|
+
def _interface(name: str, field_names: list) -> list:
|
|
673
|
+
result = [f'export interface {name} {{']
|
|
674
|
+
for f in field_names:
|
|
675
|
+
if f in all_fields:
|
|
676
|
+
ts = StoreGenerator.PY_TO_TS.get(_py_type_str(all_fields[f].py_type), 'unknown')
|
|
677
|
+
result.append(f' {f}: {ts};')
|
|
678
|
+
result.append('}')
|
|
679
|
+
return result
|
|
680
|
+
|
|
681
|
+
lines += _interface(f'{iname}Out', out_names)
|
|
682
|
+
lines.append('')
|
|
683
|
+
if has_post:
|
|
684
|
+
lines += _interface(f'{iname}PostIn', post_in_names)
|
|
685
|
+
lines.append('')
|
|
686
|
+
if has_put:
|
|
687
|
+
lines += _interface(f'{iname}PutIn', put_in_names)
|
|
688
|
+
lines.append('')
|
|
689
|
+
|
|
690
|
+
lines.append(f"const _BASE = '{base_path}';")
|
|
691
|
+
lines.append('')
|
|
692
|
+
lines.append(f"@Injectable({{ providedIn: 'root' }})")
|
|
693
|
+
lines.append(f'export class {iname}Store {{')
|
|
694
|
+
lines.append(' private auth = inject(AuthService);')
|
|
695
|
+
lines.append(' private http = inject(HttpClient);')
|
|
696
|
+
lines.append('')
|
|
697
|
+
|
|
698
|
+
if pk_field:
|
|
699
|
+
lines.append(f' readonly items = signal<{iname}Out[]>([]);')
|
|
700
|
+
lines.append(f' readonly byId = signal(new Map<string, {iname}Out>());')
|
|
701
|
+
else:
|
|
702
|
+
lines.append(f' readonly items = signal<{iname}Out[]>([]);')
|
|
703
|
+
|
|
704
|
+
lines.append('')
|
|
705
|
+
lines.append(' constructor() { registerClear(() => this.clear()); }')
|
|
706
|
+
lines.append('')
|
|
707
|
+
lines.append(' private get headers(): HttpHeaders {')
|
|
708
|
+
lines.append(' const t = this.auth.token();')
|
|
709
|
+
lines.append(' return t ? new HttpHeaders({ Authorization: `Bearer ${t}` }) : new HttpHeaders();')
|
|
710
|
+
lines.append(' }')
|
|
711
|
+
lines.append('')
|
|
712
|
+
lines.append(f' listUrl(params: Partial<{iname}Out> = {{}}): string {{')
|
|
713
|
+
lines.append(' return `${_BASE}?${new URLSearchParams(params as any)}`;')
|
|
714
|
+
lines.append(' }')
|
|
715
|
+
|
|
716
|
+
if pk_field:
|
|
717
|
+
lines.append(f' getUrl(id: {pk_ts_type}): string {{ return `${{_BASE}}/${{id}}`; }}')
|
|
718
|
+
lines.append('')
|
|
719
|
+
|
|
720
|
+
lines.append(f' list(params: Partial<{iname}Out> = {{}}): void {{')
|
|
721
|
+
lines.append(' const url = this.listUrl(params);')
|
|
722
|
+
lines.append(' if (this.auth.fetchedRoutes.has(url)) return;')
|
|
723
|
+
lines.append(' this.auth.fetchedRoutes.add(url);')
|
|
724
|
+
lines.append(' const hasFilters = Object.keys(params).length > 0;')
|
|
725
|
+
lines.append(f' this.http.get<{iname}Out[]>(url, {{ headers: this.headers }})')
|
|
726
|
+
lines.append(f' .pipe(catchError(() => of([] as {iname}Out[])))')
|
|
727
|
+
lines.append(' .subscribe(data => { if (hasFilters) this.mergeItems(data); else this.setItems(data); });')
|
|
728
|
+
lines.append(' }')
|
|
729
|
+
lines.append('')
|
|
730
|
+
|
|
731
|
+
if pk_field:
|
|
732
|
+
lines.append(f' get(id: {pk_ts_type}) {{')
|
|
733
|
+
lines.append(' const cached = this.byId().get(String(id));')
|
|
734
|
+
lines.append(' if (cached) return of(cached);')
|
|
735
|
+
lines.append(' const url = this.getUrl(id);')
|
|
736
|
+
lines.append(' this.auth.fetchedRoutes.add(url);')
|
|
737
|
+
lines.append(f' return this.http.get<{iname}Out>(url, {{ headers: this.headers }}).pipe(')
|
|
738
|
+
lines.append(' tap(item => this.setItem(item)),')
|
|
739
|
+
lines.append(f' catchError(() => of(null as {iname}Out | null))')
|
|
740
|
+
lines.append(' );')
|
|
741
|
+
lines.append(' }')
|
|
742
|
+
lines.append('')
|
|
743
|
+
|
|
744
|
+
if has_post:
|
|
745
|
+
lines.append(f' create(data: {iname}PostIn) {{')
|
|
746
|
+
lines.append(f' return this.http.post<{iname}Out>(_BASE, data, {{')
|
|
747
|
+
lines.append(" headers: this.headers.append('Content-Type', 'application/json')")
|
|
748
|
+
lines.append(' });')
|
|
749
|
+
lines.append(' }')
|
|
750
|
+
lines.append('')
|
|
751
|
+
|
|
752
|
+
if has_put and pk_field:
|
|
753
|
+
lines.append(f' update(id: {pk_ts_type}, data: {iname}PutIn) {{')
|
|
754
|
+
lines.append(f' return this.http.put<{iname}Out>(`${{_BASE}}/${{id}}`, data, {{')
|
|
755
|
+
lines.append(" headers: this.headers.append('Content-Type', 'application/json')")
|
|
756
|
+
lines.append(' });')
|
|
757
|
+
lines.append(' }')
|
|
758
|
+
lines.append('')
|
|
759
|
+
|
|
760
|
+
if has_del and pk_field:
|
|
761
|
+
lines.append(f' remove(id: {pk_ts_type}) {{')
|
|
762
|
+
lines.append(f' return this.http.delete(`${{_BASE}}/${{id}}`, {{ headers: this.headers }});')
|
|
763
|
+
lines.append(' }')
|
|
764
|
+
lines.append('')
|
|
765
|
+
|
|
766
|
+
if pk_field:
|
|
767
|
+
lines.append(f' setItems(data: {iname}Out[]): void {{')
|
|
768
|
+
lines.append(' this.items.set(data);')
|
|
769
|
+
lines.append(f' this.byId.set(new Map(data.map(i => [String(i.{pk_field}), i])));')
|
|
770
|
+
lines.append(' }')
|
|
771
|
+
lines.append('')
|
|
772
|
+
lines.append(f' mergeItems(data: {iname}Out[]): void {{')
|
|
773
|
+
lines.append(' const m = new Map(this.byId());')
|
|
774
|
+
lines.append(f' data.forEach(i => m.set(String(i.{pk_field}), i));')
|
|
775
|
+
lines.append(' this.byId.set(m);')
|
|
776
|
+
lines.append(' this.items.set([...m.values()]);')
|
|
777
|
+
lines.append(' }')
|
|
778
|
+
lines.append('')
|
|
779
|
+
lines.append(f' setItem(item: {iname}Out): void {{')
|
|
780
|
+
lines.append(' const m = new Map(this.byId());')
|
|
781
|
+
lines.append(f' m.set(String(item.{pk_field}), item);')
|
|
782
|
+
lines.append(' this.byId.set(m);')
|
|
783
|
+
lines.append(' const arr = [...this.items()];')
|
|
784
|
+
lines.append(f' const idx = arr.findIndex(i => String(i.{pk_field}) === String(item.{pk_field}));')
|
|
785
|
+
lines.append(' if (idx >= 0) arr[idx] = item; else arr.push(item);')
|
|
786
|
+
lines.append(' this.items.set(arr);')
|
|
787
|
+
lines.append(' }')
|
|
788
|
+
lines.append('')
|
|
789
|
+
lines.append(' removeItem(id: string): void {')
|
|
790
|
+
lines.append(' const m = new Map(this.byId()); m.delete(id); this.byId.set(m);')
|
|
791
|
+
lines.append(f' this.items.set(this.items().filter(i => String(i.{pk_field}) !== id));')
|
|
792
|
+
lines.append(' }')
|
|
793
|
+
lines.append('')
|
|
794
|
+
lines.append(' clear(): void { this.items.set([]); this.byId.set(new Map()); }')
|
|
795
|
+
else:
|
|
796
|
+
lines.append(f' setItems(data: {iname}Out[]): void {{ this.items.set(data); }}')
|
|
797
|
+
lines.append(f' mergeItems(data: {iname}Out[]): void {{ this.items.set(data); }}')
|
|
798
|
+
lines.append(' clear(): void { this.items.set([]); }')
|
|
799
|
+
|
|
800
|
+
lines.append('}')
|
|
801
|
+
lines.append('')
|
|
802
|
+
return '\n'.join(lines)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
# ---------------------------------------------------------------------------
|
|
806
|
+
# Per-resource components
|
|
807
|
+
# ---------------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
def _list_component(
|
|
810
|
+
schema_name: str, table_name: str,
|
|
811
|
+
iname: str, map_key: str,
|
|
812
|
+
out_names: list, pk_field: str | None, pk_ts_type: str,
|
|
813
|
+
has_post: bool, has_del: bool,
|
|
814
|
+
fk_deps: list,
|
|
815
|
+
) -> str:
|
|
816
|
+
title = _title(schema_name, table_name)
|
|
817
|
+
fk_map = {lf: (rs, rt) for lf, rs, rt, _ in fk_deps}
|
|
818
|
+
|
|
819
|
+
# Store imports — deduplicated: skip self-referential FKs and multi-FK to same table
|
|
820
|
+
_seen: set[str] = {f'{schema_name}_{table_name}'}
|
|
821
|
+
_unique_fk_deps = []
|
|
822
|
+
for dep in fk_deps:
|
|
823
|
+
_, rs, rt, _ = dep
|
|
824
|
+
stem = f'{rs}_{rt}'
|
|
825
|
+
if stem not in _seen:
|
|
826
|
+
_seen.add(stem)
|
|
827
|
+
_unique_fk_deps.append(dep)
|
|
828
|
+
|
|
829
|
+
fk_imports = '\n'.join(
|
|
830
|
+
f"import {{ {_cname(rs, rt)}Store }} from '../../../generated/stores/{rs}_{rt}.store';"
|
|
831
|
+
for _, rs, rt, _ in _unique_fk_deps
|
|
832
|
+
)
|
|
833
|
+
if fk_imports:
|
|
834
|
+
fk_imports = '\n' + fk_imports
|
|
835
|
+
|
|
836
|
+
fk_injects = '\n'.join(
|
|
837
|
+
f' private {_cname(rs, rt)[0].lower()}{_cname(rs, rt)[1:]}Store = inject({_cname(rs, rt)}Store);'
|
|
838
|
+
for _, rs, rt, _ in _unique_fk_deps
|
|
839
|
+
)
|
|
840
|
+
if fk_injects:
|
|
841
|
+
fk_injects = '\n' + fk_injects
|
|
842
|
+
|
|
843
|
+
# Table headers (sortable)
|
|
844
|
+
th_cols = '\n '.join(
|
|
845
|
+
f'<th (click)="sortBy(\'{f}\')"'
|
|
846
|
+
f' class="px-4 py-2 text-left text-sm font-semibold text-gray-600'
|
|
847
|
+
f' cursor-pointer select-none hover:bg-gray-200">'
|
|
848
|
+
f'{f} {{{{ sortField() === \'{f}\' ? (sortAsc() ? \'↑\' : \'↓\') : \'\' }}}}</th>'
|
|
849
|
+
for f in out_names
|
|
850
|
+
)
|
|
851
|
+
action_th = '<th class="px-2 py-2 w-16"></th>' if has_del and pk_field else ''
|
|
852
|
+
|
|
853
|
+
# Filter row (one input per column, hidden when embedded)
|
|
854
|
+
filter_inputs = '\n '.join(
|
|
855
|
+
f'<th class="px-2 py-1">'
|
|
856
|
+
f'<input [value]="localFilters()[\'{f}\'] || \'\'"'
|
|
857
|
+
f' (input)="setFilter(\'{f}\', $any($event).target.value)"'
|
|
858
|
+
f' placeholder="…"'
|
|
859
|
+
f' class="w-full text-xs border rounded px-2 py-1" /></th>'
|
|
860
|
+
for f in out_names
|
|
861
|
+
)
|
|
862
|
+
action_filter_th = '<th></th>' if has_del and pk_field else ''
|
|
863
|
+
filter_row = (
|
|
864
|
+
f'\n @if (!embedded) {{\n'
|
|
865
|
+
f' <tr class="bg-white border-b">\n'
|
|
866
|
+
f' {action_filter_th}\n'
|
|
867
|
+
f' {filter_inputs}\n'
|
|
868
|
+
f' </tr>\n'
|
|
869
|
+
f' }}'
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
def _td(f: str) -> str:
|
|
873
|
+
if f in fk_map:
|
|
874
|
+
rs, rt = fk_map[f]
|
|
875
|
+
return (
|
|
876
|
+
f'<td class="px-4 py-2 text-sm">'
|
|
877
|
+
f'<a [routerLink]="[\'/ho_bo/{rs}/{rt}\', item.{f}]" (click)="$event.stopPropagation()"'
|
|
878
|
+
f' class="text-blue-500 hover:underline font-mono text-xs truncate block max-w-xs"'
|
|
879
|
+
f' [title]="cellTitle(item.{f})">{{{{ fmtCell(item.{f}) }}}}</a>'
|
|
880
|
+
f'</td>'
|
|
881
|
+
)
|
|
882
|
+
return (
|
|
883
|
+
f'<td class="px-4 py-2 text-sm" (click)="cellClick($event, $any(item).{f})">'
|
|
884
|
+
f'<div class="truncate max-w-xs" [title]="cellTitle(item.{f})"'
|
|
885
|
+
f' [class.text-blue-600]="$any(item).{f} != null && typeof $any(item).{f} === \'object\'"'
|
|
886
|
+
f' [class.cursor-pointer]="$any(item).{f} != null && typeof $any(item).{f} === \'object\'">'
|
|
887
|
+
f'{{{{ fmtCell(item.{f}) }}}}</div>'
|
|
888
|
+
f'</td>'
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
td_cols = '\n '.join(_td(f) for f in out_names)
|
|
892
|
+
|
|
893
|
+
row_click = (
|
|
894
|
+
f' (click)="router.navigate([\'/ho_bo/{schema_name}/{table_name}\', item.{pk_field}])"'
|
|
895
|
+
if pk_field else ''
|
|
896
|
+
)
|
|
897
|
+
cursor = ' cursor-pointer' if pk_field else ''
|
|
898
|
+
|
|
899
|
+
action_td = ''
|
|
900
|
+
if has_del and pk_field:
|
|
901
|
+
action_td = (
|
|
902
|
+
'\n <td class="px-2 py-2">\n'
|
|
903
|
+
' @if (canDelete()) {\n'
|
|
904
|
+
f' <button (click)="handleDelete(item.{pk_field}, $event)"\n'
|
|
905
|
+
' class="text-red-600 hover:underline text-sm">Delete</button>\n'
|
|
906
|
+
' }\n'
|
|
907
|
+
' </td>'
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
new_btn = ''
|
|
911
|
+
if has_post:
|
|
912
|
+
new_btn = (
|
|
913
|
+
f'\n @if (canCreate()) {{\n'
|
|
914
|
+
f' <a [routerLink]="[\'/ho_bo/{schema_name}/{table_name}/new\']"\n'
|
|
915
|
+
f' class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm">\n'
|
|
916
|
+
f' New\n </a>\n }}'
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
can_create = f"\n readonly canCreate = computed(() => !!this.auth.access()['{map_key}']?.POST);" if has_post else ''
|
|
920
|
+
can_delete = f"\n readonly canDelete = computed(() => !!this.auth.access()['{map_key}']?.DELETE);" if has_del else ''
|
|
921
|
+
|
|
922
|
+
delete_fn = ''
|
|
923
|
+
if has_del and pk_field:
|
|
924
|
+
delete_fn = (
|
|
925
|
+
f'\n handleDelete(id: {pk_ts_type}, e: Event): void {{\n'
|
|
926
|
+
f' e.stopPropagation();\n'
|
|
927
|
+
f" if (confirm('Delete this item?')) {{\n"
|
|
928
|
+
f' this.store.remove(id).subscribe(() => this.store.removeItem(String(id)));\n'
|
|
929
|
+
f' }}\n'
|
|
930
|
+
f' }}'
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
ws_effect = (
|
|
934
|
+
f'\n this.auth.wsEvent$.pipe(\n'
|
|
935
|
+
f" filter(ev => ev.resource === '{map_key}'),\n"
|
|
936
|
+
f' takeUntilDestroyed(),\n'
|
|
937
|
+
f' ).subscribe(ev => {{\n'
|
|
938
|
+
f' if (ev.event === \'delete\') untracked(() => this.store.removeItem(String(ev.id)));\n'
|
|
939
|
+
f' else untracked(() => this.store.get(String(ev.id) as any).subscribe());\n'
|
|
940
|
+
f' }});'
|
|
941
|
+
if pk_field else ''
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
needs_router_link = has_post or bool(fk_deps)
|
|
945
|
+
|
|
946
|
+
if pk_field:
|
|
947
|
+
_fk_items_src = (
|
|
948
|
+
'Array.from(this.store.byId().values()).filter(item =>\n'
|
|
949
|
+
' Object.entries(this.filters).every(([k, v]) => String((item as any)[k]) === String(v)))'
|
|
950
|
+
)
|
|
951
|
+
else:
|
|
952
|
+
_fk_items_src = (
|
|
953
|
+
'this.store.items().filter(item =>\n'
|
|
954
|
+
' Object.entries(this.filters).every(([k, v]) => String((item as any)[k]) === String(v)))'
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
displayItems_block = f"""\
|
|
958
|
+
readonly displayItems = computed(() => {{
|
|
959
|
+
const hasFilters = Object.keys(this.filters).length > 0;
|
|
960
|
+
let items: {iname}Out[] = hasFilters
|
|
961
|
+
? {_fk_items_src}
|
|
962
|
+
: this.store.items();
|
|
963
|
+
const lf = this.localFilters();
|
|
964
|
+
if (Object.values(lf).some(v => v))
|
|
965
|
+
items = items.filter(item =>
|
|
966
|
+
Object.entries(lf).every(([k, v]) =>
|
|
967
|
+
!v || String((item as any)[k] ?? '').toLowerCase().includes(v.toLowerCase())));
|
|
968
|
+
const sf = this.sortField();
|
|
969
|
+
if (sf) {{
|
|
970
|
+
const asc = this.sortAsc();
|
|
971
|
+
items = [...items].sort((a, b) => {{
|
|
972
|
+
const av = String((a as any)[sf] ?? '');
|
|
973
|
+
const bv = String((b as any)[sf] ?? '');
|
|
974
|
+
return asc ? av.localeCompare(bv) : bv.localeCompare(av);
|
|
975
|
+
}});
|
|
976
|
+
}}
|
|
977
|
+
return items;
|
|
978
|
+
}});"""
|
|
979
|
+
|
|
980
|
+
router_link_es = "import { RouterLink } from '@angular/router';\n" if needs_router_link else ''
|
|
981
|
+
router_link_imp = 'RouterLink' if needs_router_link else ''
|
|
982
|
+
|
|
983
|
+
return f"""\
|
|
984
|
+
import {{ Component, computed, effect, inject, Input, signal, untracked }} from '@angular/core';
|
|
985
|
+
import {{ takeUntilDestroyed }} from '@angular/core/rxjs-interop';
|
|
986
|
+
import {{ filter }} from 'rxjs';
|
|
987
|
+
{router_link_es}import {{ Router }} from '@angular/router';
|
|
988
|
+
import {{ {iname}Store }} from '../../../generated/stores/{schema_name}_{table_name}.store';
|
|
989
|
+
import type {{ {iname}Out }} from '../../../generated/stores/{schema_name}_{table_name}.store';
|
|
990
|
+
import {{ AuthService }} from '../../../core/auth.service';{fk_imports}
|
|
991
|
+
|
|
992
|
+
@Component({{
|
|
993
|
+
selector: '{_selector(schema_name, table_name, 'list')}',
|
|
994
|
+
standalone: true,
|
|
995
|
+
imports: [{router_link_imp}],
|
|
996
|
+
template: `
|
|
997
|
+
@if (!embedded) {{
|
|
998
|
+
<div class="flex justify-between items-center mb-4">
|
|
999
|
+
<h1 class="text-2xl font-bold">{title}</h1>{new_btn}
|
|
1000
|
+
</div>
|
|
1001
|
+
}}
|
|
1002
|
+
<div [class]="embedded ? '' : 'bg-white shadow-sm rounded-lg overflow-hidden'">
|
|
1003
|
+
<table class="w-full border-collapse">
|
|
1004
|
+
<thead class="bg-gray-100">
|
|
1005
|
+
<tr>
|
|
1006
|
+
{action_th}
|
|
1007
|
+
{th_cols}
|
|
1008
|
+
</tr>{filter_row}
|
|
1009
|
+
</thead>
|
|
1010
|
+
<tbody>
|
|
1011
|
+
@for (item of displayItems(); track $index) {{
|
|
1012
|
+
<tr class="border-t hover:bg-gray-50{cursor}"{row_click}>
|
|
1013
|
+
{action_td}
|
|
1014
|
+
{td_cols}
|
|
1015
|
+
</tr>
|
|
1016
|
+
}}
|
|
1017
|
+
</tbody>
|
|
1018
|
+
</table>
|
|
1019
|
+
</div>
|
|
1020
|
+
@if (jsonDialogContent() !== null) {{
|
|
1021
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
|
1022
|
+
(click)="jsonDialogContent.set(null)">
|
|
1023
|
+
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 p-6"
|
|
1024
|
+
(click)="$event.stopPropagation()">
|
|
1025
|
+
<div class="flex justify-between items-center mb-3">
|
|
1026
|
+
<h3 class="font-semibold text-gray-800">JSON</h3>
|
|
1027
|
+
<button (click)="jsonDialogContent.set(null)"
|
|
1028
|
+
class="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
|
|
1029
|
+
</div>
|
|
1030
|
+
<pre class="text-xs bg-gray-50 rounded p-4 overflow-auto max-h-[60vh] whitespace-pre-wrap">{{{{ jsonDialogContent() }}}}</pre>
|
|
1031
|
+
</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
}}
|
|
1034
|
+
`
|
|
1035
|
+
}})
|
|
1036
|
+
export class {iname}ListComponent {{
|
|
1037
|
+
protected store = inject({iname}Store);
|
|
1038
|
+
protected auth = inject(AuthService);
|
|
1039
|
+
protected router = inject(Router);{fk_injects}
|
|
1040
|
+
|
|
1041
|
+
@Input() filters: Partial<{iname}Out> = {{}};
|
|
1042
|
+
@Input() embedded = false;
|
|
1043
|
+
|
|
1044
|
+
localFilters = signal<Record<string, string>>({{}});
|
|
1045
|
+
sortField = signal<string | null>(null);
|
|
1046
|
+
sortAsc = signal(true);
|
|
1047
|
+
{can_create}{can_delete}
|
|
1048
|
+
{displayItems_block}
|
|
1049
|
+
|
|
1050
|
+
constructor() {{
|
|
1051
|
+
effect(() => {{
|
|
1052
|
+
const _token = this.auth.token();
|
|
1053
|
+
if (Object.keys(this.filters).length > 0 &&
|
|
1054
|
+
this.auth.fetchedRoutes.has(this.store.listUrl({{}}))) {{
|
|
1055
|
+
return;
|
|
1056
|
+
}}
|
|
1057
|
+
this.store.list(this.filters);
|
|
1058
|
+
}});{ws_effect}
|
|
1059
|
+
}}
|
|
1060
|
+
|
|
1061
|
+
sortBy(f: string): void {{
|
|
1062
|
+
if (this.sortField() === f) this.sortAsc.set(!this.sortAsc());
|
|
1063
|
+
else {{ this.sortField.set(f); this.sortAsc.set(true); }}
|
|
1064
|
+
}}
|
|
1065
|
+
setFilter(f: string, v: string): void {{
|
|
1066
|
+
this.localFilters.set({{ ...this.localFilters(), [f]: v }});
|
|
1067
|
+
}}
|
|
1068
|
+
jsonDialogContent = signal<string | null>(null);
|
|
1069
|
+
showJson(v: unknown): void {{ this.jsonDialogContent.set(JSON.stringify(v, null, 2)); }}
|
|
1070
|
+
cellClick(e: Event, v: unknown): void {{
|
|
1071
|
+
if (v != null && typeof v === 'object') {{ e.stopPropagation(); this.showJson(v); }}
|
|
1072
|
+
}}
|
|
1073
|
+
fmtCell(v: unknown): string {{
|
|
1074
|
+
if (v == null) return '';
|
|
1075
|
+
if (Array.isArray(v)) return `JSON [${{v.length}}]`;
|
|
1076
|
+
if (typeof v === 'object') return 'JSON {{…}}';
|
|
1077
|
+
const s = String(v);
|
|
1078
|
+
return /^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/i.test(s)
|
|
1079
|
+
? s.slice(0, 8) + '…' : s;
|
|
1080
|
+
}}
|
|
1081
|
+
cellTitle(v: unknown): string {{
|
|
1082
|
+
if (v == null || typeof v === 'object') return '';
|
|
1083
|
+
return String(v);
|
|
1084
|
+
}}{delete_fn}
|
|
1085
|
+
}}
|
|
1086
|
+
"""
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _is_bool_field(f: str, all_fields: dict) -> bool:
|
|
1090
|
+
return f in all_fields and _py_type_str(all_fields[f].py_type) == 'bool'
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _is_text_field(f: str, all_fields: dict) -> bool:
|
|
1094
|
+
return f in all_fields and _py_type_str(all_fields[f].py_type) == 'str'
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def _is_required(f: str, all_fields: dict) -> bool:
|
|
1098
|
+
fo = all_fields.get(f)
|
|
1099
|
+
return bool(fo and fo.is_not_null() and fo.has_default_value is None)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _is_server_generated(f: str, all_fields: dict) -> bool:
|
|
1103
|
+
fo = all_fields.get(f)
|
|
1104
|
+
if not fo or fo.has_default_value is None:
|
|
1105
|
+
return False
|
|
1106
|
+
dv = fo.has_default_value.lower().strip()
|
|
1107
|
+
return dv.startswith('current') or dv in ('now()', 'clock_timestamp()')
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _input_type(f: str, all_fields: dict) -> str:
|
|
1111
|
+
if f not in all_fields:
|
|
1112
|
+
return 'text'
|
|
1113
|
+
fo = all_fields[f]
|
|
1114
|
+
t = _py_type_str(fo.py_type)
|
|
1115
|
+
if t == 'datetime.datetime':
|
|
1116
|
+
return 'datetime-local'
|
|
1117
|
+
if t == 'datetime.date':
|
|
1118
|
+
return 'date'
|
|
1119
|
+
try:
|
|
1120
|
+
sql = fo._Field__sql_type.lower()
|
|
1121
|
+
if 'timestamp' in sql:
|
|
1122
|
+
return 'datetime-local'
|
|
1123
|
+
if sql == 'date':
|
|
1124
|
+
return 'date'
|
|
1125
|
+
except AttributeError:
|
|
1126
|
+
pass
|
|
1127
|
+
return 'text'
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _text_fields_ts(field_names: list, all_fields: dict) -> str:
|
|
1131
|
+
text = [f for f in field_names if _is_text_field(f, all_fields)]
|
|
1132
|
+
return ', '.join(repr(f) for f in text)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _ng_form_field(f: str, all_fields: dict) -> str:
|
|
1136
|
+
req = _is_required(f, all_fields)
|
|
1137
|
+
req_attr = ' required' if req else ''
|
|
1138
|
+
req_mark = ' <span class="text-red-500">*</span>' if req else ''
|
|
1139
|
+
itype = _input_type(f, all_fields)
|
|
1140
|
+
if _is_bool_field(f, all_fields):
|
|
1141
|
+
return (
|
|
1142
|
+
f'<div class="flex items-center gap-2">\n'
|
|
1143
|
+
f' <input type="checkbox" [(ngModel)]="form.{f}" name="{f}"\n'
|
|
1144
|
+
f' class="h-4 w-4 rounded border-gray-300" />\n'
|
|
1145
|
+
f' <label class="text-sm font-medium text-gray-700">{f}</label>\n'
|
|
1146
|
+
f' </div>'
|
|
1147
|
+
)
|
|
1148
|
+
return (
|
|
1149
|
+
f'<div>\n'
|
|
1150
|
+
f' <label class="block text-sm font-medium text-gray-700 mb-1">{f}{req_mark}</label>\n'
|
|
1151
|
+
f' <input type="{itype}" [(ngModel)]="form.{f}" name="{f}"{req_attr}\n'
|
|
1152
|
+
f' class="w-full border rounded px-3 py-2 text-sm" />\n'
|
|
1153
|
+
f' </div>'
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _create_component(
|
|
1158
|
+
schema_name: str, table_name: str,
|
|
1159
|
+
iname: str,
|
|
1160
|
+
post_in_names: list, all_fields: dict,
|
|
1161
|
+
optional_post_fields: frozenset = frozenset(),
|
|
1162
|
+
) -> str:
|
|
1163
|
+
title = _title(schema_name, table_name)
|
|
1164
|
+
visible_post = [f for f in post_in_names if not _is_server_generated(f, all_fields)]
|
|
1165
|
+
fields_ts = ', '.join(
|
|
1166
|
+
f'{f}: false as any' if _is_bool_field(f, all_fields) else f'{f}: \'\' as any'
|
|
1167
|
+
for f in visible_post
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
form_fields = '\n '.join(
|
|
1171
|
+
_ng_form_field(f, all_fields)
|
|
1172
|
+
for f in visible_post
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
optional_set_ts = (
|
|
1176
|
+
f" private readonly optionalFields = new Set([{', '.join(repr(f) for f in sorted(optional_post_fields))}]);\n"
|
|
1177
|
+
if optional_post_fields else ''
|
|
1178
|
+
)
|
|
1179
|
+
text_fields_ts = _text_fields_ts(visible_post, all_fields)
|
|
1180
|
+
null_map = " .map(([k, v]): [string, unknown] => [k, !textFields.has(k) && v === '' ? null : v])\n"
|
|
1181
|
+
|
|
1182
|
+
submit_body = (
|
|
1183
|
+
f" const textFields = new Set([{text_fields_ts}]);\n"
|
|
1184
|
+
" const payload = Object.fromEntries(\n"
|
|
1185
|
+
" Object.entries(this.form as unknown as Record<string, unknown>)\n"
|
|
1186
|
+
+ (
|
|
1187
|
+
" .filter(([k, v]) => !this.optionalFields.has(k) || v !== '')\n"
|
|
1188
|
+
if optional_post_fields else ""
|
|
1189
|
+
)
|
|
1190
|
+
+ null_map
|
|
1191
|
+
+ f" ) as unknown as {iname}PostIn;\n"
|
|
1192
|
+
" this.store.create(payload).subscribe({"
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
return f"""\
|
|
1196
|
+
import {{ Component, inject, signal }} from '@angular/core';
|
|
1197
|
+
import {{ FormsModule }} from '@angular/forms';
|
|
1198
|
+
import {{ RouterLink, Router }} from '@angular/router';
|
|
1199
|
+
import {{ {iname}Store }} from '../../../generated/stores/{schema_name}_{table_name}.store';
|
|
1200
|
+
import type {{ {iname}PostIn }} from '../../../generated/stores/{schema_name}_{table_name}.store';
|
|
1201
|
+
|
|
1202
|
+
@Component({{
|
|
1203
|
+
selector: '{_selector(schema_name, table_name, 'create')}',
|
|
1204
|
+
standalone: true,
|
|
1205
|
+
imports: [FormsModule, RouterLink],
|
|
1206
|
+
template: `
|
|
1207
|
+
<div class="max-w-lg mx-auto p-6 bg-white rounded-lg shadow mt-6">
|
|
1208
|
+
<h1 class="text-2xl font-bold mb-6">New {title}</h1>
|
|
1209
|
+
@if (error()) {{ <p class="text-red-600 mb-4">{{{{ error() }}}}</p> }}
|
|
1210
|
+
<form #ngForm="ngForm" (ngSubmit)="handleSubmit()" class="space-y-4">
|
|
1211
|
+
{form_fields}
|
|
1212
|
+
<div class="flex gap-3 pt-2">
|
|
1213
|
+
<button type="submit" [disabled]="ngForm.invalid"
|
|
1214
|
+
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
|
1215
|
+
Create
|
|
1216
|
+
</button>
|
|
1217
|
+
<a routerLink="/ho_bo/{schema_name}/{table_name}"
|
|
1218
|
+
class="px-4 py-2 border rounded hover:bg-gray-50 text-sm">Cancel</a>
|
|
1219
|
+
</div>
|
|
1220
|
+
</form>
|
|
1221
|
+
</div>
|
|
1222
|
+
`
|
|
1223
|
+
}})
|
|
1224
|
+
export class {iname}CreateComponent {{
|
|
1225
|
+
private store = inject({iname}Store);
|
|
1226
|
+
private router = inject(Router);
|
|
1227
|
+
{optional_set_ts}
|
|
1228
|
+
form: Partial<{iname}PostIn> = {{ {fields_ts} }};
|
|
1229
|
+
readonly error = signal('');
|
|
1230
|
+
|
|
1231
|
+
handleSubmit(): void {{
|
|
1232
|
+
{submit_body}
|
|
1233
|
+
next: (item) => {{
|
|
1234
|
+
this.store.setItem(item);
|
|
1235
|
+
void this.router.navigate(['/ho_bo/{schema_name}/{table_name}']);
|
|
1236
|
+
}},
|
|
1237
|
+
error: (err: Error) => this.error.set(err.message),
|
|
1238
|
+
}});
|
|
1239
|
+
}}
|
|
1240
|
+
}}
|
|
1241
|
+
"""
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _detail_component(
|
|
1245
|
+
schema_name: str, table_name: str,
|
|
1246
|
+
iname: str, pk_field: str, pk_ts_type: str,
|
|
1247
|
+
out_names: list, put_in_names: list,
|
|
1248
|
+
has_put: bool, map_key: str,
|
|
1249
|
+
fk_deps: list, rev_fk_deps: list,
|
|
1250
|
+
all_fields: dict,
|
|
1251
|
+
) -> str:
|
|
1252
|
+
title = _title(schema_name, table_name)
|
|
1253
|
+
fk_map = {lf: (rs, rt) for lf, rs, rt, _ in fk_deps}
|
|
1254
|
+
|
|
1255
|
+
# FK store imports + injects — deduplicated: skip self-ref and multi-FK to same table
|
|
1256
|
+
_seen: set[str] = {f'{schema_name}_{table_name}'}
|
|
1257
|
+
_unique_fk_deps = []
|
|
1258
|
+
for dep in fk_deps:
|
|
1259
|
+
_, rs, rt, _ = dep
|
|
1260
|
+
stem = f'{rs}_{rt}'
|
|
1261
|
+
if stem not in _seen:
|
|
1262
|
+
_seen.add(stem)
|
|
1263
|
+
_unique_fk_deps.append(dep)
|
|
1264
|
+
|
|
1265
|
+
fk_store_imports = '\n'.join(
|
|
1266
|
+
f"import {{ {_cname(rs, rt)}Store }} from '../../../generated/stores/{rs}_{rt}.store';"
|
|
1267
|
+
for _, rs, rt, _ in _unique_fk_deps
|
|
1268
|
+
)
|
|
1269
|
+
if fk_store_imports:
|
|
1270
|
+
fk_store_imports = '\n' + fk_store_imports
|
|
1271
|
+
|
|
1272
|
+
fk_injects = '\n'.join(
|
|
1273
|
+
f' protected {_cname(rs, rt)[0].lower()}{_cname(rs, rt)[1:]}Store = inject({_cname(rs, rt)}Store);'
|
|
1274
|
+
for _, rs, rt, _ in _unique_fk_deps
|
|
1275
|
+
)
|
|
1276
|
+
if fk_injects:
|
|
1277
|
+
fk_injects = '\n' + fk_injects
|
|
1278
|
+
|
|
1279
|
+
# Reverse FK list imports
|
|
1280
|
+
rev_list_imports = '\n'.join(
|
|
1281
|
+
f"import {{ {_cname(rs, rt)}ListComponent }} from '../{rs}_{rt}/list.component';"
|
|
1282
|
+
for rs, rt, _ in rev_fk_deps
|
|
1283
|
+
)
|
|
1284
|
+
if rev_list_imports:
|
|
1285
|
+
rev_list_imports = '\n' + rev_list_imports
|
|
1286
|
+
|
|
1287
|
+
rev_list_in_imports = ', '.join(f'{_cname(rs, rt)}ListComponent' for rs, rt, _ in rev_fk_deps)
|
|
1288
|
+
all_imports = ', '.join(filter(None, ['RouterLink', 'FormsModule' if has_put and put_in_names else '', rev_list_in_imports]))
|
|
1289
|
+
|
|
1290
|
+
# Read-only field rows
|
|
1291
|
+
def _ro_row(f: str) -> str:
|
|
1292
|
+
label = f'<span class="font-medium text-gray-600 w-36 shrink-0">{f}</span>'
|
|
1293
|
+
if f in fk_map:
|
|
1294
|
+
rs, rt = fk_map[f]
|
|
1295
|
+
return (
|
|
1296
|
+
f'<div class="flex gap-2 items-baseline">{label}'
|
|
1297
|
+
f'<a [routerLink]="[\'/ho_bo/{rs}/{rt}\', item()!.{f}]"'
|
|
1298
|
+
f' class="text-blue-500 hover:underline font-mono text-xs">{{{{ item()!.{f} }}}}</a>'
|
|
1299
|
+
f'</div>'
|
|
1300
|
+
)
|
|
1301
|
+
return (
|
|
1302
|
+
f'<div class="flex gap-2 items-baseline">{label}'
|
|
1303
|
+
f'<span class="text-sm break-all">{{{{ item()!.{f} }}}}</span></div>'
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
ro_rows = '\n '.join(_ro_row(f) for f in out_names if f != pk_field)
|
|
1307
|
+
|
|
1308
|
+
# Edit form
|
|
1309
|
+
form_fields_tmpl = ''
|
|
1310
|
+
edit_section_tmpl = ''
|
|
1311
|
+
form_init = ''
|
|
1312
|
+
form_class = ''
|
|
1313
|
+
edit_btn_tmpl = ''
|
|
1314
|
+
can_edit_field = ''
|
|
1315
|
+
form_effect = ''
|
|
1316
|
+
|
|
1317
|
+
visible_put = [f for f in put_in_names if not _is_server_generated(f, all_fields)]
|
|
1318
|
+
|
|
1319
|
+
if has_put and visible_put:
|
|
1320
|
+
form_fields_tmpl = '\n '.join(
|
|
1321
|
+
_ng_form_field(f, all_fields).replace('\n ', '\n ')
|
|
1322
|
+
for f in visible_put
|
|
1323
|
+
)
|
|
1324
|
+
form_init = ', '.join(
|
|
1325
|
+
f'{f}: false as any' if _is_bool_field(f, all_fields) else f'{f}: \'\' as any'
|
|
1326
|
+
for f in visible_put
|
|
1327
|
+
)
|
|
1328
|
+
form_class = f' form: any = {{ {form_init} }};'
|
|
1329
|
+
can_edit_field = f"\n readonly canEdit = computed(() => !!this.auth.access()['{map_key}']?.PUT);"
|
|
1330
|
+
edit_btn_tmpl = (
|
|
1331
|
+
'\n @if (canEdit()) {\n'
|
|
1332
|
+
' <button (click)="editing.set(!editing()); error.set(\'\')"'
|
|
1333
|
+
'\n class="text-sm px-3 py-1 border rounded hover:bg-gray-50">\n'
|
|
1334
|
+
' {{ editing() ? \'Cancel\' : \'Edit\' }}\n'
|
|
1335
|
+
' </button>\n'
|
|
1336
|
+
' }'
|
|
1337
|
+
)
|
|
1338
|
+
def _effect_assign(f: str) -> str:
|
|
1339
|
+
if _is_bool_field(f, all_fields):
|
|
1340
|
+
return f'this.form.{f} = Boolean((i as any).{f});'
|
|
1341
|
+
if _input_type(f, all_fields) == 'datetime-local':
|
|
1342
|
+
return f'this.form.{f} = (i as any).{f} ? String((i as any).{f}).slice(0, 16) : \'\';'
|
|
1343
|
+
return f'this.form.{f} = (i as any).{f} ?? \'\';'
|
|
1344
|
+
effect_body = ' '.join(_effect_assign(f) for f in visible_put)
|
|
1345
|
+
form_effect = (
|
|
1346
|
+
f'\n effect(() => {{ const i = this.item(); if (i) {{ {effect_body} }} }}, {{ allowSignalWrites: true }});'
|
|
1347
|
+
)
|
|
1348
|
+
edit_section_tmpl = f"""
|
|
1349
|
+
@if (editing()) {{
|
|
1350
|
+
<div class="mt-6 pt-6 border-t">
|
|
1351
|
+
@if (error()) {{ <p class="text-red-600 mb-4">{{{{ error() }}}}</p> }}
|
|
1352
|
+
<form #editForm="ngForm" (ngSubmit)="handleUpdate()" class="space-y-4">
|
|
1353
|
+
{form_fields_tmpl}
|
|
1354
|
+
<div class="flex gap-3 pt-2">
|
|
1355
|
+
<button type="submit" [disabled]="editForm.invalid"
|
|
1356
|
+
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed">
|
|
1357
|
+
Update
|
|
1358
|
+
</button>
|
|
1359
|
+
<button type="button" (click)="editing.set(false)"
|
|
1360
|
+
class="px-4 py-2 border rounded hover:bg-gray-50 text-sm">Cancel</button>
|
|
1361
|
+
</div>
|
|
1362
|
+
</form>
|
|
1363
|
+
</div>
|
|
1364
|
+
}}"""
|
|
1365
|
+
|
|
1366
|
+
# FK reference sections — all deps; self-refs reuse this.store (already injected)
|
|
1367
|
+
fk_sections = ''
|
|
1368
|
+
for lf, rs, rt, remote_pk in fk_deps:
|
|
1369
|
+
is_self = (rs == schema_name and rt == table_name)
|
|
1370
|
+
rn_store = 'store' if is_self else f'{_cname(rs, rt)[0].lower()}{_cname(rs, rt)[1:]}Store'
|
|
1371
|
+
rt_title = _title(rs, rt)
|
|
1372
|
+
fk_sections += f"""
|
|
1373
|
+
@if (item()?.{lf}) {{
|
|
1374
|
+
<div class="mt-4 p-6 bg-white rounded-lg shadow">
|
|
1375
|
+
<div class="flex justify-between items-center mb-3">
|
|
1376
|
+
<h2 class="text-lg font-semibold">{rt_title}</h2>
|
|
1377
|
+
<a [routerLink]="['/ho_bo/{rs}/{rt}', item()!.{lf}]" class="text-sm text-blue-600 hover:underline">→</a>
|
|
1378
|
+
</div>
|
|
1379
|
+
@if ({rn_store}.byId().get(str(item()!.{lf})); as ref) {{
|
|
1380
|
+
<div class="space-y-1">
|
|
1381
|
+
@for (entry of objectEntries(ref); track entry[0]) {{
|
|
1382
|
+
<div class="flex gap-2 items-baseline">
|
|
1383
|
+
<span class="font-medium text-gray-600 w-36 shrink-0 text-sm">{{{{ entry[0] }}}}</span>
|
|
1384
|
+
<span class="text-sm break-all">{{{{ entry[1] ?? '' }}}}</span>
|
|
1385
|
+
</div>
|
|
1386
|
+
}}
|
|
1387
|
+
</div>
|
|
1388
|
+
}}
|
|
1389
|
+
</div>
|
|
1390
|
+
}}"""
|
|
1391
|
+
|
|
1392
|
+
# Reverse FK sections
|
|
1393
|
+
rev_sections = ''
|
|
1394
|
+
for rs, rt, fk_field in rev_fk_deps:
|
|
1395
|
+
cn = _cname(rs, rt)
|
|
1396
|
+
rt_title = _title(rs, rt)
|
|
1397
|
+
rev_sections += f"""
|
|
1398
|
+
<div class="mt-4 bg-white rounded-lg shadow overflow-hidden">
|
|
1399
|
+
<div class="px-6 pt-5 pb-3">
|
|
1400
|
+
<h2 class="text-lg font-semibold">{rt_title}</h2>
|
|
1401
|
+
</div>
|
|
1402
|
+
@if (item()) {{
|
|
1403
|
+
<{_selector(rs, rt, 'list')} [filters]="{{ {fk_field}: item()!.{pk_field} }}" [embedded]="true" />
|
|
1404
|
+
}}
|
|
1405
|
+
</div>"""
|
|
1406
|
+
|
|
1407
|
+
right_col = ''
|
|
1408
|
+
if fk_deps:
|
|
1409
|
+
right_col += '\n <p class="mt-4 px-1 text-xs font-semibold uppercase tracking-wide text-gray-400">↗ Direct references</p>'
|
|
1410
|
+
right_col += fk_sections
|
|
1411
|
+
if rev_fk_deps:
|
|
1412
|
+
if fk_deps:
|
|
1413
|
+
right_col += '\n <hr class="my-6 border-gray-200">'
|
|
1414
|
+
right_col += '\n <p class="mt-4 px-1 text-xs font-semibold uppercase tracking-wide text-gray-400">↙ Related</p>'
|
|
1415
|
+
right_col += rev_sections
|
|
1416
|
+
|
|
1417
|
+
handle_update = ''
|
|
1418
|
+
if has_put and put_in_names:
|
|
1419
|
+
put_text_fields_ts = _text_fields_ts(put_in_names, all_fields)
|
|
1420
|
+
handle_update = (
|
|
1421
|
+
f'\n handleUpdate(): void {{\n'
|
|
1422
|
+
f" const textFields = new Set([{put_text_fields_ts}]);\n"
|
|
1423
|
+
f' const putPayload = Object.fromEntries(\n'
|
|
1424
|
+
f' Object.entries(this.form as unknown as Record<string, unknown>)\n'
|
|
1425
|
+
f' .map(([k, v]): [string, unknown] => [k, !textFields.has(k) && v === \'\' ? null : v])\n'
|
|
1426
|
+
f' ) as unknown as {iname}PutIn;\n'
|
|
1427
|
+
f' this.store.update(this.id as any, putPayload).subscribe({{\n'
|
|
1428
|
+
f' next: (updated) => {{\n'
|
|
1429
|
+
f' this.store.setItem(updated); this.item.set(updated); this.editing.set(false);\n'
|
|
1430
|
+
f' }},\n'
|
|
1431
|
+
f' error: (err: Error) => this.error.set(err.message),\n'
|
|
1432
|
+
f' }});\n'
|
|
1433
|
+
f' }}'
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
ws_effect = (
|
|
1437
|
+
f'\n this.auth.wsEvent$.pipe(\n'
|
|
1438
|
+
f" filter(ev => ev.resource === '{map_key}' && String(ev.id) === this.id),\n"
|
|
1439
|
+
f' takeUntilDestroyed(),\n'
|
|
1440
|
+
f' ).subscribe(ev => {{\n'
|
|
1441
|
+
f' if (ev.event === \'delete\') void this.router.navigate([\'/{schema_name}/{table_name}\']);\n'
|
|
1442
|
+
f' else untracked(() => this.store.get(this.id as any).subscribe(d => {{ if (d) this.item.set(d); }}));\n'
|
|
1443
|
+
f' }});'
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
fk_fetch_effects = ''
|
|
1447
|
+
for lf, rs, rt, remote_pk in fk_deps:
|
|
1448
|
+
is_self = (rs == schema_name and rt == table_name)
|
|
1449
|
+
rn_store = 'store' if is_self else f'{_cname(rs, rt)[0].lower()}{_cname(rs, rt)[1:]}Store'
|
|
1450
|
+
fk_fetch_effects += (
|
|
1451
|
+
f'\n effect(() => {{\n'
|
|
1452
|
+
f' const v = this.item()?.{lf};\n'
|
|
1453
|
+
f' if (!v) return;\n'
|
|
1454
|
+
f' const url = this.{rn_store}.getUrl(v);\n'
|
|
1455
|
+
f' if (!this.auth.fetchedRoutes.has(url)) this.{rn_store}.get(v as any).subscribe();\n'
|
|
1456
|
+
f' }});'
|
|
1457
|
+
)
|
|
1458
|
+
|
|
1459
|
+
return f"""\
|
|
1460
|
+
import {{ Component, computed, effect, inject, signal, untracked }} from '@angular/core';
|
|
1461
|
+
import {{ takeUntilDestroyed }} from '@angular/core/rxjs-interop';
|
|
1462
|
+
import {{ filter }} from 'rxjs';
|
|
1463
|
+
import {{ FormsModule }} from '@angular/forms';
|
|
1464
|
+
import {{ RouterLink, Router, ActivatedRoute }} from '@angular/router';
|
|
1465
|
+
import {{ {iname}Store }} from '../../../generated/stores/{schema_name}_{table_name}.store';
|
|
1466
|
+
import type {{ {iname}Out{', ' + iname + 'PutIn' if has_put and put_in_names else ''} }} from '../../../generated/stores/{schema_name}_{table_name}.store';
|
|
1467
|
+
import {{ AuthService }} from '../../../core/auth.service';{fk_store_imports}{rev_list_imports}
|
|
1468
|
+
|
|
1469
|
+
@Component({{
|
|
1470
|
+
selector: '{_selector(schema_name, table_name, 'detail')}',
|
|
1471
|
+
standalone: true,
|
|
1472
|
+
imports: [{all_imports}],
|
|
1473
|
+
template: `
|
|
1474
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6 px-4 lg:h-[calc(100vh-4rem)] lg:overflow-hidden">
|
|
1475
|
+
<div class="min-w-0 lg:overflow-y-auto lg:pr-1">
|
|
1476
|
+
@if (item()) {{
|
|
1477
|
+
<div class="p-6 bg-white rounded-lg shadow">
|
|
1478
|
+
<div class="flex justify-between items-start mb-6">
|
|
1479
|
+
<h1 class="text-2xl font-bold">{title}</h1>
|
|
1480
|
+
<div class="flex gap-3 items-center">{edit_btn_tmpl}
|
|
1481
|
+
<a routerLink="/ho_bo/{schema_name}/{table_name}" class="text-sm text-gray-500 hover:underline">← Back</a>
|
|
1482
|
+
</div>
|
|
1483
|
+
</div>
|
|
1484
|
+
<div class="space-y-2 mb-4">
|
|
1485
|
+
<div class="flex gap-2 items-baseline">
|
|
1486
|
+
<span class="font-medium text-gray-600 w-36 shrink-0">{pk_field}</span>
|
|
1487
|
+
<span class="font-mono text-xs text-gray-500 break-all">{{{{ item()!.{pk_field} }}}}</span>
|
|
1488
|
+
</div>
|
|
1489
|
+
{ro_rows}
|
|
1490
|
+
</div>{edit_section_tmpl}
|
|
1491
|
+
</div>
|
|
1492
|
+
}}
|
|
1493
|
+
</div>
|
|
1494
|
+
<div class="min-w-0 lg:overflow-y-auto lg:pr-1">{right_col}
|
|
1495
|
+
</div>
|
|
1496
|
+
</div>
|
|
1497
|
+
`
|
|
1498
|
+
}})
|
|
1499
|
+
export class {iname}DetailComponent {{
|
|
1500
|
+
protected store = inject({iname}Store);
|
|
1501
|
+
protected auth = inject(AuthService);
|
|
1502
|
+
protected router = inject(Router);
|
|
1503
|
+
private route = inject(ActivatedRoute);{fk_injects}
|
|
1504
|
+
|
|
1505
|
+
readonly id = this.route.snapshot.params['id'] as string;
|
|
1506
|
+
readonly item = signal<{iname}Out | null>(this.store.byId().get(this.id) ?? null);
|
|
1507
|
+
private lastToken = this.auth.token();
|
|
1508
|
+
{can_edit_field}
|
|
1509
|
+
readonly editing = signal(false);
|
|
1510
|
+
readonly error = signal('');
|
|
1511
|
+
{form_class}
|
|
1512
|
+
|
|
1513
|
+
constructor() {{
|
|
1514
|
+
effect(() => {{
|
|
1515
|
+
const token = this.auth.token();
|
|
1516
|
+
if (token !== this.lastToken) {{ this.lastToken = token; this.item.set(null); }}
|
|
1517
|
+
if (!this.item()) this.store.get(this.id as any).subscribe(d => {{ if (d) this.item.set(d); }});
|
|
1518
|
+
}}, {{ allowSignalWrites: true }});{form_effect}{ws_effect}{fk_fetch_effects}
|
|
1519
|
+
}}
|
|
1520
|
+
|
|
1521
|
+
str(v: unknown): string {{ return String(v); }}
|
|
1522
|
+
objectEntries(obj: any): [string, any][] {{ return Object.entries(obj ?? {{}}); }}{handle_update}
|
|
1523
|
+
}}
|
|
1524
|
+
"""
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
# ---------------------------------------------------------------------------
|
|
1528
|
+
# Generator class
|
|
1529
|
+
# ---------------------------------------------------------------------------
|
|
1530
|
+
|
|
1531
|
+
class AngularAppGenerator(StoreGenerator):
|
|
1532
|
+
|
|
1533
|
+
def generate(self, classes, api_version, output_dir: Path) -> None:
|
|
1534
|
+
if output_dir.exists():
|
|
1535
|
+
shutil.rmtree(output_dir)
|
|
1536
|
+
output_dir.mkdir(parents=True)
|
|
1537
|
+
|
|
1538
|
+
version_prefix = f'/v{api_version}' if api_version is not None else ''
|
|
1539
|
+
project_name = output_dir.name
|
|
1540
|
+
project_title = ' '.join(p.capitalize() for p in project_name.split('-'))
|
|
1541
|
+
|
|
1542
|
+
# --- static files ---
|
|
1543
|
+
self._write(output_dir / 'package.json',
|
|
1544
|
+
_PACKAGE_JSON.format(project_name=project_name))
|
|
1545
|
+
self._write(output_dir / 'angular.json',
|
|
1546
|
+
_ANGULAR_JSON.format(project_name=project_name))
|
|
1547
|
+
self._write(output_dir / 'tsconfig.json', _TSCONFIG)
|
|
1548
|
+
self._write(output_dir / 'tsconfig.app.json', _TSCONFIG_APP)
|
|
1549
|
+
self._write(output_dir / 'tailwind.config.js', _TAILWIND_CONFIG)
|
|
1550
|
+
self._write(output_dir / 'postcss.config.js', _POSTCSS_CONFIG)
|
|
1551
|
+
self._write(output_dir / 'proxy.conf.json',
|
|
1552
|
+
_proxy_conf(version_prefix))
|
|
1553
|
+
self._write(output_dir / 'src' / 'index.html',
|
|
1554
|
+
_INDEX_HTML.format(project_title=project_title))
|
|
1555
|
+
self._write(output_dir / 'src' / 'styles.css', _STYLES_CSS)
|
|
1556
|
+
self._write(output_dir / 'src' / 'main.ts', _MAIN_TS)
|
|
1557
|
+
|
|
1558
|
+
app_dir = output_dir / 'src' / 'app'
|
|
1559
|
+
self._write(app_dir / 'app.config.ts', _APP_CONFIG_TS)
|
|
1560
|
+
self._write(app_dir / 'core' / 'state-registry.ts', _STATE_REGISTRY)
|
|
1561
|
+
self._write(app_dir / 'core' / 'auth.service.ts',
|
|
1562
|
+
_auth_service(version_prefix))
|
|
1563
|
+
|
|
1564
|
+
# Pass 1 — identify CRUD resources
|
|
1565
|
+
crud_resources: set[tuple[str, str]] = set()
|
|
1566
|
+
crud_resources_map: dict[tuple[str, str], dict] = {}
|
|
1567
|
+
raw = []
|
|
1568
|
+
for relation, _relation_type in classes:
|
|
1569
|
+
module_str = relation.__module__
|
|
1570
|
+
try:
|
|
1571
|
+
mod = importlib.import_module(module_str)
|
|
1572
|
+
except ImportError:
|
|
1573
|
+
continue
|
|
1574
|
+
schema_name = relation._t_fqrn[1]
|
|
1575
|
+
table_name = relation._t_fqrn[2]
|
|
1576
|
+
crud_resources.add((schema_name, table_name))
|
|
1577
|
+
crud_resources_map[(schema_name, table_name)] = getattr(mod, 'CRUD_ACCESS', {})
|
|
1578
|
+
raw.append((relation, mod))
|
|
1579
|
+
|
|
1580
|
+
# Pre-pass: compute detail_resources before Pass 2 (needed for FK link filtering)
|
|
1581
|
+
detail_resources: set[tuple[str, str]] = set()
|
|
1582
|
+
for relation, mod in raw:
|
|
1583
|
+
ca = getattr(mod, 'CRUD_ACCESS', None) or {'GET': {}, 'POST': {}, 'PUT': {}, 'DELETE': {}}
|
|
1584
|
+
if _simple_pk(relation) and 'GET' in ca:
|
|
1585
|
+
detail_resources.add((
|
|
1586
|
+
relation._t_fqrn[1],
|
|
1587
|
+
relation._t_fqrn[2],
|
|
1588
|
+
))
|
|
1589
|
+
|
|
1590
|
+
# Pass 2 — per-resource metadata
|
|
1591
|
+
resources = []
|
|
1592
|
+
for relation, mod in raw:
|
|
1593
|
+
crud_access = getattr(mod, 'CRUD_ACCESS', None) or {'GET': {}, 'POST': {}, 'PUT': {}, 'DELETE': {}}
|
|
1594
|
+
api_excluded = getattr(mod, 'API_EXCLUDED_FIELDS', [])
|
|
1595
|
+
schema_name = relation._t_fqrn[1]
|
|
1596
|
+
table_name = relation._t_fqrn[2]
|
|
1597
|
+
inst = _instance(relation)
|
|
1598
|
+
all_fields = getattr(inst, '_ho_fields', {})
|
|
1599
|
+
all_names = list(all_fields.keys())
|
|
1600
|
+
pk_info = _simple_pk(relation)
|
|
1601
|
+
pk_field = pk_info[0] if pk_info else None
|
|
1602
|
+
pk_ts_type = (
|
|
1603
|
+
StoreGenerator.PY_TO_TS.get(
|
|
1604
|
+
_py_type_str(list(inst._ho_pkey.values())[0].py_type), 'string'
|
|
1605
|
+
) if pk_info else 'string'
|
|
1606
|
+
)
|
|
1607
|
+
iname = self.interface_name(schema_name, table_name)
|
|
1608
|
+
map_key = f'{schema_name}/{table_name}'
|
|
1609
|
+
|
|
1610
|
+
out_names = _gen_out_fields(crud_access, 'GET', api_excluded, all_names)
|
|
1611
|
+
if not out_names:
|
|
1612
|
+
out_names = [f for f in all_names if f not in api_excluded]
|
|
1613
|
+
|
|
1614
|
+
has_post = 'POST' in crud_access and bool(pk_info)
|
|
1615
|
+
has_put = 'PUT' in crud_access and bool(pk_info)
|
|
1616
|
+
has_del = 'DELETE' in crud_access and bool(pk_info)
|
|
1617
|
+
has_detail = 'GET' in crud_access and bool(pk_info)
|
|
1618
|
+
|
|
1619
|
+
pk_has_default = bool(
|
|
1620
|
+
pk_field and all_fields.get(pk_field) and
|
|
1621
|
+
all_fields[pk_field].has_default_value is not None
|
|
1622
|
+
)
|
|
1623
|
+
fields_with_defaults = {
|
|
1624
|
+
f for f in all_names
|
|
1625
|
+
if all_fields.get(f) and all_fields[f].has_default_value is not None
|
|
1626
|
+
}
|
|
1627
|
+
_non_pk = [f for f in all_names
|
|
1628
|
+
if (f != pk_field or not pk_has_default) and f not in api_excluded]
|
|
1629
|
+
post_in_names = _gen_in_fields(crud_access, 'POST', pk_field, api_excluded, all_names,
|
|
1630
|
+
pk_has_default) if has_post else []
|
|
1631
|
+
if has_post and not post_in_names:
|
|
1632
|
+
post_in_names = _non_pk
|
|
1633
|
+
put_in_names = _gen_in_fields(crud_access, 'PUT', pk_field, api_excluded, all_names) if has_put else []
|
|
1634
|
+
if has_put and not put_in_names:
|
|
1635
|
+
put_in_names = _non_pk
|
|
1636
|
+
optional_post_fields = frozenset(f for f in post_in_names if f in fields_with_defaults)
|
|
1637
|
+
|
|
1638
|
+
fk_deps = self._fk_deps(inst, out_names, detail_resources)
|
|
1639
|
+
rev_fk_deps = self._reverse_fk_deps(inst, pk_field, crud_resources)
|
|
1640
|
+
|
|
1641
|
+
base_path = f'{version_prefix}/{schema_name}/{table_name}'
|
|
1642
|
+
|
|
1643
|
+
resources.append((
|
|
1644
|
+
schema_name, table_name, map_key, iname, base_path,
|
|
1645
|
+
all_fields, out_names, pk_info, pk_field, pk_ts_type,
|
|
1646
|
+
has_post, has_put, has_del, has_detail,
|
|
1647
|
+
post_in_names, put_in_names,
|
|
1648
|
+
fk_deps, rev_fk_deps,
|
|
1649
|
+
optional_post_fields,
|
|
1650
|
+
))
|
|
1651
|
+
|
|
1652
|
+
# --- auth guard ---
|
|
1653
|
+
self._write(app_dir / 'core' / 'auth.guard.ts', _auth_guard_ts())
|
|
1654
|
+
|
|
1655
|
+
# --- stores ---
|
|
1656
|
+
stores_dir = app_dir / 'generated' / 'stores'
|
|
1657
|
+
for (schema_name, table_name, map_key, iname, base_path,
|
|
1658
|
+
all_fields, out_names, pk_info, pk_field, pk_ts_type,
|
|
1659
|
+
has_post, has_put, has_del, has_detail,
|
|
1660
|
+
post_in_names, put_in_names,
|
|
1661
|
+
fk_deps, rev_fk_deps,
|
|
1662
|
+
optional_post_fields) in resources:
|
|
1663
|
+
self._write(
|
|
1664
|
+
stores_dir / f'{schema_name}_{table_name}.store.ts',
|
|
1665
|
+
_store(schema_name, table_name, base_path, iname,
|
|
1666
|
+
out_names, all_fields, pk_field, pk_ts_type,
|
|
1667
|
+
has_post, has_put, has_del, post_in_names, put_in_names),
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
# --- app routes + app component ---
|
|
1671
|
+
route_meta = [
|
|
1672
|
+
(r[0], r[1], r[2], r[10], r[11], r[13]) # sn, tn, mk, has_post, has_put, has_detail
|
|
1673
|
+
for r in resources
|
|
1674
|
+
]
|
|
1675
|
+
first_route = f'/ho_bo/{resources[0][0]}/{resources[0][1]}' if resources else '/ho_bo'
|
|
1676
|
+
self._write(app_dir / 'app.routes.ts',
|
|
1677
|
+
_app_routes(route_meta, first_route))
|
|
1678
|
+
self._write(app_dir / 'app.component.ts',
|
|
1679
|
+
_app_component([(r[0], r[1]) for r in resources]))
|
|
1680
|
+
|
|
1681
|
+
# --- home + login + access pages ---
|
|
1682
|
+
self._write(app_dir / 'pages' / 'home' / 'home.component.ts',
|
|
1683
|
+
_home_component_ts(first_route), once=True)
|
|
1684
|
+
self._write(app_dir / 'pages' / 'login' / 'login.component.ts',
|
|
1685
|
+
_login_component(version_prefix))
|
|
1686
|
+
self._write(app_dir / 'pages' / 'access' / 'access.component.ts',
|
|
1687
|
+
_access_component(version_prefix))
|
|
1688
|
+
|
|
1689
|
+
# --- per-resource generated components ---
|
|
1690
|
+
for (schema_name, table_name, map_key, iname, base_path,
|
|
1691
|
+
all_fields, out_names, pk_info, pk_field, pk_ts_type,
|
|
1692
|
+
has_post, has_put, has_del, has_detail,
|
|
1693
|
+
post_in_names, put_in_names,
|
|
1694
|
+
fk_deps, rev_fk_deps,
|
|
1695
|
+
optional_post_fields) in resources:
|
|
1696
|
+
|
|
1697
|
+
comp_dir = app_dir / 'generated' / 'components' / f'{schema_name}_{table_name}'
|
|
1698
|
+
|
|
1699
|
+
self._write(comp_dir / 'list.component.ts',
|
|
1700
|
+
_list_component(schema_name, table_name, iname, map_key,
|
|
1701
|
+
out_names, pk_field, pk_ts_type, has_post, has_del, fk_deps))
|
|
1702
|
+
|
|
1703
|
+
if has_post:
|
|
1704
|
+
self._write(comp_dir / 'create.component.ts',
|
|
1705
|
+
_create_component(schema_name, table_name, iname,
|
|
1706
|
+
post_in_names, all_fields, optional_post_fields))
|
|
1707
|
+
|
|
1708
|
+
if has_detail:
|
|
1709
|
+
self._write(comp_dir / 'detail.component.ts',
|
|
1710
|
+
_detail_component(schema_name, table_name, iname,
|
|
1711
|
+
pk_field, pk_ts_type,
|
|
1712
|
+
out_names, put_in_names, has_put,
|
|
1713
|
+
map_key, fk_deps, rev_fk_deps, all_fields))
|
|
1714
|
+
|
|
1715
|
+
print(f'\nAngular app generated in {output_dir}')
|
|
1716
|
+
print('Next steps:')
|
|
1717
|
+
print(f' cd {output_dir}')
|
|
1718
|
+
print(' npm install')
|
|
1719
|
+
print(' npm start')
|
|
1720
|
+
|
|
1721
|
+
def _write(self, path: Path, content: str, *, once: bool = False) -> None:
|
|
1722
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1723
|
+
if once and path.exists():
|
|
1724
|
+
print(f' {path} (skipped — developer-owned)')
|
|
1725
|
+
return
|
|
1726
|
+
path.write_text(content, encoding='utf-8')
|
|
1727
|
+
print(f' {path}')
|