millas 0.2.12-beta → 0.2.12-beta-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/package.json +3 -16
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +31 -10
  31. package/src/auth/AuthController.js +3 -1
  32. package/src/auth/AuthUser.js +119 -0
  33. package/src/cli.js +4 -2
  34. package/src/commands/createsuperuser.js +254 -0
  35. package/src/commands/lang.js +589 -0
  36. package/src/commands/migrate.js +154 -81
  37. package/src/commands/serve.js +82 -110
  38. package/src/container/AppInitializer.js +215 -0
  39. package/src/container/Application.js +278 -253
  40. package/src/container/HttpServer.js +156 -0
  41. package/src/container/MillasApp.js +29 -279
  42. package/src/container/MillasConfig.js +192 -0
  43. package/src/core/admin.js +5 -0
  44. package/src/core/auth.js +9 -0
  45. package/src/core/db.js +9 -0
  46. package/src/core/foundation.js +59 -0
  47. package/src/core/http.js +11 -0
  48. package/src/core/lang.js +1 -0
  49. package/src/core/mail.js +6 -0
  50. package/src/core/queue.js +7 -0
  51. package/src/core/validation.js +29 -0
  52. package/src/facades/Admin.js +1 -1
  53. package/src/facades/Auth.js +22 -39
  54. package/src/facades/Cache.js +21 -10
  55. package/src/facades/Database.js +1 -1
  56. package/src/facades/Events.js +18 -17
  57. package/src/facades/Facade.js +197 -0
  58. package/src/facades/Http.js +42 -45
  59. package/src/facades/Log.js +25 -49
  60. package/src/facades/Mail.js +27 -32
  61. package/src/facades/Queue.js +22 -15
  62. package/src/facades/Storage.js +18 -10
  63. package/src/facades/Url.js +53 -0
  64. package/src/http/HttpClient.js +673 -0
  65. package/src/http/ResponseDispatcher.js +18 -111
  66. package/src/http/UrlGenerator.js +375 -0
  67. package/src/http/WelcomePage.js +273 -0
  68. package/src/http/adapters/ExpressAdapter.js +315 -0
  69. package/src/http/adapters/HttpAdapter.js +168 -0
  70. package/src/http/adapters/index.js +9 -0
  71. package/src/i18n/I18nServiceProvider.js +91 -0
  72. package/src/i18n/Translator.js +635 -0
  73. package/src/i18n/defaults.js +122 -0
  74. package/src/i18n/index.js +164 -0
  75. package/src/i18n/locales/en.js +55 -0
  76. package/src/i18n/locales/sw.js +48 -0
  77. package/src/index.js +5 -144
  78. package/src/logger/formatters/PrettyFormatter.js +103 -57
  79. package/src/logger/internal.js +2 -2
  80. package/src/logger/patchConsole.js +91 -81
  81. package/src/middleware/MiddlewareRegistry.js +62 -82
  82. package/src/migrations/system/0001_users.js +21 -0
  83. package/src/migrations/system/0002_admin_log.js +25 -0
  84. package/src/migrations/system/0003_sessions.js +23 -0
  85. package/src/orm/fields/index.js +210 -188
  86. package/src/orm/migration/DefaultValueParser.js +325 -0
  87. package/src/orm/migration/InteractiveResolver.js +191 -0
  88. package/src/orm/migration/Makemigrations.js +312 -0
  89. package/src/orm/migration/MigrationGraph.js +227 -0
  90. package/src/orm/migration/MigrationRunner.js +202 -108
  91. package/src/orm/migration/MigrationWriter.js +463 -0
  92. package/src/orm/migration/ModelInspector.js +412 -344
  93. package/src/orm/migration/ModelScanner.js +225 -0
  94. package/src/orm/migration/ProjectState.js +213 -0
  95. package/src/orm/migration/RenameDetector.js +175 -0
  96. package/src/orm/migration/SchemaBuilder.js +8 -81
  97. package/src/orm/migration/operations/base.js +57 -0
  98. package/src/orm/migration/operations/column.js +191 -0
  99. package/src/orm/migration/operations/fields.js +252 -0
  100. package/src/orm/migration/operations/index.js +55 -0
  101. package/src/orm/migration/operations/models.js +152 -0
  102. package/src/orm/migration/operations/registry.js +131 -0
  103. package/src/orm/migration/operations/special.js +51 -0
  104. package/src/orm/migration/utils.js +208 -0
  105. package/src/orm/model/Model.js +81 -13
  106. package/src/providers/AdminServiceProvider.js +66 -9
  107. package/src/providers/AuthServiceProvider.js +46 -7
  108. package/src/providers/CacheStorageServiceProvider.js +5 -3
  109. package/src/providers/DatabaseServiceProvider.js +3 -2
  110. package/src/providers/EventServiceProvider.js +2 -1
  111. package/src/providers/LogServiceProvider.js +7 -3
  112. package/src/providers/MailServiceProvider.js +4 -3
  113. package/src/providers/QueueServiceProvider.js +4 -3
  114. package/src/router/Router.js +119 -152
  115. package/src/scaffold/templates.js +83 -26
  116. package/src/facades/Validation.js +0 -69
@@ -1,5 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ const { normaliseField } = require('../../orm/migration/ProjectState');
4
+ const { HookPipeline, AdminHooks } = require('../HookRegistry');
5
+ const { FormGenerator } = require('../FormGenerator');
6
+ const { QueryEngine } = require('../QueryEngine');
7
+
3
8
  /**
4
9
  * AdminResource
5
10
  *
@@ -146,8 +151,13 @@ class AdminResource {
146
151
  * Use AdminField.tab() and AdminField.fieldset() for layout.
147
152
  */
148
153
  static fields() {
149
- if (!this.model?.fields) return [];
150
- return Object.entries(this.model.fields).map(([name, def]) =>
154
+ // Use getFields() to get the merged field map (honours abstract inheritance).
155
+ // Falls back to .fields for models that don't yet have getFields().
156
+ const fieldMap = typeof this.model?.getFields === 'function'
157
+ ? this.model.getFields()
158
+ : (this.model?.fields || {});
159
+ if (!Object.keys(fieldMap).length) return [];
160
+ return Object.entries(fieldMap).map(([name, def]) =>
151
161
  AdminField.fromModelField(name, def)
152
162
  );
153
163
  }
@@ -161,91 +171,27 @@ class AdminResource {
161
171
  * Override to customise how records are fetched.
162
172
  * Receives { page, perPage, search, sort, order, filters }
163
173
  */
164
- static async fetchList({ page = 1, perPage, search, sort = 'id', order = 'desc', filters = {}, year, month } = {}) {
165
- const limit = perPage || this.perPage;
166
- const offset = (page - 1) * limit;
167
-
168
- // _db() is available on all ORM versions — it returns a raw knex table query.
169
- // We build everything via knex directly so this works regardless of whether
170
- // the ORM changes (changes3) have been applied.
171
- let q = this.model._db().orderBy(sort, order);
172
-
173
- // ── Search ───────────────────────────────────────────────────────────────
174
- if (search && this.searchable.length) {
175
- const cols = this.searchable;
176
- q = q.where(function () {
177
- for (const col of cols) {
178
- this.orWhere(col, 'like', `%${search}%`);
179
- }
180
- });
181
- }
182
-
183
- // ── Filters ──────────────────────────────────────────────────────────────
184
- // Translate __ lookup syntax into knex calls so filter controls work
185
- // even without the ORM changes applied.
186
- for (const [key, value] of Object.entries(filters)) {
187
- if (value === '' || value === null || value === undefined) continue;
188
-
189
- const dunder = key.lastIndexOf('__');
190
- if (dunder === -1) {
191
- q = q.where(key, value);
192
- continue;
193
- }
194
-
195
- const col = key.slice(0, dunder);
196
- const lookup = key.slice(dunder + 2);
197
-
198
- switch (lookup) {
199
- case 'exact': q = q.where(col, value); break;
200
- case 'not': q = q.where(col, '!=', value); break;
201
- case 'gt': q = q.where(col, '>', value); break;
202
- case 'gte': q = q.where(col, '>=', value); break;
203
- case 'lt': q = q.where(col, '<', value); break;
204
- case 'lte': q = q.where(col, '<=', value); break;
205
- case 'isnull': q = value ? q.whereNull(col) : q.whereNotNull(col); break;
206
- case 'in': q = q.whereIn(col, Array.isArray(value) ? value : [value]); break;
207
- case 'notin': q = q.whereNotIn(col, Array.isArray(value) ? value : [value]); break;
208
- case 'between': q = q.whereBetween(col, value); break;
209
- case 'contains':
210
- case 'icontains': q = q.where(col, 'like', `%${value}%`); break;
211
- case 'startswith':
212
- case 'istartswith': q = q.where(col, 'like', `${value}%`); break;
213
- case 'endswith':
214
- case 'iendswith': q = q.where(col, 'like', `%${value}`); break;
215
- default: q = q.where(key, value); break;
216
- }
217
- }
218
-
219
- // ── Date hierarchy ────────────────────────────────────────────────────────
220
- if (this.dateHierarchy) {
221
- const col = this.dateHierarchy;
222
- if (year) {
223
- // SQLite / MySQL / PG compatible
224
- q = q.whereRaw(`strftime('%Y', "${col}") = ?`, [String(year)])
225
- .catch
226
- // If strftime not available (PG), fall through — best effort
227
- || q;
228
- }
229
- if (month) {
230
- q = q.whereRaw(`strftime('%m', "${col}") = ?`, [String(month).padStart(2, '0')]);
231
- }
232
- }
233
-
234
- // ── Execute ───────────────────────────────────────────────────────────────
235
- const [rows, countResult] = await Promise.all([
236
- q.clone().limit(limit).offset(offset),
237
- q.clone().count('* as count').first(),
238
- ]);
239
-
240
- const total = Number(countResult?.count ?? 0);
241
-
242
- return {
243
- data: rows.map(r => this.model._hydrate(r)),
244
- total,
245
- page: Number(page),
246
- perPage: limit,
247
- lastPage: Math.ceil(total / limit) || 1,
248
- };
174
+ /**
175
+ * Fetch a paginated, filtered, sorted list of records.
176
+ *
177
+ * Delegates to QueryEngine which handles:
178
+ * - Django __ lookup syntax filtering
179
+ * - Full-text search across searchable columns
180
+ * - Date hierarchy drill-down
181
+ * - Column pruning (list_display only)
182
+ * - Parallel data + count queries
183
+ *
184
+ * Override this method in a subclass to customise the query entirely:
185
+ *
186
+ * static async fetchList(opts) {
187
+ * const base = await super.fetchList(opts);
188
+ * // post-process base.data ...
189
+ * return base;
190
+ * }
191
+ */
192
+ static async fetchList(opts = {}) {
193
+ const engine = new QueryEngine(this);
194
+ return engine.fetchList(opts);
249
195
  }
250
196
 
251
197
  /**
@@ -257,34 +203,239 @@ class AdminResource {
257
203
 
258
204
  /**
259
205
  * Create a new record from form data.
206
+ * Fires before_save (with isNew=true) and after_save hooks.
207
+ *
208
+ * before_save can:
209
+ * - Mutate and return data (e.g. hash a password, set created_by)
210
+ * - Throw to abort the create with an error shown to the admin user
211
+ *
212
+ * @param {object} rawData — raw req.body
213
+ * @param {object} [ctx] — { user, resource } injected by Admin.js handler
260
214
  */
261
- static async create(data) {
262
- return this.model.create(this._sanitise(data));
215
+ static async create(rawData, ctx = {}) {
216
+ let data = this._sanitise(rawData);
217
+
218
+ // ── before_save ──────────────────────────────────────────────────────
219
+ const beforeCtx = await HookPipeline.run(
220
+ 'before_save',
221
+ { data, user: ctx.user || null, isNew: true, resource: this },
222
+ this,
223
+ AdminHooks,
224
+ );
225
+ data = beforeCtx.data;
226
+
227
+ // ── Validate ────────────────────────────────────────────────────────────
228
+ const createErrors = FormGenerator.validate(this, data, { isNew: true });
229
+ if (createErrors) {
230
+ const err = new Error('Validation failed');
231
+ err.status = 422;
232
+ err.errors = createErrors;
233
+ throw err;
234
+ }
235
+
236
+ // ── ORM write ────────────────────────────────────────────────────────
237
+ const record = await this.model.create(data);
238
+
239
+ // ── after_save ───────────────────────────────────────────────────────
240
+ await HookPipeline.run(
241
+ 'after_save',
242
+ { record, user: ctx.user || null, isNew: true, resource: this },
243
+ this,
244
+ AdminHooks,
245
+ );
246
+
247
+ return record;
263
248
  }
264
249
 
265
250
  /**
266
251
  * Update a record from form data.
252
+ * Fires before_save (with isNew=false) and after_save hooks.
253
+ *
254
+ * @param {*} id — primary key
255
+ * @param {object} rawData — raw req.body
256
+ * @param {object} [ctx] — { user, resource } injected by Admin.js handler
267
257
  */
268
- static async update(id, data) {
258
+ static async update(id, rawData, ctx = {}) {
259
+ let data = this._sanitise(rawData);
260
+
261
+ // ── before_save ──────────────────────────────────────────────────────
262
+ const beforeCtx = await HookPipeline.run(
263
+ 'before_save',
264
+ { data, user: ctx.user || null, isNew: false, resource: this },
265
+ this,
266
+ AdminHooks,
267
+ );
268
+ data = beforeCtx.data;
269
+
270
+ // ── Validate ────────────────────────────────────────────────────────────
271
+ const updateErrors = FormGenerator.validate(this, data, { isNew: false });
272
+ if (updateErrors) {
273
+ const err = new Error('Validation failed');
274
+ err.status = 422;
275
+ err.errors = updateErrors;
276
+ throw err;
277
+ }
278
+
279
+ // ── ORM write ────────────────────────────────────────────────────────
269
280
  const record = await this.model.findOrFail(id);
270
- return record.update(this._sanitise(data));
281
+ await record.update(data);
282
+
283
+ // ── after_save ───────────────────────────────────────────────────────
284
+ await HookPipeline.run(
285
+ 'after_save',
286
+ { record, user: ctx.user || null, isNew: false, resource: this },
287
+ this,
288
+ AdminHooks,
289
+ );
290
+
291
+ return record;
271
292
  }
272
293
 
273
294
  /**
274
295
  * Delete a record.
296
+ * Fires before_delete (can abort by throwing) and after_delete hooks.
297
+ *
298
+ * @param {*} id — primary key
299
+ * @param {object} [ctx] — { user, resource } injected by Admin.js handler
275
300
  */
276
- static async destroy(id) {
277
- return this.model.destroy(id);
301
+ static async destroy(id, ctx = {}) {
302
+ // Load the record first so before_delete hooks can inspect it
303
+ const record = await this.model.findOrFail(id);
304
+
305
+ // ── before_delete ────────────────────────────────────────────────────
306
+ await HookPipeline.run(
307
+ 'before_delete',
308
+ { record, user: ctx.user || null, resource: this },
309
+ this,
310
+ AdminHooks,
311
+ );
312
+
313
+ // ── ORM delete ───────────────────────────────────────────────────────
314
+ await this.model.destroy(id);
315
+
316
+ // ── after_delete ─────────────────────────────────────────────────────
317
+ await HookPipeline.run(
318
+ 'after_delete',
319
+ { id, record, user: ctx.user || null, resource: this },
320
+ this,
321
+ AdminHooks,
322
+ );
278
323
  }
279
324
 
280
325
  // ─── Internal ─────────────────────────────────────────────────────────────
281
326
 
282
- static _sanitise(data) {
283
- // Remove private/system fields
327
+ /**
328
+ * Sanitise and type-coerce raw HTML form data before passing to the ORM.
329
+ *
330
+ * HTML forms submit everything as strings. Without coercion:
331
+ * - checkboxes come in as 'on' or missing entirely (not false)
332
+ * - numbers come in as '42' (string), breaking integer/decimal columns
333
+ * - nullable fields come in as '' (empty string) instead of null
334
+ * - booleans come in as 'true'/'false' strings
335
+ *
336
+ * This method reads the model's field definitions and coerces each value
337
+ * to the correct type before the ORM sees it.
338
+ *
339
+ * @param {object} data — raw req.body
340
+ * @param {object} [model] — optional model class override (defaults to this.model)
341
+ */
342
+ static _sanitise(data, model) {
284
343
  const clean = { ...data };
344
+
345
+ // Strip system / framework fields — never write these
285
346
  delete clean.id;
286
347
  delete clean._method;
287
348
  delete clean._token;
349
+ delete clean._csrf;
350
+ delete clean._submit;
351
+
352
+ // Get the merged field map for type coercion
353
+ const M = model || this.model;
354
+ const fieldMap = M
355
+ ? (typeof M.getFields === 'function' ? M.getFields() : (M.fields || {}))
356
+ : {};
357
+
358
+ // ── Boolean fields: checkbox sends 'on' when checked, nothing when unchecked ──
359
+ // We must explicitly set false for unchecked boxes because the key is absent.
360
+ for (const [name, def] of Object.entries(fieldMap)) {
361
+ if (def && def.type === 'boolean') {
362
+ const raw = clean[name];
363
+ if (raw === undefined || raw === null || raw === '') {
364
+ // Unchecked checkbox — HTML sends nothing, default to false
365
+ clean[name] = false;
366
+ } else if (raw === 'on' || raw === '1' || raw === 'true' || raw === true) {
367
+ clean[name] = true;
368
+ } else if (raw === '0' || raw === 'false' || raw === false) {
369
+ clean[name] = false;
370
+ } else {
371
+ clean[name] = Boolean(raw);
372
+ }
373
+ }
374
+ }
375
+
376
+ // ── Coerce remaining types ────────────────────────────────────────────────
377
+ for (const [key, raw] of Object.entries(clean)) {
378
+ const def = fieldMap[key];
379
+ if (!def) continue; // unknown field — leave as-is, ORM will reject if invalid
380
+
381
+ switch (def.type) {
382
+ case 'integer':
383
+ case 'bigInteger': {
384
+ if (raw === '' || raw === null || raw === undefined) {
385
+ clean[key] = def.nullable ? null : 0;
386
+ } else {
387
+ const n = parseInt(raw, 10);
388
+ clean[key] = isNaN(n) ? (def.nullable ? null : 0) : n;
389
+ }
390
+ break;
391
+ }
392
+ case 'float':
393
+ case 'decimal': {
394
+ if (raw === '' || raw === null || raw === undefined) {
395
+ clean[key] = def.nullable ? null : 0;
396
+ } else {
397
+ const n = parseFloat(raw);
398
+ clean[key] = isNaN(n) ? (def.nullable ? null : 0) : n;
399
+ }
400
+ break;
401
+ }
402
+ case 'boolean':
403
+ // Already handled above
404
+ break;
405
+ case 'string':
406
+ case 'text': {
407
+ if (raw === '' || raw === undefined) {
408
+ // Empty string on a nullable field → null; on required field → keep as ''
409
+ // so the validator can complain rather than silently nulling it
410
+ clean[key] = def.nullable ? null : '';
411
+ }
412
+ break;
413
+ }
414
+ case 'json': {
415
+ if (typeof raw === 'string' && raw.trim() !== '') {
416
+ try { clean[key] = JSON.parse(raw); } catch { /* leave as string, let validator catch */ }
417
+ } else if (raw === '' || raw === undefined) {
418
+ clean[key] = def.nullable ? null : {};
419
+ }
420
+ break;
421
+ }
422
+ case 'date':
423
+ case 'timestamp': {
424
+ if (raw === '' || raw === null || raw === undefined) {
425
+ clean[key] = def.nullable ? null : undefined;
426
+ if (clean[key] === undefined) delete clean[key];
427
+ }
428
+ break;
429
+ }
430
+ case 'id':
431
+ // Never write id — already deleted above
432
+ delete clean[key];
433
+ break;
434
+ default:
435
+ // string-like fields: leave as-is
436
+ }
437
+ }
438
+
288
439
  return clean;
289
440
  }
290
441
 
@@ -295,6 +446,75 @@ class AdminResource {
295
446
  static _getLabelSingular() {
296
447
  return this.labelSingular || this.model?.name || 'Record';
297
448
  }
449
+
450
+ // ─── Per-user permission resolution ───────────────────────────────────────
451
+
452
+ /**
453
+ * Resolve whether `user` has `action` permission for this resource.
454
+ *
455
+ * action: 'view' | 'add' | 'change' | 'delete'
456
+ *
457
+ * Rules (Django-matching):
458
+ * 1. Superusers (is_superuser=true) always get true.
459
+ * 2. The static boolean flag (canView/canCreate/canEdit/canDelete) is
460
+ * the class-level default — if false, nobody gets in regardless.
461
+ * 3. Non-superuser staff check user.permissions — a JSON array of
462
+ * permission strings like ['{slug}.view', '{slug}.add', ...].
463
+ * An empty/missing permissions array means no access.
464
+ *
465
+ * Override this method in a resource subclass for custom logic:
466
+ *
467
+ * static hasPermission(user, action) {
468
+ * if (action === 'delete') return false; // nobody can delete
469
+ * return super.hasPermission(user, action);
470
+ * }
471
+ *
472
+ * @param {object|null} user — req.adminUser (live User model instance)
473
+ * @param {string} action — 'view'|'add'|'change'|'delete'
474
+ * @returns {boolean}
475
+ */
476
+ static hasPermission(user, action) {
477
+ // Map action → static boolean flag
478
+ const flagMap = {
479
+ view: 'canView',
480
+ add: 'canCreate',
481
+ change: 'canEdit',
482
+ delete: 'canDelete',
483
+ };
484
+ const flag = flagMap[action];
485
+
486
+ // Class-level hard disable — applies to everyone including superusers
487
+ if (flag && this[flag] === false) return false;
488
+
489
+ // No user context (auth disabled) — fall back to static flag
490
+ if (!user) return flag ? this[flag] !== false : true;
491
+
492
+ // Superusers bypass all per-user permission checks
493
+ if (user.is_superuser || user.is_superuser === 1) return true;
494
+
495
+ // Non-superuser staff — check permissions array
496
+ // Format: '{slug}.{action}' e.g. 'users.view', 'posts.add'
497
+ const permKey = `${this.slug}.${action}`;
498
+ const userPerms = this._parsePermissions(user.permissions);
499
+ return userPerms.has(permKey);
500
+ }
501
+
502
+ /**
503
+ * Parse user.permissions into a Set of permission strings.
504
+ * Handles: JSON string, plain array, comma-separated string, null/undefined.
505
+ */
506
+ static _parsePermissions(raw) {
507
+ if (!raw) return new Set();
508
+ try {
509
+ if (Array.isArray(raw)) return new Set(raw);
510
+ if (typeof raw === 'string') {
511
+ const trimmed = raw.trim();
512
+ if (trimmed.startsWith('[')) return new Set(JSON.parse(trimmed));
513
+ return new Set(trimmed.split(',').map(s => s.trim()).filter(Boolean));
514
+ }
515
+ } catch {}
516
+ return new Set();
517
+ }
298
518
  }
299
519
 
300
520
  // ── AdminField ────────────────────────────────────────────────────────────────
@@ -337,6 +557,27 @@ class AdminField {
337
557
  static color(name) { return new AdminField(name, 'color'); }
338
558
  static richtext(name) { return new AdminField(name, 'richtext'); }
339
559
 
560
+ /**
561
+ * FK relation field — renders as a searchable select.
562
+ * resourceSlug: the admin resource slug to fetch options from.
563
+ * AdminField.fk('user_id', 'users').label('User')
564
+ */
565
+ static fk(name, resourceSlug) {
566
+ const f = new AdminField(name, 'fk');
567
+ f._fkResource = resourceSlug || null;
568
+ return f;
569
+ }
570
+
571
+ /**
572
+ * M2M relation field — renders as a dual-list widget.
573
+ * AdminField.m2m('tags', 'tags').label('Tags')
574
+ */
575
+ static m2m(name, resourceSlug) {
576
+ const f = new AdminField(name, 'm2m');
577
+ f._m2mResource = resourceSlug || null;
578
+ return f;
579
+ }
580
+
340
581
  static select(name, options) {
341
582
  const f = new AdminField(name, 'select');
342
583
  f._options = Array.isArray(options)
@@ -423,6 +664,8 @@ class AdminField {
423
664
  min: this._min,
424
665
  max: this._max,
425
666
  isLink: this._isLink || false,
667
+ fkResource: this._fkResource || null,
668
+ m2mResource: this._m2mResource || null,
426
669
  prepopulate: this._prepopulate || null,
427
670
  };
428
671
  }
@@ -440,6 +683,33 @@ class AdminField {
440
683
  }
441
684
 
442
685
  static fromModelField(name, fieldDef) {
686
+ // Normalise first — raw FieldDefinition from fields.ForeignKey() has
687
+ // _isForeignKey=true but references=null until normaliseField resolves
688
+ // _fkModel into { table, column, onDelete }. Without this, fkResource
689
+ // is always null and the dropdown never loads.
690
+ const def = normaliseField(fieldDef);
691
+
692
+ // ── FK / M2M detection ────────────────────────────────────────────────
693
+ // ForeignKey fields are integer type but carry _isForeignKey flag.
694
+ // ManyToMany fields carry _isManyToMany flag.
695
+ if (def._isManyToMany) {
696
+ const f = AdminField.m2m(name, null);
697
+ f._nullable = true;
698
+ return f;
699
+ }
700
+ if (def._isForeignKey) {
701
+ // Django convention: declared as 'landlord' → DB column 'landlord_id'.
702
+ const colName = name.endsWith('_id') ? name : name + '_id';
703
+
704
+ // references.table is now resolved by normaliseField (e.g. 'users')
705
+ const resourceSlug = def.references?.table || null;
706
+
707
+ const f = AdminField.fk(colName, resourceSlug);
708
+ f._label = _toLabel(name);
709
+ if (def.nullable) f._nullable = true;
710
+ return f;
711
+ }
712
+
443
713
  const typeMap = {
444
714
  id: () => AdminField.id(name),
445
715
  string: () => AdminField.text(name),
@@ -547,7 +817,10 @@ class AdminInline {
547
817
 
548
818
  /** Serialise to plain object for template rendering. */
549
819
  toJSON() {
550
- const modelFields = this.model?.fields || {};
820
+ // Use getFields() for merged inheritance support; fall back to .fields
821
+ const modelFields = typeof this.model?.getFields === 'function'
822
+ ? this.model.getFields()
823
+ : (this.model?.fields || {});
551
824
  const displayFields = this.fields.length
552
825
  ? this.fields
553
826
  : Object.keys(modelFields).slice(0, 6);
@@ -566,4 +839,17 @@ class AdminInline {
566
839
  }
567
840
  }
568
841
 
569
- module.exports = { AdminResource, AdminField, AdminFilter, AdminInline };
842
+ // ── Helpers ───────────────────────────────────────────────────────────────────
843
+
844
+ /**
845
+ * Convert a snake_case field name to a Title Case label.
846
+ * 'landlord_id' → 'Landlord', 'user_id' → 'User', 'created_at' → 'Created At'
847
+ */
848
+ function _toLabel(name) {
849
+ return name
850
+ .replace(/_id$/, '') // strip _id suffix
851
+ .replace(/_/g, ' ') // underscores → spaces
852
+ .replace(/\w/g, c => c.toUpperCase()); // Title Case
853
+ }
854
+
855
+ module.exports = { AdminResource, AdminField, AdminFilter, AdminInline };