labimotion 2.4.0.rc3 → 2.4.0.rc5
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/labimotion/apis/generic_dataset_api.rb +12 -3
- data/lib/labimotion/apis/generic_element_api.rb +103 -8
- data/lib/labimotion/apis/generic_klass_api.rb +50 -6
- data/lib/labimotion/apis/klass_share_api.rb +646 -0
- data/lib/labimotion/apis/labimotion_api.rb +1 -0
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/apis/segment_api.rb +42 -10
- data/lib/labimotion/apis/user_klass_settings_api.rb +28 -5
- data/lib/labimotion/constants.rb +36 -0
- data/lib/labimotion/entities/application_entity.rb +8 -0
- data/lib/labimotion/entities/eln_element_entity.rb +6 -0
- data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/entities/properties_entity.rb +55 -7
- data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +10 -1
- data/lib/labimotion/helpers/element_helpers.rb +39 -6
- data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
- data/lib/labimotion/helpers/generic_helpers.rb +214 -4
- data/lib/labimotion/helpers/param_helpers.rb +9 -1
- data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
- data/lib/labimotion/helpers/segment_helpers.rb +25 -4
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/export_element.rb +128 -13
- data/lib/labimotion/libs/linked_element.rb +113 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/sample_association.rb +47 -1
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +369 -0
- data/lib/labimotion/models/concerns/datasetable.rb +3 -0
- data/lib/labimotion/models/concerns/template_doi.rb +133 -0
- data/lib/labimotion/models/dataset_klass.rb +1 -1
- data/lib/labimotion/models/element_klass.rb +1 -1
- data/lib/labimotion/models/klass_share.rb +126 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/models/user_klass_setting.rb +47 -1
- data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/utils/export_utils.rb +1 -0
- data/lib/labimotion/utils/import_utils.rb +20 -3
- data/lib/labimotion/utils/serializer.rb +27 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +9 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e6b4cffa98971ea0ef93e54a537710c7fdc587f2433f038a54f42be1ac63f21
|
|
4
|
+
data.tar.gz: b57ba99244c3b9ec975fef1acb2c1b588b8d9bee5a5d6c356d336bc247c08738
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c727c0b2409e463b0e6130986ebdcc528cf6473696b2124889061557f365ec163cc1393d08662b258801ddadafd16f7134e82786d759121b915e47b499814b55
|
|
7
|
+
data.tar.gz: 84ac6b69a8326012b61eae9aefef2f248c85ac22fa45cf266cf03114378a0cde2d1839f0798d3d460d35223000283db2a7ee4dcb3bdcfa4e4faac414ad01460b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
* Features and enhancements:
|
|
5
|
+
* `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.
|
|
6
|
+
* 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.
|
|
7
|
+
* 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.
|
|
8
|
+
* 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.
|
|
9
|
+
* 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.
|
|
10
|
+
* `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).
|
|
11
|
+
* 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.
|
|
12
|
+
* 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.
|
|
13
|
+
* 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`.
|
|
14
|
+
* 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.
|
|
15
|
+
* 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.
|
|
16
|
+
* 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
17
|
* 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
18
|
* 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
19
|
* 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.
|
|
@@ -26,7 +26,10 @@ module Labimotion
|
|
|
26
26
|
get do
|
|
27
27
|
list = klass_list(params[:is_active], params[:displayed_in_list])
|
|
28
28
|
serialized_data = Labimotion::DatasetKlassEntity.represent(list,
|
|
29
|
-
displayed_in_list: params[:displayed_in_list]
|
|
29
|
+
displayed_in_list: params[:displayed_in_list],
|
|
30
|
+
with_ownership: true,
|
|
31
|
+
owners: Labimotion::OwnerResolver.map_for(list),
|
|
32
|
+
share_context: Labimotion::ShareResolver.context_for(list, current_user))
|
|
30
33
|
{ mc: 'ss00', data: serialized_data }
|
|
31
34
|
rescue StandardError => e
|
|
32
35
|
Labimotion.log_exception(e, current_user)
|
|
@@ -34,7 +37,8 @@ module Labimotion
|
|
|
34
37
|
end
|
|
35
38
|
end
|
|
36
39
|
|
|
37
|
-
# Deprecated
|
|
40
|
+
# Deprecated as an endpoint, but it is what the Dataset Designer grid still calls
|
|
41
|
+
# (eln GenericDSsFetcher.listDatasetKlass), so ownership has to be exposed here too.
|
|
38
42
|
# It is replaced by `list_klass`.
|
|
39
43
|
namespace :list_dataset_klass do
|
|
40
44
|
desc 'list Generic Dataset Klass'
|
|
@@ -43,7 +47,9 @@ module Labimotion
|
|
|
43
47
|
end
|
|
44
48
|
get do
|
|
45
49
|
list = klass_list(params[:is_active], false)
|
|
46
|
-
present list, with: Labimotion::DatasetKlassEntity, root: 'klass', displayed_in_list: false
|
|
50
|
+
present list, with: Labimotion::DatasetKlassEntity, root: 'klass', displayed_in_list: false,
|
|
51
|
+
with_ownership: true, owners: Labimotion::OwnerResolver.map_for(list),
|
|
52
|
+
share_context: Labimotion::ShareResolver.context_for(list, current_user)
|
|
47
53
|
end
|
|
48
54
|
end
|
|
49
55
|
|
|
@@ -60,6 +66,9 @@ module Labimotion
|
|
|
60
66
|
requires :identifier, type: String, desc: 'Identifier'
|
|
61
67
|
end
|
|
62
68
|
post do
|
|
69
|
+
# Importing needs the global create right; upgrading an existing template is gated
|
|
70
|
+
# owner-only inside the helper's update branch.
|
|
71
|
+
authenticate_admin!(Labimotion::Constants::Family::DATASET)
|
|
63
72
|
msg = create_repo_klass(params, current_user, request.headers['Origin'])
|
|
64
73
|
klass = Labimotion::DatasetKlassEntity.represent(Labimotion::DatasetKlass.all)
|
|
65
74
|
{ status: msg[:status], message: msg[:message], klass: klass }
|
|
@@ -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
|
|
@@ -108,6 +109,31 @@ module Labimotion
|
|
|
108
109
|
Labimotion.log_exception(e, current_user)
|
|
109
110
|
{ attributes: [], error: e.message }
|
|
110
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
|
|
111
137
|
end
|
|
112
138
|
|
|
113
139
|
namespace :search_basic_by_like do
|
|
@@ -178,7 +204,7 @@ module Labimotion
|
|
|
178
204
|
use :create_element_klass_params
|
|
179
205
|
end
|
|
180
206
|
post do
|
|
181
|
-
authenticate_admin!(
|
|
207
|
+
authenticate_admin!(Labimotion::Constants::Family::ELEMENT)
|
|
182
208
|
create_element_klass(current_user, params)
|
|
183
209
|
status 201
|
|
184
210
|
rescue ActiveRecord::RecordInvalid => e
|
|
@@ -192,7 +218,7 @@ module Labimotion
|
|
|
192
218
|
use :update_element_klass_params
|
|
193
219
|
end
|
|
194
220
|
post do
|
|
195
|
-
|
|
221
|
+
# authorize_klass!(:write) runs inside the helper, on the loaded record.
|
|
196
222
|
update_element_klass(current_user, params)
|
|
197
223
|
rescue StandardError => e
|
|
198
224
|
Labimotion.log_exception(e, current_user)
|
|
@@ -239,7 +265,7 @@ module Labimotion
|
|
|
239
265
|
requires :klass, type: String, desc: 'Klass', values: %w[ElementKlass SegmentKlass DatasetKlass]
|
|
240
266
|
end
|
|
241
267
|
post do
|
|
242
|
-
|
|
268
|
+
# authorize_klass!(:destroy) runs inside the helper, once the klass row is loaded.
|
|
243
269
|
delete_klass_revision(params)
|
|
244
270
|
status 201
|
|
245
271
|
rescue StandardError => e
|
|
@@ -319,19 +345,26 @@ module Labimotion
|
|
|
319
345
|
scope = scope.where(is_active: params[:is_active]) if params.key?(:is_active)
|
|
320
346
|
|
|
321
347
|
list = scope.sort_by(&:place)
|
|
322
|
-
present list, with: Labimotion::ElementKlassEntity, root: 'klass',
|
|
348
|
+
present list, with: Labimotion::ElementKlassEntity, root: 'klass',
|
|
349
|
+
displayed_in_list: params[:displayed_in_list],
|
|
350
|
+
with_ownership: true, owners: Labimotion::OwnerResolver.map_for(list),
|
|
351
|
+
share_context: Labimotion::ShareResolver.context_for(list, current_user)
|
|
323
352
|
rescue StandardError => e
|
|
324
353
|
Labimotion.log_exception(e, current_user)
|
|
325
354
|
raise e
|
|
326
355
|
end
|
|
327
356
|
end
|
|
328
357
|
|
|
329
|
-
# Deprecated
|
|
358
|
+
# Deprecated as an endpoint, but it is what the Element Designer grid still calls
|
|
359
|
+
# (eln GenericElsFetcher.fetchElementKlasses), so ownership has to be exposed here too —
|
|
360
|
+
# adding `created_by` to `for_list_display` alone would never reach that grid.
|
|
330
361
|
namespace :klasses_all do
|
|
331
362
|
desc 'get all klasses for admin function'
|
|
332
363
|
get do
|
|
333
364
|
list = Labimotion::ElementKlass.all.sort_by { |e| e.place }
|
|
334
|
-
present list, with: Labimotion::ElementKlassEntity, root: 'klass'
|
|
365
|
+
present list, with: Labimotion::ElementKlassEntity, root: 'klass',
|
|
366
|
+
with_ownership: true, owners: Labimotion::OwnerResolver.map_for(list),
|
|
367
|
+
share_context: Labimotion::ShareResolver.context_for(list, current_user)
|
|
335
368
|
rescue StandardError => e
|
|
336
369
|
Labimotion.log_exception(e, current_user)
|
|
337
370
|
[]
|
|
@@ -354,6 +387,9 @@ module Labimotion
|
|
|
354
387
|
requires :identifier, type: String, desc: 'Identifier'
|
|
355
388
|
end
|
|
356
389
|
post do
|
|
390
|
+
# Importing needs the global create right; upgrading an existing template is gated
|
|
391
|
+
# owner-only inside validate_klass, where the create/update branch is decided.
|
|
392
|
+
authenticate_admin!(Labimotion::Constants::Family::ELEMENT)
|
|
357
393
|
msg = create_repo_klass(params, current_user, request.headers['Origin'])
|
|
358
394
|
klass = Labimotion::ElementKlassEntity.represent(Labimotion::ElementKlass.all)
|
|
359
395
|
{ status: msg[:status], message: msg[:message], klass: klass }
|
|
@@ -371,7 +407,8 @@ module Labimotion
|
|
|
371
407
|
requires :is_active, type: Boolean, desc: 'Active or Inactive Klass'
|
|
372
408
|
end
|
|
373
409
|
after_validation do
|
|
374
|
-
|
|
410
|
+
# The share gate needs the loaded record, so it runs inside the helper
|
|
411
|
+
# (authorize_klass!(:deactivate)) rather than up here where only params exist.
|
|
375
412
|
fetch_klass(params[:klass], params[:id])
|
|
376
413
|
end
|
|
377
414
|
post do
|
|
@@ -407,7 +444,8 @@ module Labimotion
|
|
|
407
444
|
optional :release, type: String, default: 'draft', desc: 'release status', values: %w[draft major minor patch]
|
|
408
445
|
end
|
|
409
446
|
after_validation do
|
|
410
|
-
|
|
447
|
+
# authorize_klass! (:write for a draft, :release otherwise) runs inside
|
|
448
|
+
# update_template, on the loaded record.
|
|
411
449
|
fetch_klass(params[:klass], params[:id])
|
|
412
450
|
end
|
|
413
451
|
post do
|
|
@@ -424,6 +462,9 @@ module Labimotion
|
|
|
424
462
|
use :upload_element_klass_params
|
|
425
463
|
end
|
|
426
464
|
post do
|
|
465
|
+
# Same split as create_repo_klass: create right here, owner-only upgrade inside
|
|
466
|
+
# validate_klass. This route previously had no gate at all.
|
|
467
|
+
authenticate_admin!(Labimotion::Constants::Family::ELEMENT)
|
|
427
468
|
declared_params = declared(params, include_missing: false)
|
|
428
469
|
attributes = declared_params.merge(
|
|
429
470
|
created_by: current_user.id,
|
|
@@ -478,6 +519,58 @@ module Labimotion
|
|
|
478
519
|
{ generic_elements: [] }
|
|
479
520
|
end
|
|
480
521
|
|
|
522
|
+
# Declared before `route_param :id` so the literal segment is not
|
|
523
|
+
# swallowed by the id capture.
|
|
524
|
+
namespace :cover_images do
|
|
525
|
+
desc 'Save the cover-image selection directly (live, like klass settings)'
|
|
526
|
+
params do
|
|
527
|
+
requires :element_id, type: Integer, desc: 'Element id'
|
|
528
|
+
requires :cover_images, type: Array, desc: 'Ordered {source, id} references; [] clears'
|
|
529
|
+
end
|
|
530
|
+
post do
|
|
531
|
+
element = Labimotion::Element.find(params[:element_id])
|
|
532
|
+
unless current_user.matrix_check_by_name('genericElement') &&
|
|
533
|
+
ElementPolicy.new(current_user, element).update?
|
|
534
|
+
error!('401 Unauthorized', 401)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
{ mc: 'ss00', cover_images: save_cover_images(element, params[:cover_images]) }
|
|
538
|
+
rescue ActiveRecord::RecordNotFound
|
|
539
|
+
error!('404 Not Found', 404)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
namespace :cover_image do
|
|
544
|
+
desc 'Serve the image behind a cover-image reference (original or thumbnail)'
|
|
545
|
+
params do
|
|
546
|
+
requires :element_id, type: Integer, desc: 'Element id'
|
|
547
|
+
requires :source, type: String, values: Labimotion::Constants::CoverImage::SOURCES,
|
|
548
|
+
desc: 'Reference source'
|
|
549
|
+
requires :id, type: Integer, desc: 'Attachment or analysis-container id'
|
|
550
|
+
optional :variant, type: String, values: %w[original thumbnail], default: 'original',
|
|
551
|
+
desc: 'original file (display area) or PNG thumbnail (picker rows)'
|
|
552
|
+
end
|
|
553
|
+
get do
|
|
554
|
+
element = Labimotion::Element.find(params[:element_id])
|
|
555
|
+
policy = ElementPolicy.new(current_user, element)
|
|
556
|
+
unless current_user.matrix_check_by_name('genericElement') && policy.read? && policy.read_dataset?
|
|
557
|
+
error!('401 Unauthorized', 401)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
attachment = cover_image_attachment(element, params[:source], params[:id])
|
|
561
|
+
payload = attachment && cover_image_body(attachment, params[:variant])
|
|
562
|
+
error!('404 Not Found', 404) if payload.nil?
|
|
563
|
+
|
|
564
|
+
data, mime_type = payload
|
|
565
|
+
env['api.format'] = :binary
|
|
566
|
+
content_type(mime_type || 'application/octet-stream')
|
|
567
|
+
header('Content-Disposition', "inline; filename=\"#{CGI.escape(attachment.filename.to_s)}\"")
|
|
568
|
+
data
|
|
569
|
+
rescue ActiveRecord::RecordNotFound
|
|
570
|
+
error!('404 Not Found', 404)
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
481
574
|
desc 'Return serialized element by id'
|
|
482
575
|
params do
|
|
483
576
|
requires :id, type: Integer, desc: 'Element id'
|
|
@@ -495,6 +588,7 @@ module Labimotion
|
|
|
495
588
|
{
|
|
496
589
|
element: Labimotion::ElementEntity.represent(
|
|
497
590
|
element,
|
|
591
|
+
current_user: current_user,
|
|
498
592
|
detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
|
|
499
593
|
policy: @element_policy
|
|
500
594
|
),
|
|
@@ -539,6 +633,7 @@ module Labimotion
|
|
|
539
633
|
{
|
|
540
634
|
element: Labimotion::ElementEntity.represent(
|
|
541
635
|
element,
|
|
636
|
+
current_user: current_user,
|
|
542
637
|
detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: element).detail_levels,
|
|
543
638
|
),
|
|
544
639
|
attachments: Entities::AttachmentEntity.represent(element&.attachments),
|
|
@@ -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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|