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.
- package/package.json +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- 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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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(
|
|
262
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|