labimotion 2.4.0.rc3 → 2.4.0.rc4

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +60 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +50 -6
  6. data/lib/labimotion/apis/klass_share_api.rb +646 -0
  7. data/lib/labimotion/apis/labimotion_api.rb +1 -0
  8. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  9. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  10. data/lib/labimotion/apis/segment_api.rb +61 -10
  11. data/lib/labimotion/constants.rb +14 -0
  12. data/lib/labimotion/entities/application_entity.rb +8 -0
  13. data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
  14. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  15. data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
  16. data/lib/labimotion/helpers/element_helpers.rb +206 -4
  17. data/lib/labimotion/helpers/generic_helpers.rb +276 -4
  18. data/lib/labimotion/helpers/param_helpers.rb +68 -0
  19. data/lib/labimotion/helpers/segment_helpers.rb +87 -4
  20. data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
  21. data/lib/labimotion/libs/ai_template.rb +1482 -0
  22. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  23. data/lib/labimotion/libs/file_extractor.rb +210 -0
  24. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  25. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  26. data/lib/labimotion/libs/share_notifier.rb +114 -0
  27. data/lib/labimotion/libs/share_resolver.rb +369 -0
  28. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  29. data/lib/labimotion/models/dataset_klass.rb +1 -1
  30. data/lib/labimotion/models/element_klass.rb +1 -1
  31. data/lib/labimotion/models/klass_share.rb +126 -0
  32. data/lib/labimotion/models/segment_klass.rb +1 -1
  33. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  34. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  35. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  36. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  37. data/lib/labimotion/version.rb +1 -1
  38. data/lib/labimotion.rb +11 -0
  39. metadata +28 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c40bb5efbdc756295f36c7a5a9b424639565f38912c7ef200151d124152fd30
4
- data.tar.gz: 935efaab787fc3c8d1c0aee4d11ca8206def39e244249ba6c9161b3e16088d89
3
+ metadata.gz: 1babeffe4e43eb088d5ab781101aa48574f67aab9abf0d32df4d883742e63788
4
+ data.tar.gz: 8c063d368a6af316e5397804951da314d8fc65035a852f56b066097091b0b2b3
5
5
  SHA512:
6
- metadata.gz: e01cbe9c498ea9bc82515c4aea85fa50e131b523fd3564d91f5f962d5b9fdd1175b197f5b0663f6e38c98e7c8d5ab5afe14d48f63c764c0b1d9ee22423dc1e9e
7
- data.tar.gz: d08a91299b3c202684bfe1ab222bfe1d5765c41664c731285848ba6b03489031ea7824b50f96cc1059e8c207eb1de488a06e745f0d77e51ceda318afedf941ef
6
+ metadata.gz: 5d92879758e133fbf40fd433882967c8731ebf18e1e2586d8026a65b2d085359a530333cd740d00b7b0359981f155c034eee5687ffe326bb842c2dbc1ee52ad8
7
+ data.tar.gz: 3db447e733afbe176b1d7adb80078ef85899b473336e882d41bb9f7da4b66b51ca91d1f4084d30a0f644401e95c4312669bd1f9a6d7bc861af42dc8ad89d1abd
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
  * Features and enhancements:
5
+ * Share notifications now fire from the gem itself ([chem-generic-ui#1040](https://github.com/LabIMotion/chem-generic-ui/issues/1040), labimotion#255): a successful grant or level change (`POST /generic_klass/shares`), an ownership transfer (`POST /generic_klass/transfer_ownership`) and the ask that creates an access-request row (`POST /generic_klass/request_access`, `created: true` only — repeat asks are not repeat pings) each send the toast the host's `Chemotion::TemplateShareAPI` used to send, through the new `Labimotion::ShareNotifier`. Recipients are Persons only, never the actor; the channel is looked up by the literal subject `'Template Share Notification'` (the host ELN's channel migration stays required for delivery); and the notifier rescues internally — an undeliverable notification is a notification not sent, never a failed share. A host without Message/Channel machinery (or without the channel row) silently sends nothing. The level-change route (`PUT /shares/:id`) stays silent, matching the host layer it replaces. Until the matching ELN change removes `TemplateShareAPI` and its caller, grants on the integration branches notify twice — accepted, dev-only.
6
+ * Per-template ownership and sharing, backend ([chem-generic-ui#1001](https://github.com/LabIMotion/chem-generic-ui/issues/1001) Phase 2): `klass_shares` table (viewer/editor/owner, one owner per template enforced by a partial unique index), `authorize_klass!` per-template gate replacing the designer-wide check on template mutation endpoints (owner-less templates keep the legacy behaviour permanently), share management endpoints under `/generic_klass/shares` plus `/generic_klass/transfer_ownership`, and capability booleans (`can_write` … `can_manage`) on the Designer list payloads. `download_klass` is now gated (`:read`) and no longer writes an identifier during a GET. Requires host ELN twin migrations `create_klass_shares` and `seed_klass_share_owners` (the latter seeds owner rows from `created_by` in raw SQL, idempotent via `ON CONFLICT DO NOTHING`); deploy order is migrate → **restart** (the stale schema cache otherwise silently drops the running app's writes to the new table). Ships as one upgrade with the matching chem-generic-ui and ELN changes — see the design doc's ADR 0001.
7
+ * `created_by` and `created_at` are now `NOT NULL` on `element_klasses` and `segment_klasses` ([chem-generic-ui#1018](https://github.com/LabIMotion/chem-generic-ui/issues/1018)); `dataset_klasses` already declared both. Two new migrations, back-dated ahead of `create_klass_shares`: `backfill_klass_created_columns` fills a NULL `created_by` with the first system administrator (falling back to the lowest-id user of any type, and raising if the `users` table is empty) and a NULL `created_at` with `COALESCE(updated_at, NOW())`; `tighten_klass_created_columns` then applies the constraints — data before constraint. The back-dating is what lets `seed_klass_share_owners` stay unchanged: with `created_by` already filled in, its `WHERE created_by IS NOT NULL` filter excludes nothing and every template gets an owner row instead of staying owner-less on the permanent legacy gate. The backfill re-runs that owner seed itself (`ON CONFLICT DO NOTHING`) so a database on which the seed had already run picks up the repaired rows. Requires host ELN twin migrations for both, back-dated the same way; deploy order is migrate → **restart** (the stale schema cache otherwise silently drops the running app's writes).
8
+ * Any individual account may own a template, and an owner who cannot be notified is now reported as such ([chem-generic-ui#1019](https://github.com/LabIMotion/chem-generic-ui/issues/1019)). `Labimotion::KlassShare` validates `shared_with_id` against an allowlist (`Person`, `Admin`) instead of `User.persons`, so the raw-SQL `seed_klass_share_owners` migration and the runtime `KlassShare.seed_owner!` reach the same verdict for an admin-created template: the migration used to write the owner row while the runtime path refused it and left the template owner-less on the permanent legacy gate, where any designer of the family may edit and delete it. `backfill_klass_created_columns` fills a NULL `created_by` with the first system administrator, so those templates exist in numbers. `Group` and `DeviceDeprecated` stay excluded — a group is not an individual, and ELN reserves the type for device management — and grants and ownership transfers through the API stay Person-only, since an admin owner is a state to clean up rather than one to create. `GenericKlassEntity#owner_state` gains a fourth value, `unnotifiable` (after `deleted` and `deactivated`), for an owner whose `type` is not `Person`: such a template read `active` and appeared in no banner, while a designer refused on it was told to ask an owner that ELN's `channel_type` 8 fan-out cannot reach, so `request_access` answered 422 and nothing said a transfer was needed.
9
+ * An access request is now stored as a `klass_shares` row rather than sent as a message ([chem-generic-ui#1023](https://github.com/LabIMotion/chem-generic-ui/issues/1023)). `Labimotion::KlassShare::LEVELS` gains `requested: 0` — additive, never a renumber, since the one-owner-per-klass partial index is `WHERE permission_level = 30` — so a level-0 row records the ask while granting nothing (`0 >= 10` is false, so every existing threshold refuses it unchanged). The last hop of the request loop used to land on the Designer, which polls no message list, so an owner working there never saw the request; as a row it survives acknowledgement, and the unique `(klass_type, klass_id, shared_with_id)` index dedupes repeat asks. New `POST /generic_klass/request_access?klass=&id=`, answering `{ mc: 'ss00', created: <Boolean> }`: self only, level 0 only, idempotent — no row creates one and answers `created: true` (the caller fires the host ping only then, so ten asks are not ten pings), an existing level-0 row answers `created: false` silently, and a caller who already holds viewer/editor/owner gets 409 `sc13`. A lost race on the unique index answers `created: false` rather than 500 — the row existing is the goal state. Gated on the legacy family designer right, not `authorize_klass!(:read)`: `:read` demands level ≥ 10 and a requester has none, so the share gate would refuse every caller the route exists for. Granting and rejecting need no new endpoint — `POST /generic_klass/shares` lifts the level-0 row to viewer/editor in place, and `DELETE /generic_klass/shares/:id` is the rejection (a hard delete, so "no" does not stick and the requester may ask again — accepted, since a tombstone would contradict `KlassShare` deliberately not being `acts_as_paranoid`). Every share route's body gains a `requests` key carrying the level-0 rows, which `shares` no longer contains: someone *waiting* for access listed under "People with access" says the opposite of what they are. `shares` keeps its contents and owner-first ordering for everything ≥ 10. Both lists go to anyone who may `:read` the template, so viewers and editors see others' pending requests — intended transparency. Klass list payloads gain `pending_request_count` (Integer, 0 when absent), derived from the share rows `ShareResolver.context_for` already loads — no extra query — and scoped to the requesting user's own owner rows, since a naive count would ship "somebody asked" to every editor and viewer in the payload. Exposed under the same `share_context` guard as the capability booleans, so exports, Hub submissions and un-migrated hosts are unaffected. No migration: `permission_level` already accepts 0. Note that `current_user_permission` now has a fourth answer, `'requested'` — a client must compare the level, not test the string for truthiness.
10
+ * The Designer's share poller now asks the gem instead of the host ([chem-generic-ui#1023](https://github.com/LabIMotion/chem-generic-ui/issues/1023)). New `GET /generic_klass/share_activity`, answering `{ mc: 'ss00', count: <Integer>, latest: <ISO8601 String|null> }`: how many `klass_shares` rows involve the caller — every row on the templates they own, plus the rows granting them access on other people's — and the newest `updated_at` among them. The pair is an opaque change token, and the client refreshes its grid when **either** value differs in **either** direction. Deliberately not a "newer than" cursor: a revocation is a hard delete (`KlassShare` is not `acts_as_paranoid`), so `latest` moves *backwards* while `count` drops — precisely the event a timestamp comparison would stop detecting. The library used to poll the host's `/api/v1/messages/list?is_ack=0` for this: its only reach outside gem-owned API surface, a route no embedder is obliged to mount, carrying every kind of notification with no way for the library to name the host's channels and filter it (an unrelated spectra message refreshed the Designer grid), and acknowledging message rows as a side effect of the read. Authentication only and no further gate — there is no single klass for `authorize_klass!` to take and no family for `authenticate_admin!`, and the response is two aggregates over the caller's *own* rows, naming no template, user or level and disclosing nothing about anybody else's sharing, while a gate would refuse the grantees the route exists for. Aggregates only, three indexed queries however large the account (the owned keys, then `COUNT` and `MAX` over one OR-ed scope), because an open Designer polls it about once a minute. The administrator is an implicit owner everywhere but holds rows only where they really own something, so their token moves for their own templates alone. No migration: `klass_shares` already carries `t.timestamps` and an index on `shared_with_id`.
11
+ * The share poller can now refresh individual template rows instead of reloading the whole Designer grid ([chem-generic-ui#1023](https://github.com/LabIMotion/chem-generic-ui/issues/1023)). Reloading the grid re-seeds the open Work Area and destroys unsaved template edits, and it fired even for the user's own grant or revoke; two reads replace it. First, `GET /generic_klass/share_activity` gains `digests`: `{ "ElementKlass:12": "<row count>|<newest updated_at, ISO8601 with milliseconds>", … }`, one entry per klass the caller is involved in — the same involvement `count` and `latest` already aggregate over, split by key, each value that klass's own count and `MAX(updated_at)`. The client diffs the map and re-reads only the keys whose digest changed, appeared or vanished. `count` and `latest` stay, unchanged, as the cheap early-out. The same inequality-in-either-direction rule applies and matters more here: a revocation is a hard delete, so a key's digest can move *backwards* or the key can drop out of the map entirely, and a "newer than" comparison would see neither. The digest string is opaque — compare it, never parse it. No extra query: the map is a `GROUP BY (klass_type, klass_id)` over the scope that was already being aggregated, and the pair is then derived from the groups rather than queried again (three indexed queries before and after), so `count` is the sum of the digests' counts by construction. Second, new `GET /generic_klass/rows?keys[]=ElementKlass:12&keys[]=SegmentKlass:3`, answering `{ mc: 'ss00', data: [<grid-shaped entity>, …], keys: [<the key of each entry>, …] }`. Each entry is exactly what the Designer list endpoints send for that record — same entity, `with_ownership: true`, preloaded `owners:` and a `share_context:` resolved over precisely the requested records — so a row can be dropped into a grid unmodified and `owner`, `can_write` … `can_manage`, `current_user_permission` and `pending_request_count` all keep meaning what they meant in the list. `GET /generic_klass/fetch` could not serve this: it represents with neither `share_context` nor `with_ownership`, and the library reads an absent capability field as permissive (that is how an un-upgraded gem stays usable), so a row patched from it would quietly turn a viewer's template editable. `keys` runs parallel to `data` because the klass entities carry no type field — an id alone cannot distinguish `ElementKlass:9` from `DatasetKlass:9` — and it is also how a client learns which requested keys were skipped. Batches are capped at 50 keys, counted before de-duplication, and refused with 400 `se00` rather than truncated, so a short answer never reads as "those templates were deleted"; malformed keys are refused the same way rather than guessed at; a key whose record no longer exists is skipped, since a template deleted between the poll and the read is an ordinary race. Grouped by klass type, so the cost is one query per type present in the batch, not one per key. Ungated, deliberately and like the list endpoints it mirrors — those list every template to every designer and let the *actions* gate — and what constrains the caller is inside the payload: the capability booleans are resolved for that user, and every mutating route still re-checks with `authorize_klass!`. No migration.
12
+ * The Designer now has a share inbox, derived entirely from `klass_shares` ([chem-generic-ui#1028](https://github.com/LabIMotion/chem-generic-ui/issues/1028)). An owner working in the Designer had no list of what was waiting for them, and a requester was never told they had been granted access: the request loop's last hop landed on a host message that only `NoticeButton` polls, mounted in the mydb topbar outside the Designer's React roots. New `GET /generic_klass/share_inbox`, answering `{ mc: 'ss00', requests: [{ share_id, klass_type, klass_id, klass_label, user: { id, name, name_abbreviation }, requested_at }, …], grants: [{ share_id, klass_type, klass_id, klass_label, permission, user: { … }, changed_at }, …] }`. **Both queues are queries over `klass_shares`, not a message log** — a second store would immediately have its own ack state, its own retention question, and the ability to disagree with the rows. `requests` is the owner's queue: the `permission_level = 0` rows on the templates the caller holds the level-30 row on, oldest ask first, `user` the requester and `requested_at` their `created_at`. No column gates it and none should — a grant promotes the row off level 0 and a reject hard-deletes it, so it empties itself, and it is the same predicate behind the grid's `ShareBtn` badge, so the two cannot disagree. `grants` is the requester's queue: `shared_with_id = caller AND permission_level >= 10 AND created_by <> shared_with_id AND (acked_at IS NULL OR acked_at < updated_at)`, newest change first, `user` the granter (`created_by`) and `changed_at` the `updated_at` that `acked_at` is compared against. `created_by <> shared_with_id` is load-bearing and looks removable: every owner row is a self-grant written by `KlassShare.seed_owner!`, so without it every designer would be told "you were granted owner on…" for every template they ever created; a real grant re-stamps `created_by` to the granter and so does a transfer, so both still announce. Entries whose template can no longer be resolved are dropped rather than sent with a null label — an entry exists to be clicked through to a grid row — and the share row survives, so a restore brings the entry back untouched. Labels and user names are resolved in bulk (one query per klass type present, one for the users), so the whole route is at most seven indexed queries however large the account; it is polled on the same tick as `share_activity`. Authentication only, no `authorize_klass!` and no family gate, for the same reason as `share_activity`: every row returned either names the caller or sits on a template they own, and a gate would refuse the grantees the route exists for.
13
+ * New `POST /generic_klass/shares/:share_id/ack`, answering `{ mc: 'ss00' }` ([chem-generic-ui#1028](https://github.com/LabIMotion/chem-generic-ui/issues/1028)). Self only — a row whose `shared_with_id` is not the caller is refused 404, the same answer an unknown id gets, so a row that does not name you is indistinguishable from one that does not exist; holding `:manage` on the template does not make somebody else's grant yours to acknowledge. Idempotent. Written with `update_column`, deliberately: `updated_at` is the other half of the unacknowledged comparison, so an ack that bumped it would leave the row announcing itself for ever, and it would also move `share_activity`'s change token and make every other Designer session re-read the grid row. `acked_at` is a nullable datetime carried by `create_klass_shares` itself rather than a separate ALTER migration — one file per table while nothing is released — so a database that already ran that version will never receive the column and must be dropped and recreated rather than migrated again. Nullable because every row standing at upgrade time is unacknowledged by definition and a backfill would silently swallow it. The ELN twin of `create_klass_shares` carries the same change, byte-identical. Deploy order is migrate → **restart** (the stale schema cache otherwise silently drops the running app's writes to the new column). The library never reads ELN's notifications and never acks them: `NoticeButton`'s read marker and `acked_at` are deliberately independent, answering different questions in different places.
5
14
  * Added per-user, per-klass toolbar configuration (Overview/Arrange visibility) via `POST /user_klass_settings/save` (upsert) and `GET /user_klass_settings/fetch` ([chem-generic-ui#905](https://github.com/LabIMotion/chem-generic-ui/issues/905)). Each user stores their own `settings` (`{ toolbar: { overview, arrange } }`) per element/segment/dataset klass, keyed by the stable klass identifier so it applies to every instance of that klass across template versions. Requires host ELN twin migration `create_user_klass_settings`.
6
15
  * Designer can enable/disable common layer functionalities (record time, add reaction, add/remove layer) per template; template-scoped and independent of template versions ([chem-generic-ui#904](https://github.com/LabIMotion/chem-generic-ui/issues/904)). Saved segments expose the effective klass `settings` so records honor the toggles after a reload. Requires host ELN twin migration `add_settings_to_klasses`.
7
16
  * Support linking CellLine elements in the generic Link Element via the `Labimotion::CellLine` wrapper ([chem-generic-ui#906](https://github.com/LabIMotion/chem-generic-ui/issues/906)). Requires a host ELN `element_klasses` row for `cell_line` (migration) — see PR notes.
@@ -7,6 +7,7 @@ module Labimotion
7
7
 
8
8
  helpers Labimotion::GenericHelpers
9
9
  helpers Labimotion::DatasetHelpers
10
+ helpers Labimotion::ParamHelpers
10
11
 
11
12
  resource :generic_dataset do
12
13
  namespace :klasses do
@@ -26,7 +27,10 @@ module Labimotion
26
27
  get do
27
28
  list = klass_list(params[:is_active], params[:displayed_in_list])
28
29
  serialized_data = Labimotion::DatasetKlassEntity.represent(list,
29
- displayed_in_list: params[:displayed_in_list])
30
+ displayed_in_list: params[:displayed_in_list],
31
+ with_ownership: true,
32
+ owners: Labimotion::OwnerResolver.map_for(list),
33
+ share_context: Labimotion::ShareResolver.context_for(list, current_user))
30
34
  { mc: 'ss00', data: serialized_data }
31
35
  rescue StandardError => e
32
36
  Labimotion.log_exception(e, current_user)
@@ -34,7 +38,8 @@ module Labimotion
34
38
  end
35
39
  end
36
40
 
37
- # Deprecated: This namespace is no longer used, but kept for backward compatibility.
41
+ # Deprecated as an endpoint, but it is what the Dataset Designer grid still calls
42
+ # (eln GenericDSsFetcher.listDatasetKlass), so ownership has to be exposed here too.
38
43
  # It is replaced by `list_klass`.
39
44
  namespace :list_dataset_klass do
40
45
  desc 'list Generic Dataset Klass'
@@ -43,7 +48,9 @@ module Labimotion
43
48
  end
44
49
  get do
45
50
  list = klass_list(params[:is_active], false)
46
- present list, with: Labimotion::DatasetKlassEntity, root: 'klass', displayed_in_list: false
51
+ present list, with: Labimotion::DatasetKlassEntity, root: 'klass', displayed_in_list: false,
52
+ with_ownership: true, owners: Labimotion::OwnerResolver.map_for(list),
53
+ share_context: Labimotion::ShareResolver.context_for(list, current_user)
47
54
  end
48
55
  end
49
56
 
@@ -60,6 +67,9 @@ module Labimotion
60
67
  requires :identifier, type: String, desc: 'Identifier'
61
68
  end
62
69
  post do
70
+ # Importing needs the global create right; upgrading an existing template is gated
71
+ # owner-only inside the helper's update branch.
72
+ authenticate_admin!(Labimotion::Constants::Family::DATASET)
63
73
  msg = create_repo_klass(params, current_user, request.headers['Origin'])
64
74
  klass = Labimotion::DatasetKlassEntity.represent(Labimotion::DatasetKlass.all)
65
75
  { status: msg[:status], message: msg[:message], klass: klass }
@@ -69,6 +79,34 @@ module Labimotion
69
79
  end
70
80
  end
71
81
 
82
+ namespace :create_ai_klass do
83
+ desc 'create a Generic Dataset Klass from an AI-generated template'
84
+ params do
85
+ use :create_ai_dataset_klass_params
86
+ end
87
+ post do
88
+ msg = create_ai_dataset_klass(params, current_user)
89
+ klass = Labimotion::DatasetKlassEntity.represent(Labimotion::DatasetKlass.all)
90
+ { status: msg[:status], message: msg[:message], klass: klass }
91
+ rescue StandardError => e
92
+ Labimotion.log_exception(e, current_user)
93
+ { error: e.message }
94
+ end
95
+ end
96
+
97
+ namespace :refine_ai_klass do
98
+ desc 'refine a Generic Dataset Klass template with AI (returns the revised template; not persisted)'
99
+ params do
100
+ use :refine_ai_dataset_klass_params
101
+ end
102
+ post do
103
+ refine_ai_dataset_klass(params, current_user)
104
+ rescue StandardError => e
105
+ Labimotion.log_exception(e, current_user)
106
+ { status: 'error', message: e.message }
107
+ end
108
+ end
109
+
72
110
  namespace :find_template do
73
111
  desc 'Find best matching template for given OLS term ID'
74
112
  params do
@@ -178,7 +178,7 @@ module Labimotion
178
178
  use :create_element_klass_params
179
179
  end
180
180
  post do
181
- authenticate_admin!('elements')
181
+ authenticate_admin!(Labimotion::Constants::Family::ELEMENT)
182
182
  create_element_klass(current_user, params)
183
183
  status 201
184
184
  rescue ActiveRecord::RecordInvalid => e
@@ -186,13 +186,50 @@ module Labimotion
186
186
  end
187
187
  end
188
188
 
189
+ namespace :create_ai_klass do
190
+ desc 'create a Generic Element Klass from an AI-generated template'
191
+ params do
192
+ use :create_ai_element_klass_params
193
+ end
194
+ post do
195
+ authenticate_admin!('elements')
196
+ msg = create_ai_element_klass(params, current_user)
197
+ { status: msg[:status], message: msg[:message],
198
+ klass: Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.where(is_active: true)) }
199
+ rescue StandardError => e
200
+ Labimotion.log_exception(e, current_user)
201
+ { error: e.message }
202
+ end
203
+ end
204
+
205
+ # Sibling namespace of :create_ai_klass — MUST stay above the route_param :id
206
+ # blocks below, otherwise POST /ai_fill_data falls through to the element
207
+ # instance route and Element.find(nil) is raised.
208
+ namespace :ai_fill_data do
209
+ desc "Auto-fill a generic element's data values from a document using AI"
210
+ params do
211
+ use :ai_fill_element_data_params
212
+ end
213
+ post do
214
+ msg = ai_fill_element_data(params, current_user)
215
+ if msg[:status] == 'success'
216
+ { status: 'success', values: msg[:values], summary: msg[:summary] }
217
+ else
218
+ { status: 'error', message: msg[:message] }
219
+ end
220
+ rescue StandardError => e
221
+ Labimotion.log_exception(e, current_user)
222
+ { status: 'error', message: e.message }
223
+ end
224
+ end
225
+
189
226
  namespace :update_element_klass do
190
227
  desc 'update Generic Element Klass'
191
228
  params do
192
229
  use :update_element_klass_params
193
230
  end
194
231
  post do
195
- authenticate_admin!('elements')
232
+ # authorize_klass!(:write) runs inside the helper, on the loaded record.
196
233
  update_element_klass(current_user, params)
197
234
  rescue StandardError => e
198
235
  Labimotion.log_exception(e, current_user)
@@ -239,7 +276,7 @@ module Labimotion
239
276
  requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass]
240
277
  end
241
278
  post do
242
- authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
279
+ # authorize_klass!(:destroy) runs inside the helper, once the klass row is loaded.
243
280
  delete_klass_revision(params)
244
281
  status 201
245
282
  rescue StandardError => e
@@ -319,19 +356,26 @@ module Labimotion
319
356
  scope = scope.where(is_active: params[:is_active]) if params.key?(:is_active)
320
357
 
321
358
  list = scope.sort_by(&:place)
322
- present list, with: Labimotion::ElementKlassEntity, root: 'klass', displayed_in_list: params[:displayed_in_list]
359
+ present list, with: Labimotion::ElementKlassEntity, root: 'klass',
360
+ displayed_in_list: params[:displayed_in_list],
361
+ with_ownership: true, owners: Labimotion::OwnerResolver.map_for(list),
362
+ share_context: Labimotion::ShareResolver.context_for(list, current_user)
323
363
  rescue StandardError => e
324
364
  Labimotion.log_exception(e, current_user)
325
365
  raise e
326
366
  end
327
367
  end
328
368
 
329
- # Deprecated: This namespace is no longer used, but kept for backward compatibility.
369
+ # Deprecated as an endpoint, but it is what the Element Designer grid still calls
370
+ # (eln GenericElsFetcher.fetchElementKlasses), so ownership has to be exposed here too —
371
+ # adding `created_by` to `for_list_display` alone would never reach that grid.
330
372
  namespace :klasses_all do
331
373
  desc 'get all klasses for admin function'
332
374
  get do
333
375
  list = Labimotion::ElementKlass.all.sort_by { |e| e.place }
334
- present list, with: Labimotion::ElementKlassEntity, root: 'klass'
376
+ present list, with: Labimotion::ElementKlassEntity, root: 'klass',
377
+ with_ownership: true, owners: Labimotion::OwnerResolver.map_for(list),
378
+ share_context: Labimotion::ShareResolver.context_for(list, current_user)
335
379
  rescue StandardError => e
336
380
  Labimotion.log_exception(e, current_user)
337
381
  []
@@ -354,6 +398,9 @@ module Labimotion
354
398
  requires :identifier, type: String, desc: 'Identifier'
355
399
  end
356
400
  post do
401
+ # Importing needs the global create right; upgrading an existing template is gated
402
+ # owner-only inside validate_klass, where the create/update branch is decided.
403
+ authenticate_admin!(Labimotion::Constants::Family::ELEMENT)
357
404
  msg = create_repo_klass(params, current_user, request.headers['Origin'])
358
405
  klass = Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.all)
359
406
  { status: msg[:status], message: msg[:message], klass: klass }
@@ -371,7 +418,8 @@ module Labimotion
371
418
  requires :is_active, type: Boolean, desc: 'Active or Inactive Klass'
372
419
  end
373
420
  after_validation do
374
- authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
421
+ # The share gate needs the loaded record, so it runs inside the helper
422
+ # (authorize_klass!(:deactivate)) rather than up here where only params exist.
375
423
  fetch_klass(params[:klass], params[:id])
376
424
  end
377
425
  post do
@@ -407,7 +455,8 @@ module Labimotion
407
455
  optional :release, type: String, default: 'draft', desc: 'release status', values: %w[draft major minor patch]
408
456
  end
409
457
  after_validation do
410
- authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
458
+ # authorize_klass! (:write for a draft, :release otherwise) runs inside
459
+ # update_template, on the loaded record.
411
460
  fetch_klass(params[:klass], params[:id])
412
461
  end
413
462
  post do
@@ -424,6 +473,9 @@ module Labimotion
424
473
  use :upload_element_klass_params
425
474
  end
426
475
  post do
476
+ # Same split as create_repo_klass: create right here, owner-only upgrade inside
477
+ # validate_klass. This route previously had no gate at all.
478
+ authenticate_admin!(Labimotion::Constants::Family::ELEMENT)
427
479
  declared_params = declared(params, include_missing: false)
428
480
  attributes = declared_params.merge(
429
481
  created_by: current_user.id,
@@ -17,15 +17,18 @@ module Labimotion
17
17
  requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
18
18
  end
19
19
  get do
20
- entity = "Labimotion::#{params[:klass]}".constantize.find_by(id: params[:id])
21
- entity.update_columns(identifier: SecureRandom.uuid) if entity&.identifier.nil?
20
+ # This export previously accepted any logged-in account (no admin gate at all) and,
21
+ # worse, wrote through update_columns inside a GET — minting the identifier that Hub
22
+ # sync keys on, for whoever happened to press download first. Gated :read now, and a
23
+ # template with no identifier simply exports without one; minting stays with the
24
+ # write paths.
25
+ entity = fetch_klass(params[:klass], params[:id])
26
+ authorize_klass!(entity, :read)
22
27
  env['api.format'] = :binary
23
28
  content_type('application/json')
24
29
  filename = CGI.escape("LabIMotion_#{params[:klass]}_#{entity.label}-#{Time.new.strftime("%Y%m%d%H%M%S")}.json")
25
- # header['Content-Disposition'] = "attachment; filename=abc.docx"
26
30
  header('Content-Disposition', "attachment; filename=\"#{filename}\"")
27
31
  "Labimotion::#{params[:klass]}Entity".constantize.represent(entity)
28
- # klass.as_json
29
32
  rescue StandardError => e
30
33
  Labimotion.log_exception(e, current_user)
31
34
  {}
@@ -40,7 +43,7 @@ module Labimotion
40
43
  requires :is_active, type: Boolean, desc: 'Active or Inactive Klass'
41
44
  end
42
45
  after_validation do
43
- authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
46
+ # authorize_klass!(:deactivate) runs inside the helper, on the loaded record.
44
47
  fetch_klass(params[:klass], params[:id])
45
48
  end
46
49
  post do
@@ -62,7 +65,7 @@ module Labimotion
62
65
  requires :settings, type: Hash, desc: 'Klass settings'
63
66
  end
64
67
  after_validation do
65
- authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
68
+ # authorize_klass!(:write) runs inside the helper, on the loaded record.
66
69
  fetch_klass(params[:klass], params[:id])
67
70
  end
68
71
  post do
@@ -76,6 +79,47 @@ module Labimotion
76
79
  end
77
80
  end
78
81
 
82
+ namespace :deleted_klasses do
83
+ desc 'list the soft-deleted Generic Klasses of one type'
84
+ params do
85
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
86
+ end
87
+ after_validation do
88
+ authenticate_admin!(Labimotion::Constants::Family::FAMILY_OF[params[:klass]])
89
+ end
90
+ get do
91
+ list = deleted_klasses(params)
92
+ entity_class = "Labimotion::#{params[:klass]}Entity".constantize
93
+ # `deleted_at` is not on the klass entities (a sibling PR owns those files), and a
94
+ # restore list that cannot say when a template was deleted is not much of a list.
95
+ serialized = list.map do |klz|
96
+ entity_class.represent(klz).as_json.merge(deleted_at: klz.deleted_at&.iso8601)
97
+ end
98
+ { klass: serialized }
99
+ rescue StandardError => e
100
+ Labimotion.log_exception(e, current_user)
101
+ { mc: 'se00', msg: e.message, klass: [] }
102
+ end
103
+ end
104
+
105
+ namespace :restore_klass do
106
+ desc 'restore a soft-deleted Generic Klass'
107
+ params do
108
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
109
+ requires :id, type: Integer, desc: 'Klass ID'
110
+ end
111
+ # authorize_klass!(:destroy) runs inside the helper, on the loaded deleted record —
112
+ # shares survive deletion, so the owner row is still there to gate the restore.
113
+ post do
114
+ restored_klass = restore_klass(params)
115
+ entity_class = "Labimotion::#{params[:klass]}Entity".constantize
116
+ { mc: 'ss00', data: entity_class.represent(restored_klass) }
117
+ rescue StandardError => e
118
+ Labimotion.log_exception(e, current_user)
119
+ { mc: 'se00', msg: e.message, data: {} }
120
+ end
121
+ end
122
+
79
123
  namespace :fetch do
80
124
  desc 'fetch Generic Klass by id'
81
125
  params do