labimotion 2.3.0 → 2.4.0.rc11

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. metadata +45 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae2d21e32163f9242bb0228e21a9d691bf02873463b6165a5f1e8ad204393c32
4
- data.tar.gz: 1da451215fba4042ea72906285df462080d212a31017d55a3f0d9d43c970b91b
3
+ metadata.gz: cc8a97c07b6329e1a6be6097c923d710101dfe03494e98463ab2795cea02a590
4
+ data.tar.gz: 7d5535979919fe5709feed7a00860ba6ac24c6c69999e3b1e9f0c03819b3268e
5
5
  SHA512:
6
- metadata.gz: a5823f0eab2c94c8a76c1436f5596d0040c6931cdf486ad707456864ba1750efbc6e5897ebb4c137cba89cc1bfba349c7d9e7749d4c7b3c64b74c4115067b77e
7
- data.tar.gz: 62f356dfedee9fff2776797201fc1f72cc3aadaef9bbff9fd93b4d402c45367ab8f04f7a5c3b842ffce7d89eff25d47bdc28164b79688941fbf492b3faa8b6c4
6
+ metadata.gz: 79b2acc5223830bb6c4444342173b80af2ad4611f095e07cfcc89861473cf5f2e2854b0ed67e098b8c9bd94fe9285178f06fb20c7ae84729d5938d567a490531
7
+ data.tar.gz: b657890bf6c80d13283ba1936678c2c364308e80ba0cf01b51f36d129012873c47b2a9393724d2154307ba1ee267708a00992a49038428c3abe8160ef607eeb2
data/CHANGELOG.md CHANGED
@@ -2,13 +2,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
  * Features and enhancements:
5
+ * The AI whitelist reading moves into the gem as `Labimotion::MatriceLabimotion`, a concern the host mixes into whichever model holds its UI feature configs (`Matrice` in the Chemotion ELN). Who is allowed stays the host's decision and the host's data — an admin edits `ai_uids` / `fill_uids` on the UI Features page — but what the answer means is LabIMotion's: which generic types have an AI integration (`AI_FEATURES`), that auto-fill is a sub-gate on `ai_uids` rather than a list of its own, and that every step is closed by default, so a missing feature row, a missing key, an empty list or a malformed value all resolve to nobody. Supplies `.ai_uids`, `.fill_uids`, `.ai_enabled_for?`, `.ai_features_for` and `.ai_enabled_for_any?`; the uid reader behind them stays private to the including model. A host needs two things for it to work: the including model answers `find_by(name:)` with a record exposing a `configs` hash, and its user answers `matrix_check_by_name(name)` with whether that feature is visible to them at all. This also settles where `Labimotion::LabimotionAiAPI`'s own `Matrice.ai_enabled_for_any?` gate comes from — the method is now defined by this gem rather than borrowed from whatever the host happened to implement. It also absorbs the auto-fill gate the host used to own as a `LabimotionAiAccess` service: `.fill_enabled_for?(user)` is `ai_uids` AND `fill_uids` on `FILL_FEATURE` (genericElement, the only feature carrying such a list — the designer's "New (AI)" buttons on the other two types are admin-only and need no second gate), so the gem's `ai_fill_data` route and the host's own `aiFill` flag answer through one method and cannot disagree about who may auto-fill. That drops the gem from two host constants to one: it no longer reaches for a `LabimotionAiAccess` a host had to implement, only for the model carrying this concern. Host ELN change is the `include`, the deletion of the moved methods and of that service; **the host will not boot without a gem carrying this concern**, since the model that includes it is loaded app-wide.
6
+ * Per-user settings now live in a gem-owned `user_settings` table instead of the host's profile jsonb, starting with the AI settings that were the only occupant. One row per `(user_id, key)`, each with its own `settings` jsonb and a unique index on the pair — `Labimotion::UserSetting`, keys enumerated in `Constants::UserSettingKey` (`ai` today, `my_layers` next). Split by row rather than one blob per user on purpose: the keys have nothing to do with each other and differ wildly in size, so a shared row would mean every save rewrites the lot and two features saving at once would clobber each other. `Labimotion::ProfileAiSettings` — the concern the host mixed into `Profile` — is **removed** and replaced by `Labimotion::UserAiSettings`, a wrapper over the `ai` row with the same field semantics it always had (nil leaves a field unchanged, a blank string clears it, the api_key is stored encrypted and only ever reported as set-or-not) and an injectable store so the shape can be exercised without a database. Encryption stays the host's `Encryptor`, unchanged, so stored ciphertext keeps decrypting. `Constants::UserSetting` is renamed `Constants::UserKlassSetting` — its members are the sub-keys of the per-klass table and the old name would now read as the new one's. Requires host ELN twin migrations `create_user_settings` and `move_labimotion_ai_settings_to_user_settings`; the latter copies each profile's `labimotion_ai` object verbatim (ciphertext included — re-encrypting would be the only way to get it wrong) and then deletes the profile key, unlike the `ui_preference` consolidation which left an inert value behind: this one would otherwise leave a second copy of a credential in a column several endpoints serialize. It is reversible, so rolling the gem back does not strand a key in a table nothing reads. Deploy order is migrate → **restart**.
7
+ * New template-sharing role `Maintainer` ([chem-generic-ui#1082](https://github.com/cllde8/chem-generic-ui/issues/1082)): everything an editor may do plus releasing **minor** versions — major releases, activation, deletion and share management stay owner-only. `KlassShare::LEVELS` gains `maintainer: 25` (additive, strictly between editor and owner, nobody renumbered — the one-owner partial index on level 30 is untouched, so no migration); `ShareResolver::REQUIRED_LEVEL` gains `release_minor: 25` while `release` keeps meaning the major at 30; `update_template` now asks `:write` for a draft, `:release_minor` for a minor and `:release` for a major — an unrecognised release value fails toward the stricter gate. List payloads gain `can_release_minor` under the same `share_context` guard as the other capability booleans, the grant and level-change routes accept level 25, and the `release_minor` refusal names maintainer access instead of claiming owner-only. Ships with the matching chem-generic-ui change (Minor button gated on the new capability; an absent field on an older gem falls back to `can_release`).
8
+ * `Link Element` is now available as a column type inside `Table` fields of generic elements and segments ([chem-generic-ui#1055](https://github.com/LabIMotion/chem-generic-ui/issues/1055), labimotion#261). A `drag_element` cell stores the same payload the layer-level field stores (`el_id`, `el_klass`, `el_label`, `el_name`, `el_tip`, `icon_name`) and deliberately carries no `is_new`/`cr_opt`: a drop links a record that already exists and must never create one on save, unlike the sample column it sits next to. The stored label, name, tooltip and icon are refreshed on every read by the new `Labimotion::Serializer.set_table_element`, called from `PropertiesEntity` and `ElnElementEntity` as well, so all three property loaders agree rather than drifting into three copies. `SampleAssociation.build_table_element` writes the `Labimotion::ElementsElement` join rows and feeds the same `els` list that the stale-link cleanup compares against — feeding it is load-bearing, since the cleanup would otherwise `destroy_all` the rows it had just created. Both paths skip a cell whose `el_klass` is a `Labimotion::LinkedElement::MODELS` key: such a column accepts reactions, wellplates, screens and the rest as readily as generic elements, and for those `el_id` is not a `Labimotion::Element` id, so resolving it would relabel or link a wholly unrelated element — the same guard the layer-level field already applies. Carried through every export path: XLSX (`exporter_helpers`, expandable like the sample and molecule columns, short label as the cell text and `Name` as an optional sub-column), DOCX (`export_element`), and the ZIP round-trip (`export_utils` remaps element ids to uuids, `import_utils` re-links them through the new `proc_table_element`). A linked element has no structure image, so the cell shows the klass icon instead of an SVG.
9
+ * Element cover images, write path ([chem-generic-ui#1050](https://github.com/LabIMotion/chem-generic-ui/issues/1050), labimotion#259): the element create and update saves now validate the `cover_images` key of the optional `metadata` param — an array of at most 3 `{ source: attachment|analysis, id: <integer> }` references, mirroring the chem-generic-ui picker's cap and vocabulary, normalized to exactly those two entry keys — and refuse a malformed selection with a plain 422 before any container/sample-association write runs. All cover-image helpers live in the dedicated `Labimotion::CoverImageHelpers` module, with the contract cap and source vocabulary as `Constants::CoverImage::MAX`/`SOURCES`. The rest of the metadata document keeps its existing contract: a present param replaces the stored hash wholesale, because its other writers (layer groups/restrictions from the details page, host regComps through the registry seam) remove a key by omitting it, while an absent param leaves stored metadata untouched — an old client that does not send `metadata` can never wipe a saved cover selection. References only, no FK: a deleted attachment/analysis leaves a dangling reference the UI skips at render time. No migration (`elements.metadata` and `elements_revisions.metadata` already exist), no entity change (`metadata` was already exposed), no klass change (the per-klass `cover_image` toggle rides the existing `update_settings` shallow merge); revision snapshots already carry `metadata` alongside `properties`, and restoring a revision round-trips the cover selection through this validated save.
10
+ * Element cover images, original serving ([chem-generic-ui#1050](https://github.com/LabIMotion/chem-generic-ui/issues/1050), labimotion#259): new `GET /generic_elements/cover_image?element_id=&source=&id=` answers the original file behind a cover-image reference, so the element page's display area can show originals while thumbnails stay in the picker. Gated by the `genericElement` matrix check plus `ElementPolicy` `read?` **and** `read_dataset?` (analysis previews are dataset attachments, and the ELN's own attachment reads demand both); the reference resolves through the element's own associations only, so a foreign attachment or container id answers 404, never a file. An `analysis` reference resolves the container preview server-side for the first time, mirroring the client's `getContainerImageData`: the container's `preferred_thumbnail` while it still exists, else the newest "combined" image, else the newest thumbnail-bearing dataset attachment. The body is the original bytes under the file's own content type — or the PNG thumbnail when a browser could not render the original (TIFF, raw-data files behind an analysis preview). An optional `variant=thumbnail` always answers the PNG thumbnail through the same gate and resolution, which is what the picker's rows show — for an svg the stored thumbnail (black: ImageMagick reads `transparent` as black) is replaced by a fresh white-background rasterization of the original, through the same shared converter as the docx export — with it, the whole cover-image feature runs on gem-owned API surface and the host wires no fetcher at all. The picker's Save persists directly through the new `POST /generic_elements/cover_images` (`element_id`, `cover_images`; gated by `ElementPolicy` `update?` + the matrix check): the same validation as the element-save path, then a **targeted merge** of exactly the `cover_images` key into the stored metadata — deliberately narrower than the element save's wholesale-replace contract, so this writer can neither wipe nor resurrect `groups`/host keys. Live like klass settings: the write is immediate and unversioned until the next element save snapshots metadata into the revisions. The docx export (`ExportElement`) now renders the cover selection too: the stored references resolve through the same helpers as the `cover_image` endpoint, each image is embedded above the layers (originals for the rasters Word renders reliably — png/jpeg/gif/bmp; other renderable originals such as svg are rasterized onto white via MiniMagick, with the CSS keyword `transparent` normalized to `none` first because ImageMagick’s own SVG renderer reads it as black — the PNG thumbnail only as last resort), captioned "ref: …" and scaled to the page via MiniMagick-read dimensions (best effort — without them the template placeholder geometry is kept), Requires the matching `Labimotion_lines.docx` template change in the host ELN (a `has_cover`-guarded `cover_images:each` block before the layers loop); an old template simply ignores the new context keys.
11
+ * 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.
12
+ * 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.
13
+ * `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).
14
+ * 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.
15
+ * 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.
16
+ * 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`.
17
+ * 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.
18
+ * 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.
19
+ * 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.
20
+ * 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`.
21
+ * 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`.
22
+ * 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.
23
+ * `LinkedElement` resolves `cell_line` links to curated `CelllineSample` columns (previously an unknown el_klass misresolved to a generic `Labimotion::Element` sharing the same id).
24
+ * `LinkedElement` exposes two distinct names for `cell_line` links — the shared **Cell line name** (`material_name`, from the linked `CelllineMaterial`) and the per-sample **Name of specific sample** (`name`) — as separately selectable inline attributes.
5
25
  * Element variations can be exported to and imported from Excel (`.xlsx`). The exported workbook is itself the import template: `GET /api/v1/element_variations/:element_id/export` and `POST /api/v1/element_variations/:element_id/import`.
6
26
  * The exported sheet carries the grid's grouped header -- group, sub-group and field rows, merged as the grid draws them -- above the hidden key and unit rows. Workbooks from the earlier single-row header still import.
7
27
  * Import merges rows by uuid, so a sheet only updates the columns it carries; a row with a blank `Row ID` is added as a new variation.
8
28
  * `number`, `integer` and `system-defined` columns accept numbers only. A cell holding anything else is cleared with a warning rather than stored as text, which also cleans up text those columns already hold.
9
29
  * Import warnings name the cell they came from (`Cell I6: ...`), so the user can go straight to it in the spreadsheet.
10
30
  * Bug fixes:
11
- * `fetch_for_user`: the shared-records branch joined `collection_shares` directly on the element model (an association that only exists on `Collection`), raising `ActiveRecord::ConfigurationError` — rescued into empty search results. Now joins through `collections`. Fixed for both the basic elements (`ElementFetchable`) and the generic element (`Labimotion::Element`), so `search_by_like` and `search_basic_by_like` return shared records again.
31
+ * `ElementFetchable#fetch_for_user`: the shared-records branch joined `collection_shares` directly on the element model (an association that only exists on `Collection`), raising `ActiveRecord::ConfigurationError` — rescued into empty search results for every element type. Now joins through `collections`.
32
+ * `Labimotion::Element.fetch_for_user` carried the same broken `collection_shares` join, so the generic-element `search_by_like` endpoint silently returned no shared records. Now joins through `collections` as well.
33
+ * `Labimotion::CellLine` delegation forwards keyword arguments on Ruby >= 3.0 (`ruby2_keywords`); without it `fetch_for_user(id, name:, short_label:, limit:)` raised `ArgumentError`.
34
+ * `SampleAssociation#update_sample_association` no longer creates `Labimotion::ElementsElement` join rows for drag_element values that link standard elements (e.g. `cell_line`, `reaction`), whose `el_id` is not a `Labimotion::Element` id.
35
+ * `Segmentable#save_segments` → `touch_analyses_properties` assumed every segmentable model defines `#analyses`; `CelllineSample` did not, so `POST /api/v1/cell_lines` raised `NameError` on create/update. Guard the analysis-vocabulary touch when `#analyses` is undefined so the save no longer fails.
12
36
  * Dependencies:
13
37
  * Added `roo` (~> 2.10) for reading the uploaded workbook.
14
38
 
@@ -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,86 @@ 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
+ # Settle the cheap objections BEFORE spending a provider call on this:
89
+ # a taken name is knowable now, and finding out after generation means
90
+ # 15-90s and a token spend for an answer a lookup could have given.
91
+ validation = Labimotion::AiKlassValidator.error_for(kind: 'dataset', params: params)
92
+ next { status: 'error', message: validation } if validation
93
+
94
+ # Generation is a long provider call, so it is handed to the host's worker
95
+ # and the request answers at once; the result arrives as a notification
96
+ # linking back into the designer. A host with no such worker gets the
97
+ # old inline behaviour rather than an error.
98
+ if Labimotion::AiKlassQueue.queue(kind: 'dataset', params: params, user: current_user)
99
+ next { status: 'queued',
100
+ message: 'Generating the template in the background. You will be notified when it is ready.' }
101
+ end
102
+
103
+ msg = create_ai_dataset_klass(params, current_user)
104
+ klass = Labimotion::DatasetKlassEntity.represent(Labimotion::DatasetKlass.all)
105
+ { status: msg[:status], message: msg[:message], klass: klass }
106
+ rescue StandardError => e
107
+ Labimotion.log_exception(e, current_user)
108
+ { error: e.message }
109
+ end
110
+ end
111
+
112
+ namespace :refine_ai_klass do
113
+ desc 'refine a Generic Dataset Klass template with AI (returns the revised template; not persisted)'
114
+ params do
115
+ use :refine_ai_dataset_klass_params
116
+ end
117
+ post do
118
+ refine_ai_dataset_klass(params, current_user)
119
+ rescue StandardError => e
120
+ Labimotion.log_exception(e, current_user)
121
+ { status: 'error', message: e.message }
122
+ end
123
+ end
124
+
125
+ namespace :plan_ai_klass do
126
+ # The cheap half of fine-tuning: an instruction plus an INDEX of the open
127
+ # template in, a short list of structural operations out, which the
128
+ # designer applies with the handlers it already has. Type-agnostic like
129
+ # :refine_ai_klass beside it — the path reads as dataset only because
130
+ # that is where this family of routes was first added.
131
+ #
132
+ # Gated on the same whitelist as the AI settings API
133
+ # (Labimotion::MatriceLabimotion, via the host's model): this endpoint
134
+ # spends the user's AI budget, so reaching it needs more than being
135
+ # logged in. NameError, not defined?, for the reason spelled out at
136
+ # generic_element_api's :ai_fill_data — under Zeitwerk `defined?` is nil
137
+ # until the constant is first referenced, and a host that ships no such
138
+ # model is a refusal too.
139
+ before do
140
+ allowed = begin
141
+ Matrice.ai_enabled_for_any?(current_user)
142
+ rescue NameError
143
+ false
144
+ end
145
+ unless allowed
146
+ error!({ status: 'error', message: 'AI template editing is not enabled for this account.' }, 403)
147
+ end
148
+ end
149
+
150
+ desc 'plan structural changes to a template with AI (returns operations for the designer to apply)'
151
+ params do
152
+ use :plan_ai_dataset_klass_params
153
+ end
154
+ post do
155
+ plan_ai_dataset_klass(params, current_user)
156
+ rescue StandardError => e
157
+ Labimotion.log_exception(e, current_user)
158
+ { status: 'error', message: e.message }
159
+ end
160
+ end
161
+
72
162
  namespace :find_template do
73
163
  desc 'Find best matching template for given OLS term ID'
74
164
  params do
@@ -16,6 +16,7 @@ module Labimotion
16
16
  helpers Labimotion::VocabularyHelpers
17
17
  helpers Labimotion::GenericHelpers
18
18
  helpers Labimotion::ElementHelpers
19
+ helpers Labimotion::CoverImageHelpers
19
20
  helpers Labimotion::ParamHelpers
20
21
 
21
22
  resource :generic_elements do
@@ -88,6 +89,53 @@ module Labimotion
88
89
  end
89
90
  end
90
91
 
92
+ namespace :linked_attributes do
93
+ desc 'List the selectable inline attributes of an element linked from a drag_element field'
94
+ params do
95
+ optional :el_klass, type: String, desc: 'Generic element klass name or permit-target key (reaction, sample, ...)'
96
+ optional :el_type, type: String, desc: 'Linked element type (element, sample, molecule, ...)'
97
+ requires :el_id, type: Integer, desc: 'Linked element id'
98
+ end
99
+ get do
100
+ linked = Labimotion::LinkedElement.new(params[:el_klass], params[:el_id], params[:el_type])
101
+ record = linked.record
102
+ error!('404 Not Found', 404) if record.nil?
103
+ # Reference data (molecules) is public; everything else is collection-scoped.
104
+ readable = record.is_a?(::Molecule) || ElementPolicy.new(current_user, record).read?
105
+ error!('401 Unauthorized', 401) unless readable
106
+
107
+ { attributes: linked.available }
108
+ rescue StandardError => e
109
+ Labimotion.log_exception(e, current_user)
110
+ { attributes: [], error: e.message }
111
+ end
112
+
113
+ desc 'List the selectable inline attributes of a klass, with no element instance'
114
+ params do
115
+ requires :el_klass, type: String, desc: 'Generic klass name or permit-target key (reaction, sample, ...)'
116
+ end
117
+ get 'for_klass' do
118
+ error!('401 Unauthorized', 401) unless current_user
119
+
120
+ # Template shape only — no instance is read, so there is nothing
121
+ # collection-scoped to authorize beyond being a signed-in user.
122
+ { attributes: Labimotion::LinkedElement.available_for_klass(params[:el_klass]) }
123
+ rescue StandardError => e
124
+ Labimotion.log_exception(e, current_user)
125
+ { attributes: [], error: e.message }
126
+ end
127
+
128
+ desc 'List the standard ELN element types a drag field can link'
129
+ get 'permit_targets' do
130
+ error!('401 Unauthorized', 401) unless current_user
131
+
132
+ { permit_targets: Labimotion::LinkedElement.permit_targets }
133
+ rescue StandardError => e
134
+ Labimotion.log_exception(e, current_user)
135
+ { permit_targets: [], error: e.message }
136
+ end
137
+ end
138
+
91
139
  namespace :search_basic_by_like do
92
140
  desc 'Search basic elements by name and short label (case-insensitive like search)'
93
141
  params do
@@ -156,7 +204,7 @@ module Labimotion
156
204
  use :create_element_klass_params
157
205
  end
158
206
  post do
159
- authenticate_admin!('elements')
207
+ authenticate_admin!(Labimotion::Constants::Family::ELEMENT)
160
208
  create_element_klass(current_user, params)
161
209
  status 201
162
210
  rescue ActiveRecord::RecordInvalid => e
@@ -164,13 +212,86 @@ module Labimotion
164
212
  end
165
213
  end
166
214
 
215
+ namespace :create_ai_klass do
216
+ desc 'create a Generic Element Klass from an AI-generated template'
217
+ params do
218
+ use :create_ai_element_klass_params
219
+ end
220
+ post do
221
+ authenticate_admin!('elements')
222
+ # Settle the cheap objections BEFORE spending a provider call on this:
223
+ # a taken name is knowable now, and finding out after generation means
224
+ # 15-90s and a token spend for an answer a lookup could have given.
225
+ validation = Labimotion::AiKlassValidator.error_for(kind: 'element', params: params)
226
+ next { status: 'error', message: validation } if validation
227
+
228
+ # Generation is a long provider call, so it is handed to the host's worker
229
+ # and the request answers at once; the result arrives as a notification
230
+ # linking back into the designer. A host with no such worker gets the
231
+ # old inline behaviour rather than an error.
232
+ if Labimotion::AiKlassQueue.queue(kind: 'element', params: params, user: current_user)
233
+ next { status: 'queued',
234
+ message: 'Generating the template in the background. You will be notified when it is ready.' }
235
+ end
236
+
237
+ msg = create_ai_element_klass(params, current_user)
238
+ { status: msg[:status], message: msg[:message],
239
+ klass: Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.where(is_active: true)) }
240
+ rescue StandardError => e
241
+ Labimotion.log_exception(e, current_user)
242
+ { error: e.message }
243
+ end
244
+ end
245
+
246
+ # Sibling namespace of :create_ai_klass — MUST stay above the route_param :id
247
+ # blocks below, otherwise POST /ai_fill_data falls through to the element
248
+ # instance route and Element.find(nil) is raised.
249
+ namespace :ai_fill_data do
250
+ # Auto-fill is the one AI action ordinary users reach — the designer's
251
+ # create/refine routes are admin-only — so it carries its own whitelist:
252
+ # the genericElement AI list narrowed by :fill_uids. The rule is ours
253
+ # (Labimotion::MatriceLabimotion); the lists are the host's, on the
254
+ # model it mixes that concern into. Reached the same way this API already
255
+ # reaches ElementPolicy, and CLOSED when the host mounts no such model,
256
+ # matching how every other gate in this feature defaults.
257
+ # NameError, not defined?: under Zeitwerk `defined?` is nil until the
258
+ # constant has actually been referenced, so testing it would refuse the
259
+ # first request after every boot. Referencing it triggers the autoload;
260
+ # a host that genuinely ships no such model raises, and that is a
261
+ # refusal too.
262
+ before do
263
+ allowed = begin
264
+ Matrice.fill_enabled_for?(current_user)
265
+ rescue NameError
266
+ false
267
+ end
268
+ error!({ status: 'error', message: 'AI auto-fill is not enabled for this account.' }, 403) unless allowed
269
+ end
270
+
271
+ desc "Auto-fill a generic element's data values from a document using AI"
272
+ params do
273
+ use :ai_fill_element_data_params
274
+ end
275
+ post do
276
+ msg = ai_fill_element_data(params, current_user)
277
+ if msg[:status] == 'success'
278
+ { status: 'success', values: msg[:values], summary: msg[:summary] }
279
+ else
280
+ { status: 'error', message: msg[:message] }
281
+ end
282
+ rescue StandardError => e
283
+ Labimotion.log_exception(e, current_user)
284
+ { status: 'error', message: e.message }
285
+ end
286
+ end
287
+
167
288
  namespace :update_element_klass do
168
289
  desc 'update Generic Element Klass'
169
290
  params do
170
291
  use :update_element_klass_params
171
292
  end
172
293
  post do
173
- authenticate_admin!('elements')
294
+ # authorize_klass!(:write) runs inside the helper, on the loaded record.
174
295
  update_element_klass(current_user, params)
175
296
  rescue StandardError => e
176
297
  Labimotion.log_exception(e, current_user)
@@ -217,7 +338,7 @@ module Labimotion
217
338
  requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass]
218
339
  end
219
340
  post do
220
- authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
341
+ # authorize_klass!(:destroy) runs inside the helper, once the klass row is loaded.
221
342
  delete_klass_revision(params)
222
343
  status 201
223
344
  rescue StandardError => e
@@ -297,19 +418,26 @@ module Labimotion
297
418
  scope = scope.where(is_active: params[:is_active]) if params.key?(:is_active)
298
419
 
299
420
  list = scope.sort_by(&:place)
300
- present list, with: Labimotion::ElementKlassEntity, root: 'klass', displayed_in_list: params[:displayed_in_list]
421
+ present list, with: Labimotion::ElementKlassEntity, root: 'klass',
422
+ displayed_in_list: params[:displayed_in_list],
423
+ with_ownership: true, owners: Labimotion::OwnerResolver.map_for(list),
424
+ share_context: Labimotion::ShareResolver.context_for(list, current_user)
301
425
  rescue StandardError => e
302
426
  Labimotion.log_exception(e, current_user)
303
427
  raise e
304
428
  end
305
429
  end
306
430
 
307
- # Deprecated: This namespace is no longer used, but kept for backward compatibility.
431
+ # Deprecated as an endpoint, but it is what the Element Designer grid still calls
432
+ # (eln GenericElsFetcher.fetchElementKlasses), so ownership has to be exposed here too —
433
+ # adding `created_by` to `for_list_display` alone would never reach that grid.
308
434
  namespace :klasses_all do
309
435
  desc 'get all klasses for admin function'
310
436
  get do
311
437
  list = Labimotion::ElementKlass.all.sort_by { |e| e.place }
312
- present list, with: Labimotion::ElementKlassEntity, root: 'klass'
438
+ present list, with: Labimotion::ElementKlassEntity, root: 'klass',
439
+ with_ownership: true, owners: Labimotion::OwnerResolver.map_for(list),
440
+ share_context: Labimotion::ShareResolver.context_for(list, current_user)
313
441
  rescue StandardError => e
314
442
  Labimotion.log_exception(e, current_user)
315
443
  []
@@ -332,6 +460,9 @@ module Labimotion
332
460
  requires :identifier, type: String, desc: 'Identifier'
333
461
  end
334
462
  post do
463
+ # Importing needs the global create right; upgrading an existing template is gated
464
+ # owner-only inside validate_klass, where the create/update branch is decided.
465
+ authenticate_admin!(Labimotion::Constants::Family::ELEMENT)
335
466
  msg = create_repo_klass(params, current_user, request.headers['Origin'])
336
467
  klass = Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.all)
337
468
  { status: msg[:status], message: msg[:message], klass: klass }
@@ -349,7 +480,8 @@ module Labimotion
349
480
  requires :is_active, type: Boolean, desc: 'Active or Inactive Klass'
350
481
  end
351
482
  after_validation do
352
- authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
483
+ # The share gate needs the loaded record, so it runs inside the helper
484
+ # (authorize_klass!(:deactivate)) rather than up here where only params exist.
353
485
  fetch_klass(params[:klass], params[:id])
354
486
  end
355
487
  post do
@@ -385,7 +517,8 @@ module Labimotion
385
517
  optional :release, type: String, default: 'draft', desc: 'release status', values: %w[draft major minor patch]
386
518
  end
387
519
  after_validation do
388
- authenticate_admin!(params[:klass].gsub(/(Klass)/, 's').downcase)
520
+ # authorize_klass! (:write for a draft, :release otherwise) runs inside
521
+ # update_template, on the loaded record.
389
522
  fetch_klass(params[:klass], params[:id])
390
523
  end
391
524
  post do
@@ -402,6 +535,9 @@ module Labimotion
402
535
  use :upload_element_klass_params
403
536
  end
404
537
  post do
538
+ # Same split as create_repo_klass: create right here, owner-only upgrade inside
539
+ # validate_klass. This route previously had no gate at all.
540
+ authenticate_admin!(Labimotion::Constants::Family::ELEMENT)
405
541
  declared_params = declared(params, include_missing: false)
406
542
  attributes = declared_params.merge(
407
543
  created_by: current_user.id,
@@ -456,6 +592,58 @@ module Labimotion
456
592
  { generic_elements: [] }
457
593
  end
458
594
 
595
+ # Declared before `route_param :id` so the literal segment is not
596
+ # swallowed by the id capture.
597
+ namespace :cover_images do
598
+ desc 'Save the cover-image selection directly (live, like klass settings)'
599
+ params do
600
+ requires :element_id, type: Integer, desc: 'Element id'
601
+ requires :cover_images, type: Array, desc: 'Ordered {source, id} references; [] clears'
602
+ end
603
+ post do
604
+ element = Labimotion::Element.find(params[:element_id])
605
+ unless current_user.matrix_check_by_name('genericElement') &&
606
+ ElementPolicy.new(current_user, element).update?
607
+ error!('401 Unauthorized', 401)
608
+ end
609
+
610
+ { mc: 'ss00', cover_images: save_cover_images(element, params[:cover_images]) }
611
+ rescue ActiveRecord::RecordNotFound
612
+ error!('404 Not Found', 404)
613
+ end
614
+ end
615
+
616
+ namespace :cover_image do
617
+ desc 'Serve the image behind a cover-image reference (original or thumbnail)'
618
+ params do
619
+ requires :element_id, type: Integer, desc: 'Element id'
620
+ requires :source, type: String, values: Labimotion::Constants::CoverImage::SOURCES,
621
+ desc: 'Reference source'
622
+ requires :id, type: Integer, desc: 'Attachment or analysis-container id'
623
+ optional :variant, type: String, values: %w[original thumbnail], default: 'original',
624
+ desc: 'original file (display area) or PNG thumbnail (picker rows)'
625
+ end
626
+ get do
627
+ element = Labimotion::Element.find(params[:element_id])
628
+ policy = ElementPolicy.new(current_user, element)
629
+ unless current_user.matrix_check_by_name('genericElement') && policy.read? && policy.read_dataset?
630
+ error!('401 Unauthorized', 401)
631
+ end
632
+
633
+ attachment = cover_image_attachment(element, params[:source], params[:id])
634
+ payload = attachment && cover_image_body(attachment, params[:variant])
635
+ error!('404 Not Found', 404) if payload.nil?
636
+
637
+ data, mime_type = payload
638
+ env['api.format'] = :binary
639
+ content_type(mime_type || 'application/octet-stream')
640
+ header('Content-Disposition', "inline; filename=\"#{CGI.escape(attachment.filename.to_s)}\"")
641
+ data
642
+ rescue ActiveRecord::RecordNotFound
643
+ error!('404 Not Found', 404)
644
+ end
645
+ end
646
+
459
647
  desc 'Return serialized element by id'
460
648
  params do
461
649
  requires :id, type: Integer, desc: 'Element id'
@@ -473,6 +661,7 @@ module Labimotion
473
661
  {
474
662
  element: Labimotion::ElementEntity.represent(
475
663
  element,
664
+ current_user: current_user,
476
665
  detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
477
666
  policy: @element_policy
478
667
  ),
@@ -517,6 +706,7 @@ module Labimotion
517
706
  {
518
707
  element: Labimotion::ElementEntity.represent(
519
708
  element,
709
+ current_user: current_user,
520
710
  detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
521
711
  ),
522
712
  attachments: Entities::AttachmentEntity.represent(element&.attachments),