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.
@@ -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}')