@saltcorn/server 0.8.7 → 0.8.8-beta.0

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,492 @@
1
+ /**
2
+ * @category server
3
+ * @module routes/models
4
+ * @subcategory routes
5
+ */
6
+
7
+ const Router = require("express-promise-router");
8
+
9
+ const { error_catcher, is_relative_url, isAdmin } = require("./utils.js");
10
+ const Table = require("@saltcorn/data/models/table");
11
+ const Form = require("@saltcorn/data/models/form");
12
+ const Model = require("@saltcorn/data/models/model");
13
+ const ModelInstance = require("@saltcorn/data/models/model_instance");
14
+ const { getState } = require("@saltcorn/data/db/state");
15
+ const db = require("@saltcorn/data/db");
16
+ const moment = require("moment");
17
+
18
+ const { mkTable, renderForm, post_delete_btn } = require("@saltcorn/markup");
19
+ const {
20
+ span,
21
+ h4,
22
+ p,
23
+ a,
24
+ div,
25
+ i,
26
+ form,
27
+ label,
28
+ input,
29
+ text,
30
+ script,
31
+ domReady,
32
+ code,
33
+ iframe,
34
+ style,
35
+ pre,
36
+ } = require("@saltcorn/markup/tags");
37
+
38
+ const router = new Router();
39
+ module.exports = router;
40
+
41
+ const newModelForm = (table, req) => {
42
+ return new Form({
43
+ action: "/models/new/" + table.id,
44
+ fields: [
45
+ { name: "name", label: "Name", type: "String", required: true },
46
+ {
47
+ name: "modelpattern",
48
+ label: "Model pattern",
49
+ type: "String",
50
+ required: true,
51
+ attributes: { options: Object.keys(getState().modelpatterns) },
52
+ },
53
+ ],
54
+ });
55
+ };
56
+
57
+ router.get(
58
+ "/new/:table_id",
59
+ isAdmin,
60
+ error_catcher(async (req, res) => {
61
+ const { table_id } = req.params;
62
+ const table = await Table.findOne({ id: table_id });
63
+ res.sendWrap(req.__(`New model`), {
64
+ above: [
65
+ {
66
+ type: "breadcrumbs",
67
+ crumbs: [
68
+ { text: req.__("Tables"), href: "/table" },
69
+ { href: `/table/${table.id}`, text: table.name },
70
+ { text: req.__(`New model`) },
71
+ ],
72
+ },
73
+ {
74
+ type: "card",
75
+ class: "mt-0",
76
+ title: req.__(`New model`),
77
+ contents: renderForm(newModelForm(table, req), req.csrfToken()),
78
+ },
79
+ ],
80
+ });
81
+ })
82
+ );
83
+
84
+ router.post(
85
+ "/new/:table_id",
86
+ isAdmin,
87
+ error_catcher(async (req, res) => {
88
+ const { table_id } = req.params;
89
+ const table = await Table.findOne({ id: table_id });
90
+ const form = newModelForm(table, req);
91
+ form.validate(req.body);
92
+ if (form.hasErrors) {
93
+ res.sendWrap(req.__(`New model`), renderForm(form, req.csrfToken()));
94
+ } else {
95
+ const model = await Model.create({ ...form.values, table_id: table.id });
96
+ if (model.templateObj.configuration_workflow)
97
+ res.redirect(`/models/config/${model.id}`);
98
+ else res.redirect(`/models/show/${model.id}`);
99
+ }
100
+ })
101
+ );
102
+
103
+ const respondWorkflow = (model, table, wf, wfres, req, res) => {
104
+ const wrap = (contents, noCard, previewURL) => ({
105
+ above: [
106
+ {
107
+ type: "breadcrumbs",
108
+ crumbs: [
109
+ { text: req.__("Tables"), href: "/table" },
110
+ { href: `/table/${table.id || table.name}`, text: table.name },
111
+ { href: `/models/show/${model.id}`, text: model.name },
112
+ { text: req.__("Configuration") },
113
+ ],
114
+ },
115
+ {
116
+ type: noCard ? "container" : "card",
117
+ class: !noCard && "mt-0",
118
+ title: wfres.title,
119
+ titleAjaxIndicator: true,
120
+ contents,
121
+ },
122
+ ],
123
+ });
124
+ if (wfres.flash) req.flash(wfres.flash[0], wfres.flash[1]);
125
+ if (wfres.renderForm)
126
+ res.sendWrap(
127
+ {
128
+ title: req.__(`%s configuration`, model.name),
129
+ headers: [
130
+ {
131
+ script: `/static_assets/${db.connectObj.version_tag}/jquery-menu-editor.min.js`,
132
+ },
133
+ {
134
+ script: `/static_assets/${db.connectObj.version_tag}/iconset-fontawesome5-3-1.min.js`,
135
+ },
136
+ {
137
+ script: `/static_assets/${db.connectObj.version_tag}/bootstrap-iconpicker.js`,
138
+ },
139
+ {
140
+ css: `/static_assets/${db.connectObj.version_tag}/bootstrap-iconpicker.min.css`,
141
+ },
142
+ ],
143
+ },
144
+ wrap(
145
+ renderForm(wfres.renderForm, req.csrfToken()),
146
+ false,
147
+ wfres.previewURL
148
+ )
149
+ );
150
+ else res.redirect(wfres.redirect);
151
+ };
152
+
153
+ const get_model_workflow = (model, req) => {
154
+ const workflow = model.templateObj.configuration_workflow(req);
155
+ workflow.action = `/models/config/${model.id}`;
156
+ const oldOnDone = workflow.onDone || ((c) => c);
157
+ workflow.onDone = async (ctx) => {
158
+ const { id, ...configuration } = await oldOnDone(ctx);
159
+ await model.update({ configuration });
160
+
161
+ return {
162
+ redirect: `/models/show/${model.id}`,
163
+ flash: ["success", `Model ${this.name || ""} saved`],
164
+ };
165
+ };
166
+ return workflow;
167
+ };
168
+
169
+ router.get(
170
+ "/config/:id",
171
+ isAdmin,
172
+ error_catcher(async (req, res) => {
173
+ const { id } = req.params;
174
+ const { step } = req.query;
175
+
176
+ const model = await Model.findOne({ id });
177
+ const table = await Table.findOne({ id: model.table_id });
178
+ const configFlow = get_model_workflow(model, req);
179
+ const wfres = await configFlow.run(
180
+ {
181
+ ...model.configuration,
182
+ id: model.id,
183
+ table_id: model.table_id,
184
+ ...(step ? { stepName: step } : {}),
185
+ },
186
+ req
187
+ );
188
+ respondWorkflow(model, table, configFlow, wfres, req, res);
189
+ })
190
+ );
191
+
192
+ router.post(
193
+ "/config/:id",
194
+ isAdmin,
195
+ error_catcher(async (req, res) => {
196
+ const { id } = req.params;
197
+ const { step } = req.query;
198
+
199
+ const model = await Model.findOne({ id });
200
+ const table = await Table.findOne({ id: model.table_id });
201
+ if (!table) {
202
+ req.flash("error", `Table not found`);
203
+ res.redirect(`/table`);
204
+ return;
205
+ }
206
+ const workflow = get_model_workflow(model, req);
207
+ const wfres = await workflow.run(req.body, req);
208
+ respondWorkflow(model, table, workflow, wfres, req, res);
209
+ })
210
+ );
211
+
212
+ router.get(
213
+ "/show/:id",
214
+ isAdmin,
215
+ error_catcher(async (req, res) => {
216
+ const { id } = req.params;
217
+ const model = await Model.findOne({ id });
218
+ const table = await Table.findOne({ id: model.table_id });
219
+ const instances = await ModelInstance.find({ model_id: model.id });
220
+ const metrics = model.templateObj.metrics || {};
221
+ const metricCols = Object.entries(metrics).map(([k, v]) => ({
222
+ label: k,
223
+ key: (inst) => inst.metric_values?.[k]?.toPrecision(6),
224
+ }));
225
+ const anyReport = instances.some((i) => !!i.report);
226
+ res.sendWrap(req.__(`Show model`), {
227
+ above: [
228
+ {
229
+ type: "breadcrumbs",
230
+ crumbs: [
231
+ { text: req.__("Tables"), href: "/table" },
232
+ { href: `/table/${table.id}`, text: table.name },
233
+ { text: model.name },
234
+ ],
235
+ after: a(
236
+ { href: `/models/config/${model.id}` },
237
+ req.__("Edit"),
238
+ i({ class: "ms-1 fas fa-edit" })
239
+ ),
240
+ },
241
+ {
242
+ type: "card",
243
+ class: "mt-0",
244
+ title: req.__("Model instances"),
245
+ contents: div(
246
+ mkTable(
247
+ [
248
+ { label: req.__("Name"), key: "name" },
249
+ {
250
+ label: req.__("Trained"),
251
+ key: (inst) => moment(inst.trained_on).fromNow(),
252
+ },
253
+ ...(anyReport
254
+ ? [
255
+ {
256
+ label: req.__("Report"),
257
+ key: (inst) =>
258
+ inst.report
259
+ ? a(
260
+ { href: `/models/show-report/${inst.id}` },
261
+ i({ class: "fas fa-file-alt" })
262
+ )
263
+ : "",
264
+ },
265
+ ]
266
+ : []),
267
+ ...metricCols,
268
+ {
269
+ label: req.__("Default"),
270
+ key: (inst) =>
271
+ form(
272
+ {
273
+ action: `/models/make-default-instance/${inst.id}`,
274
+ method: "POST",
275
+ },
276
+ span(
277
+ { class: "form-switch" },
278
+ input({
279
+ class: ["form-check-input"],
280
+ type: "checkbox",
281
+ onChange: "this.form.submit()",
282
+ role: "switch",
283
+ name: "enabled",
284
+ ...(inst.is_default && { checked: true }),
285
+ })
286
+ ),
287
+ input({
288
+ type: "hidden",
289
+ name: "_csrf",
290
+ value: req.csrfToken(),
291
+ })
292
+ ),
293
+ },
294
+ {
295
+ label: req.__("Delete"),
296
+ key: (r) =>
297
+ post_delete_btn(
298
+ `/models/delete-instance/${encodeURIComponent(r.id)}`,
299
+ req
300
+ ),
301
+ },
302
+ ],
303
+ instances
304
+ ),
305
+ a(
306
+ { href: `/models/train/${model.id}`, class: "btn btn-primary" },
307
+ i({ class: "fas fa-graduation-cap me-1" }),
308
+ req.__("Train new instance")
309
+ )
310
+ ),
311
+ },
312
+ ],
313
+ });
314
+ })
315
+ );
316
+ const model_train_form = (model, table, req) => {
317
+ const hyperparameter_fields =
318
+ model.templateObj.hyperparameter_fields?.({
319
+ table,
320
+ ...model,
321
+ }) || [];
322
+ return new Form({
323
+ action: `/models/train/${model.id}`,
324
+ onSubmit: "press_store_button(this)",
325
+ fields: [
326
+ {
327
+ name: "name",
328
+ label: req.__("Name"),
329
+ type: "String",
330
+ required: true,
331
+ },
332
+ ...hyperparameter_fields,
333
+ ],
334
+ });
335
+ };
336
+ router.post(
337
+ "/delete/:id",
338
+ isAdmin,
339
+ error_catcher(async (req, res) => {
340
+ const { id } = req.params;
341
+ const model = await Model.findOne({ id });
342
+ await model.delete();
343
+ req.flash("success", req.__("Model %s deleted", model.name));
344
+ res.redirect(`/table/${model.table_id}`);
345
+ })
346
+ );
347
+
348
+ router.post(
349
+ "/delete-instance/:id",
350
+ isAdmin,
351
+ error_catcher(async (req, res) => {
352
+ const { id } = req.params;
353
+ const model_inst = await ModelInstance.findOne({ id });
354
+ await model_inst.delete();
355
+ req.flash("success", req.__("Model instance %s deleted", model_inst.name));
356
+ res.redirect(`/models/show/${model_inst.model_id}`);
357
+ })
358
+ );
359
+
360
+ router.get(
361
+ "/train/:id",
362
+ isAdmin,
363
+ error_catcher(async (req, res) => {
364
+ const { id } = req.params;
365
+ const model = await Model.findOne({ id });
366
+ const table = await Table.findOne({ id: model.table_id });
367
+ const form = model_train_form(model, table, req);
368
+ res.sendWrap(req.__(`Train model`), {
369
+ above: [
370
+ {
371
+ type: "breadcrumbs",
372
+ crumbs: [
373
+ { text: req.__("Tables"), href: "/table" },
374
+ { href: `/table/${table.id}`, text: table.name },
375
+ { href: `/models/show/${model.id}`, text: model.name },
376
+ { text: req.__(`Train`) },
377
+ ],
378
+ },
379
+ {
380
+ type: "card",
381
+ class: "mt-0",
382
+ title: req.__(`New model`),
383
+ contents: renderForm(form, req.csrfToken()),
384
+ },
385
+ ],
386
+ });
387
+ })
388
+ );
389
+ router.post(
390
+ "/train/:id",
391
+ isAdmin,
392
+ error_catcher(async (req, res) => {
393
+ const { id } = req.params;
394
+ const model = await Model.findOne({ id });
395
+ const table = Table.findOne({ id: model.table_id });
396
+ const form = model_train_form(model, table, req);
397
+ form.validate(req.body);
398
+ if (form.hasErrors) {
399
+ res.sendWrap(req.__(`Train model`), renderForm(form, req.csrfToken()));
400
+ } else {
401
+ const { name, ...hyperparameters } = form.values;
402
+
403
+ const train_res = await model.train_instance(name, hyperparameters, {});
404
+ if (typeof train_res === "string") {
405
+ res.sendWrap(req.__(`Model training error`), {
406
+ above: [
407
+ {
408
+ type: "breadcrumbs",
409
+ crumbs: [
410
+ { text: req.__("Tables"), href: "/table" },
411
+ { href: `/table/${table.id}`, text: table.name },
412
+ { href: `/models/show/${model.id}`, text: model.name },
413
+ { text: req.__(`Training error`) },
414
+ ],
415
+ },
416
+ {
417
+ type: "card",
418
+ class: "mt-0",
419
+ title: req.__(`Training error`),
420
+ contents: pre(train_res),
421
+ },
422
+ ],
423
+ });
424
+ } else {
425
+ req.flash("success", "Model trained");
426
+ res.redirect(`/models/show/${model.id}`);
427
+ }
428
+ }
429
+ })
430
+ );
431
+
432
+ router.post(
433
+ "/make-default-instance/:id",
434
+ isAdmin,
435
+ error_catcher(async (req, res) => {
436
+ const { id } = req.params;
437
+ const model_instance = await ModelInstance.findOne({ id });
438
+ await model_instance.make_default(!req.body.enabled);
439
+ res.redirect(`/models/show/${model_instance.model_id}`);
440
+ })
441
+ );
442
+
443
+ const encode = (s) =>
444
+ s.replace(
445
+ //https://stackoverflow.com/a/57448862/19839414
446
+ /[&<>'"]/g,
447
+ (tag) =>
448
+ ({
449
+ "&": "&amp;",
450
+ "<": "&lt;",
451
+ ">": "&gt;",
452
+ "'": "&#39;",
453
+ '"': "&quot;",
454
+ }[tag])
455
+ );
456
+
457
+ router.get(
458
+ "/show-report/:id",
459
+ isAdmin,
460
+ error_catcher(async (req, res) => {
461
+ const { id } = req.params;
462
+ const model_instance = await ModelInstance.findOne({ id });
463
+ const model = await Model.findOne({ id: model_instance.model_id });
464
+ const table = Table.findOne({ id: model.table_id });
465
+ res.sendWrap(req.__(`Train model`), {
466
+ above: [
467
+ {
468
+ type: "breadcrumbs",
469
+ crumbs: [
470
+ { text: req.__("Tables"), href: "/table" },
471
+ { href: `/table/${table.id}`, text: table.name },
472
+ { href: `/models/show/${model.id}`, text: model.name },
473
+ { text: model_instance.name },
474
+ { text: req.__(`Report`) },
475
+ ],
476
+ },
477
+ {
478
+ type: "card",
479
+ class: "mt-0",
480
+ title: req.__(`Model training report`),
481
+ contents:
482
+ iframe({
483
+ id: "trainreport",
484
+ width: "100%",
485
+ height: "100vh",
486
+ srcdoc: encode(model_instance.report),
487
+ }) + style(`iframe#trainreport { height: 100vh}`),
488
+ },
489
+ ],
490
+ });
491
+ })
492
+ );
package/routes/page.js CHANGED
@@ -7,6 +7,7 @@
7
7
  const Router = require("express-promise-router");
8
8
 
9
9
  const Page = require("@saltcorn/data/models/page");
10
+ const Trigger = require("@saltcorn/data/models/trigger");
10
11
  const { getState } = require("@saltcorn/data/db/state");
11
12
  const {
12
13
  error_catcher,
@@ -40,18 +41,21 @@ router.get(
40
41
  const { pagename } = req.params;
41
42
  const state = getState();
42
43
  state.log(3, `Route /page/${pagename} user=${req.user?.id}`);
43
- const tic = state.logLevel >= 5 ? new Date() : null;
44
+ const tic = new Date();
44
45
 
45
46
  const role = req.user && req.user.id ? req.user.role_id : 100;
46
47
  const db_page = await Page.findOne({ name: pagename });
47
48
  if (db_page && role <= db_page.min_role) {
48
49
  const contents = await db_page.run(req.query, { res, req });
49
50
  const title = scan_for_page_title(contents, db_page.title);
50
- if (tic) {
51
- const tock = new Date();
52
- const ms = tock.getTime() - tic.getTime();
53
- state.log(5, `Page ${pagename} rendered in ${ms} ms`);
54
- }
51
+ const tock = new Date();
52
+ const ms = tock.getTime() - tic.getTime();
53
+ Trigger.emitEvent("PageLoad", null, req.user, {
54
+ text: req.__("Page '%s' was loaded", pagename),
55
+ type: "page",
56
+ name: pagename,
57
+ render_time: ms,
58
+ });
55
59
  res.sendWrap(
56
60
  {
57
61
  title,
package/routes/tables.js CHANGED
@@ -11,6 +11,7 @@ const Table = require("@saltcorn/data/models/table");
11
11
  const File = require("@saltcorn/data/models/file");
12
12
  const View = require("@saltcorn/data/models/view");
13
13
  const User = require("@saltcorn/data/models/user");
14
+ const Model = require("@saltcorn/data/models/model");
14
15
  const Trigger = require("@saltcorn/data/models/trigger");
15
16
  const {
16
17
  mkTable,
@@ -54,7 +55,7 @@ const {
54
55
  } = require("@saltcorn/data/models/discovery");
55
56
  const { getState } = require("@saltcorn/data/db/state");
56
57
  const { cardHeaderTabs } = require("@saltcorn/markup/layout_utils");
57
- const { tablesList } = require("./common_lists");
58
+ const { tablesList, viewsList } = require("./common_lists");
58
59
  const {
59
60
  InvalidConfiguration,
60
61
  removeAllWhiteSpace,
@@ -654,7 +655,14 @@ const attribBadges = (f) => {
654
655
  let s = "";
655
656
  if (f.attributes) {
656
657
  Object.entries(f.attributes).forEach(([k, v]) => {
657
- if (["summary_field", "on_delete_cascade", "on_delete"].includes(k))
658
+ if (
659
+ [
660
+ "summary_field",
661
+ "on_delete_cascade",
662
+ "on_delete",
663
+ "unique_error_msg",
664
+ ].includes(k)
665
+ )
658
666
  return;
659
667
  if (v || v === 0) s += badge("secondary", k);
660
668
  });
@@ -785,37 +793,10 @@ router.get(
785
793
  );
786
794
  var viewCardContents;
787
795
  if (views.length > 0) {
788
- viewCardContents = mkTable(
789
- [
790
- {
791
- label: req.__("Name"),
792
- key: (r) => link(`/view/${encodeURIComponent(r.name)}`, r.name),
793
- },
794
- { label: req.__("Pattern"), key: "viewtemplate" },
795
- {
796
- label: req.__("Configure"),
797
- key: (r) =>
798
- link(
799
- `/viewedit/config/${encodeURIComponent(
800
- r.name
801
- )}?on_done_redirect=${encodeURIComponent(
802
- `table/${table.name}`
803
- )}`,
804
- req.__("Configure")
805
- ),
806
- },
807
- {
808
- label: req.__("Delete"),
809
- key: (r) =>
810
- post_delete_btn(
811
- `/viewedit/delete/${encodeURIComponent(r.id)}`,
812
- req
813
- ),
814
- },
815
- ],
816
- views,
817
- { hover: true }
818
- );
796
+ viewCardContents = await viewsList(views, req, {
797
+ on_done_redirect: encodeURIComponent(`table/${table.name}`),
798
+ notable: true,
799
+ });
819
800
  } else {
820
801
  viewCardContents = div(
821
802
  h4(req.__("No views defined")),
@@ -838,6 +819,33 @@ router.get(
838
819
  ),
839
820
  };
840
821
  }
822
+ const models = await Model.find({ table_id: table.id });
823
+ const modelCard = div(
824
+ mkTable(
825
+ [
826
+ {
827
+ label: req.__("Name"),
828
+ key: (r) => link(`/models/show/${r.id}`, r.name),
829
+ },
830
+ { label: req.__("Pattern"), key: "modelpattern" },
831
+ {
832
+ label: req.__("Delete"),
833
+ key: (r) =>
834
+ post_delete_btn(
835
+ `/models/delete/${encodeURIComponent(r.id)}`,
836
+ req
837
+ ),
838
+ },
839
+ ],
840
+ models
841
+ ),
842
+ a(
843
+ { href: `/models/new/${table.id}`, class: "btn btn-primary" },
844
+ i({ class: "fas fa-plus-square me-1" }),
845
+ req.__("Create model")
846
+ )
847
+ );
848
+
841
849
  // Table Data card
842
850
  const dataCard = div(
843
851
  { class: "d-flex text-center" },
@@ -997,6 +1005,15 @@ router.get(
997
1005
  titleAjaxIndicator: true,
998
1006
  contents: renderForm(tblForm, req.csrfToken()),
999
1007
  },
1008
+ ...(Model.has_templates
1009
+ ? [
1010
+ {
1011
+ type: "card",
1012
+ title: req.__("Models"),
1013
+ contents: modelCard,
1014
+ },
1015
+ ]
1016
+ : []),
1000
1017
  ],
1001
1018
  });
1002
1019
  })
@@ -1389,11 +1406,19 @@ const constraintForm = (req, table_id, fields, type) => {
1389
1406
  blurb: req.__(
1390
1407
  "Tick the boxes for the fields that should be jointly unique"
1391
1408
  ),
1392
- fields: fields.map((f) => ({
1393
- name: f.name,
1394
- label: f.label,
1395
- type: "Bool",
1396
- })),
1409
+ fields: [
1410
+ ...fields.map((f) => ({
1411
+ name: f.name,
1412
+ label: f.label,
1413
+ type: "Bool",
1414
+ })),
1415
+ {
1416
+ name: "errormsg",
1417
+ label: "Error message",
1418
+ sublabel: "Shown the user if joint uniqueness is violated",
1419
+ type: "String",
1420
+ },
1421
+ ],
1397
1422
  });
1398
1423
  case "Index":
1399
1424
  return new Form({
@@ -1485,11 +1510,12 @@ router.post(
1485
1510
  if (form.hasErrors) req.flash("error", req.__("An error occurred"));
1486
1511
  else {
1487
1512
  let configuration = {};
1488
- if (type === "Unique")
1513
+ if (type === "Unique") {
1489
1514
  configuration.fields = fields
1490
1515
  .map((f) => f.name)
1491
1516
  .filter((f) => form.values[f]);
1492
- else configuration = form.values;
1517
+ configuration.errormsg = form.values.errormsg;
1518
+ } else configuration = form.values;
1493
1519
  await TableConstraint.create({
1494
1520
  table_id: table.id,
1495
1521
  type,
@@ -1873,7 +1899,7 @@ const get_provider_workflow = (table, req) => {
1873
1899
 
1874
1900
  return {
1875
1901
  redirect: `/table/${table.id}`,
1876
- flash: ["success", `Table ${this.name || ""} saved`],
1902
+ flash: ["success", `Table ${table.name || ""} saved`],
1877
1903
  };
1878
1904
  };
1879
1905
  return workflow;