diff --git a/AGENTS.md b/AGENTS.md index 893ead6..74472f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,10 @@ +# Shell Session Defaults + +- Default to an interactive shell for shell work. On this Mac, use the user's default shell unless the user explicitly asks for another shell. +- For AWS EC2, ParallelCluster, and other remote Linux hosts, default to an interactive `bash` login shell as `ubuntu`. Do not use `root` unless the user explicitly grants permission for that specific work; use targeted `sudo` from `ubuntu` when escalation is required. +- For Daylily/DayOA/DAY-EC headnode workflow work, use an interactive `ubuntu` tmux/login-shell pane for controllers and workflow commands. Run setup as separate commands in that pane (`source dyoainit`, then `dy-a ...`, then `dy-r ...`) so aliases/functions are defined before use. +- SSM Run Command is for simple inspection or for writing helper scripts through the supported helpers. Do not launch workflow controllers or rely on `dy-*` aliases from non-interactive SSM scripts. + # Bloom CLI Policy ## Session Setup @@ -57,6 +64,16 @@ source ./activate - Service-host certs use DNS-01 renewal; do not depend on HTTP-01 public reachability for Bloom service hosts. - Future dev, test, and stage deployments must use their own approved-source lists, credentials, certificates, TapDB schemas, and tenant data, separate from production. +## Version Tags + +- Use non-v semver tags for package releases, e.g. `2.0.19` or `5.0.21`, not `v2.0.19`. +- Commit first, then tag the exact clean release commit. +- Use annotated tags for release provenance: `git tag -a 2.0.19 -m "Release 2.0.19"`. +- Lightweight tags are acceptable only for scratch/internal marks, not package releases. +- Do not move or overwrite pushed version tags. If a pushed tag is wrong, cut the next patch version. +- If signing is configured and expected, use signed annotated tags: `git tag -s 2.0.19 -m "Release 2.0.19"`. +- Verify tag type with `git cat-file -t 2.0.18`; `tag` means annotated and `commit` means lightweight. + ## Bloom Examples - Start with `source ./activate ` @@ -64,3 +81,9 @@ source ./activate - Use `bloom db seed` - Use `bloom server start --port 8912` - Use `tapdb ...` and `daycog ...` only where Bloom docs or Bloom CLI explicitly delegate to them +# DEPLOYED SERVICE GUI AUTH EVIDENCE + +- For deployed service acceptance, API health checks and HTTP `302` redirects are not sufficient. Also use Playwright or the bundled Playwright CLI workflow to open each deployed GUI login page, snapshot the page, click through to the Google OAuth option, and capture screenshots. +- Store screenshots and records under `output/playwright/` or the deployment ledger evidence directory. Record service name, deploy name, URL, timestamp, screenshot paths, and status as `confirmed_success` or `confirmed_fail`. +- If the Google OAuth click reaches an expected external blocker, such as `redirect_uri_mismatch`, record the exact callback URI and mark the row blocked instead of treating the service as healthy. +- Debug and fix failures that are in repository/deployment scope. If the failure requires external configuration or human credentials, annotate the reason precisely. Do not enter real user credentials unless the user explicitly authorizes that in the current turn. diff --git a/Dockerfile b/Dockerfile index b5a2d6d..1b18a82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG PYTHON_VERSION=3.12 FROM ghcr.io/astral-sh/uv:0.5.30-python${PYTHON_VERSION}-bookworm-slim AS builder ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0 -ENV SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION} \ +ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_BLOOM_LIMS=${SETUPTOOLS_SCM_PRETEND_VERSION} \ UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy \ PYTHONDONTWRITEBYTECODE=1 \ @@ -12,16 +12,18 @@ ENV SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION} \ WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libpq-dev \ + && apt-get install -y --no-install-recommends build-essential ca-certificates git libpq-dev \ && rm -rf /var/lib/apt/lists/* COPY pyproject.toml uv.lock README.md ./ -RUN uv sync --frozen --no-dev --no-install-project +RUN unset SETUPTOOLS_SCM_PRETEND_VERSION && uv sync --frozen --no-dev --no-install-project +COPY config ./config +COPY auth ./auth COPY bloom_lims ./bloom_lims COPY main.py ./main.py COPY static ./static COPY templates ./templates -RUN uv sync --frozen --no-dev +RUN unset SETUPTOOLS_SCM_PRETEND_VERSION && uv sync --frozen --no-dev FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime diff --git a/README.md b/README.md index 7b9a562..6e0ea7c 100644 --- a/README.md +++ b/README.md @@ -1,399 +1,136 @@ -# BLOOM +
+
+██████╗ ██╗      ██████╗  ██████╗ ███╗   ███╗
+██╔══██╗██║     ██╔═══██╗██╔═══██╗████╗ ████║
+██████╔╝██║     ██║   ██║██║   ██║██╔████╔██║
+██╔══██╗██║     ██║   ██║██║   ██║██║╚██╔╝██║
+██████╔╝███████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║
+╚═════╝ ╚══════╝ ╚═════╝  ╚═════╝ ╚═╝     ╚═╝
+
+

Internal material, container, lineage, and lab-action graph service.

+

+ Quickstart · + API · + GUI · + Lab actions · + Tests +

+
-BLOOM is the material-state service in the Dayhoff lab-software bundle. It tracks the primitives that other systems build from: containers, specimens, samples, reagents, equipment, lineage edges, queue/runtime state, and the external references that tie those records back to the rest of a lab ecosystem. Bloom is intentionally not a soup-to-nuts LIMS. It does not own order intake, portal UX, identity lifecycle, artifact byte storage, or deployment orchestration. It owns the durable answer to questions like "what exists?", "where is it?", "what did it come from?", and "what instance of Bloom is serving that answer?" +## Overview -In practice that makes Bloom a foundation service. Atlas can accession and coordinate intake, Dewey can register and search artifacts, Ursa can provide adjacent service logic, Zebra Day can handle label printing, daycog can manage shared Cognito, and Dayhoff can deploy the stack, but Bloom remains the place where material primitives and lineage become first-class, queryable state. +Bloom is the LSMC internal material and container graph service. It owns laboratory containers, materials/content, equipment, lineage, recursive template creation, search, and graph views. Atlas owns orders and customer-facing accession context; Bloom owns the physical/material execution graph. -Bloom's Cognito integration now follows the `daylily-auth-cognito` 2.0 split: browser sessions live in `browser.session`, Hosted UI helpers live in `browser.oauth` and `browser.google`, bearer verification lives in `runtime.verifier` and `runtime.m2m`, and Cognito lifecycle stays in `daycog` via `admin.*`. Service runtime code should not import `daylily_auth_cognito.cli`. +Current Dayhoff pin: `8.0.0`. Current TapDB dependency: `daylily-tapdb @ ...@9.0.10`. -## Bloom In The Dayhoff Ecology +Bloom is internal-only in Dayhoff exposure policy. It must be reachable only through approved LSMC networks and Dayhoff-generated service credentials. -```mermaid -flowchart LR - classDef control fill:#efe8d8,stroke:#7c5a00,color:#1a1a1a,stroke-width:1.5px; - classDef service fill:#e4f1ee,stroke:#0f6b5b,color:#10221d,stroke-width:1.5px; - classDef core fill:#f8ddd2,stroke:#aa4d24,color:#25130d,stroke-width:2px; - classDef data fill:#e7ebfb,stroke:#3758b5,color:#111a35,stroke-width:1.5px; - - subgraph Control["Control Plane And Shared Runtime"] - Dayhoff["Dayhoff
deploys and locates services"] - daycog["daycog / daylily-auth-cognito
shared Cognito lifecycle"] - TapDB["TapDB
namespaced runtime and admin surface"] - end - - subgraph Services["LIS Ecology"] - Atlas["Atlas
orders, intake, accession context"] - Bloom["Bloom
material primitives and lineage"] - Dewey["Dewey
artifact registration and retrieval"] - Ursa["Ursa
adjacent service logic"] - Zebra["Zebra Day
label printing"] - end - - Dayhoff --> Atlas - Dayhoff --> Bloom - Dayhoff --> Dewey - Dayhoff --> Ursa - Dayhoff --> Zebra - daycog --> Bloom - TapDB --> Bloom - Atlas --> Bloom - Bloom --> Dewey - Bloom --> Zebra - Bloom --> Atlas - - class Dayhoff,daycog,TapDB control - class Atlas,Dewey,Ursa,Zebra service - class Bloom core -``` - -## What Bloom Owns - -- Material primitives: container, content, specimen, sample, reagent, subject, equipment, template-backed instances. -- Material relationships: parent/child lineage, containment, graph traversal, and operator-visible provenance. -- Queue/runtime state for the current queue-centric beta surface. -- Integration-facing material references: Atlas reference binding, Atlas status event push, Dewey artifact registration calls, Zebra Day printer preferences, carrier tracking lookups. - -## What Bloom Does Not Own +TapDB template identity is semantic. Categories are names like `container`, `material`, `data`, `equipment`, `set`, and `workflow`; prefixes such as `BC*`, `BN*`, `BD*`, `BR*`, and `BG*` are issuance and governance labels only. Existing historical objects keep their already minted EUIDs, but new and active template definitions must not use a Meridian/EUID prefix as the category name. -- End-to-end order or requisition lifecycle. Atlas owns that side of the ecology. -- Artifact bytes and downstream analysis products. Dewey and the analysis stack own those surfaces. -- Shared identity lifecycle. Cognito and `daycog` own pool and app-client lifecycle. -- Deployment orchestration and service discovery. Dayhoff owns that control plane. +## What It Does -## Architecture, Stack, And Philosophy +- Owns Bloom containers, materials/content, equipment, recursive templates, lineages, search, and graph views. +- Exposes `/lab-actions` and `/api/v1/lab-actions/*` for extraction plate, sequencing-library plate, pool-tube, and sequencing-run setup. +- Mounts TapDB at `/tapdb` for generic object, template, lineage, audit, graph, and external-link inspection. +- Provides health and observability surfaces for Kahlo and Dayhoff deployment verification. -Bloom is a FastAPI service with a mounted GUI and a mounted TapDB admin sub-application. The runtime is configured through deployment-scoped YAML and TapDB namespace config rather than ad hoc shell state. Persistence and object identity are TapDB-backed. Browser authentication is Cognito-hosted-UI based through `daylily-auth-cognito`. The GUI is mostly server-rendered Jinja templates with a modern operations surface, while graph exploration is powered by a Cytoscape-based client under `static/js/graph.js`. - -The design philosophy is narrower than a classic monolith: - -- Keep Bloom authoritative for material state and lineage, not for every lab concern. -- Prefer deployment-scoped, reproducible runtime config over hidden workstation state. -- Expose stable, EUID-centered HTTP contracts instead of leaking internal UUIDs. -- Let other Dayhoff services call Bloom for state and provenance instead of copying that state. -- Treat older workflow/workset surfaces as history. The supported product surface is queue-centric. - -```mermaid -flowchart TB - classDef edge fill:#eef4e1,stroke:#597b2b,color:#18220f,stroke-width:1.5px; - classDef app fill:#dbe9f8,stroke:#2b5d90,color:#102030,stroke-width:1.5px; - classDef store fill:#fce9d7,stroke:#a85d17,color:#271406,stroke-width:1.5px; - classDef ext fill:#efe2f8,stroke:#7140a2,color:#1d1030,stroke-width:1.5px; - - Browser["Browser Session"] - TokenClient["Service Or CLI Token Client"] - App["Bloom FastAPI App
health, GUI, API v1, observability"] - GUI["GUI Routes
dashboard, search, queue, admin, graph"] - API["Versioned API
/api/v1/*"] - Admin["Mounted TapDB Admin
/admin/tapdb"] - Domain["Domain Modules
containers, content, queue, external specimens"] - Config["Bloom YAML + TapDB Namespace Config"] - Store["TapDB / PostgreSQL Runtime"] - AtlasExt["Atlas"] - DeweyExt["Dewey"] - ZebraExt["Zebra Day"] - - Browser --> GUI - TokenClient --> API - GUI --> App - API --> App - App --> Admin - App --> Domain - Config --> App - Domain --> Store - Domain --> AtlasExt - Domain --> DeweyExt - Domain --> ZebraExt - - class Browser,TokenClient edge - class App,GUI,API,Admin,Domain,Config app - class Store store - class AtlasExt,DeweyExt,ZebraExt ext -``` - -## Functional Model - -Bloom is best understood as a graph of material facts: - -- Templates define what kinds of things can exist. -- Object creation mints EUID-backed instances from those templates. -- Containers and content can be linked so Bloom knows what is in what. -- Lineage edges record derivation and movement. -- Queue/runtime objects describe the current operator-facing beta execution surface. -- External references bind Bloom records to Atlas and other systems without giving those systems direct ownership of Bloom state. +## How It Works ```mermaid flowchart LR - classDef state fill:#e9f7f1,stroke:#267a55,color:#10261c,stroke-width:1.5px; - classDef ref fill:#fce7da,stroke:#b05a21,color:#2a1407,stroke-width:1.5px; - - T["Template"] --> C["Container Instance"] - T --> S["Specimen / Sample / Reagent"] - C -->|"contains"| S - S -->|"lineage"| D["Derived Material"] - D -->|"queue/runtime"| Q["Execution Queue Item"] - D -->|"external refs"| A["Atlas Context"] - D -->|"artifact pointer"| F["Dewey Artifact"] - - class T,C,S,D,Q state - class A,F ref + Tube["Incoming tube + content"] --> Extraction["Extraction plate + gDNA"] + Extraction --> Library["Sequencing-library plate"] + Library --> Pool["Pool tube + pool content"] + Pool --> RunSet["Sequencing run set"] + RunSet --> OWY["Samplesheet for OWY/Kahlo traceability"] ``` -## Quick Start +Bloom models physical/material execution. Atlas owns orders and customer accession context; Dewey owns durable artifact identity; Ursa owns analysis trigger/job records. -Bloom's supported repo entrypoint is the activation script: +## Quickstart ```bash +cd /Users/jmajor/projects/mega_dayhoff/repos_work/bloom source ./activate -``` - -For a first local bring-up: - -```bash -source ./activate bringup -bloom config init +bloom --help +bloom config init --help bloom db build --target local -bloom config status -bloom config doctor bloom server start --port 8912 -curl -k https://127.0.0.1:8912/readyz ``` -Bloom's primary config files are deployment-scoped: +Runtime config, service config, TapDB config, and registry paths must be passed explicitly. Do not rely on deployment-name guessing or `~/.config` fallback discovery. -- Bloom YAML: `~/.config/bloom-/bloom-config-.yaml` -- TapDB runtime config: `~/.config/tapdb/bloom/bloom-/tapdb-config.yaml` -- Default local upload directory: `~/.config/tapdb/bloom/bloom-//uploads` +## CLI Interface -Use Bloom's CLI first: +The primary CLI is `bloom`. It covers config initialization, DB/bootstrap tasks, service startup, object/template workflows, and operational helpers. -- `bloom config ...` for service config inspection and repair. -- `bloom db ...` for runtime/bootstrap actions delegated to TapDB. -- `bloom server ...` for lifecycle. -- `tapdb ...` only when Bloom explicitly delegates low-level DB/runtime config. -- `daycog ...` only when Bloom explicitly delegates shared Cognito lifecycle. +Bloom can delegate low-level storage lifecycle to `tapdb` and shared Cognito lifecycle to `daycog` only where the Bloom CLI or docs explicitly say so. -See [docs/how-tos.md](docs/how-tos.md) for complete setup, auth, and troubleshooting recipes. +Common command families: -## API Surface Summary +| Family | Purpose | +|---|---| +| `bloom config ...` | Materialize explicit deployment-scoped service config. | +| `bloom db ...` | Build or verify Bloom-owned local database state through supported paths. | +| `bloom server ...` | Start the FastAPI service with explicit generated config. | +| `bloom objects/templates/lineage ...` | Work with Bloom-owned containers, materials, templates, and lineages where exposed by the CLI. | +| `bloom tokens/admin ...` | Manage supported internal/admin auth surfaces without printing secrets. | -The supported versioned API lives under `/api/v1`. Core route groups include: +For normal user/admin behavior, the CLI, API, and GUI are alternate surfaces over the same Bloom object, template, lineage, and auth capabilities. A feature should not be CLI-only, API-only, or GUI-only unless the docs say why. -- object and template management: `/api/v1/objects`, `/api/v1/containers`, `/api/v1/content`, `/api/v1/templates`, `/api/v1/object-creation` -- identity and token introspection: `/api/v1/auth`, `/api/v1/user-tokens`, `/api/v1/admin/groups`, `/api/v1/admin/user-tokens` -- graph and search: `/api/v1/lineages`, `/api/v1/graph`, `/api/v1/search/v2` -- operator/runtime: `/api/v1/execution`, `/api/v1/batch`, `/api/v1/tasks`, `/api/v1/stats`, `/api/v1/tracking` -- integration-specific: `/api/v1/external/specimens`, `/api/v1/external/atlas`, `/api/v1/external/atlas/beta` +## GUI -Two design points matter: +Bloom exposes a FastAPI/Jinja GUI for internal operators. Current surfaces include dashboard/home, object search and details, container/content/equipment operations, graph views, auth/profile flows, the `/lab-actions` wet-lab action wizard, and the mounted TapDB GUI at `/tapdb` when configured by Dayhoff. -- Public API payloads are EUID-centered. The tests explicitly check that internal UUIDs do not leak back out on key object surfaces. -- Bloom does not currently expose a general event bus. "Messaging" in current code means synchronous HTTP APIs plus targeted integration hooks, such as Atlas status-event push and best-effort Bloom-to-Atlas webhook delivery. +`/lab-actions` is the temporary operator flow for mapping incoming biospecimen tubes to extraction plates, extraction QC plates, sequencing-library plates, pool tubes, generic lab/run sets, per-well associated data, and sequencing run sets. It also accepts `.csv` and `.xlsx` spreadsheet uploads for the same API-backed actions. GUI actions should not have behavior that is unavailable through the API. -Example: unified search v2 +See [`docs/lab_actions.md`](docs/lab_actions.md) for the template matrix, lineage contract, API examples, GUI flow, and production rollout checklist for this surface. -```bash -curl -k https://localhost:8912/api/v1/search/v2/query \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{ - "query": "test", - "record_types": ["instance", "template"], - "page": 1, - "page_size": 25 - }' -``` +Every human-visible EUID should link to the canonical TapDB object page at `/tapdb/object/{euid}` unless Bloom owns a more specific detail page; service-specific pages should still link back to canonical TapDB details. -Example: external specimen create with Atlas references +## API -```bash -curl -k https://localhost:8912/api/v1/external/specimens \ - -H "Authorization: Bearer " \ - -H "Idempotency-Key: specimen-create-001" \ - -H "Content-Type: application/json" \ - -d '{ - "specimen_template_code": "content/specimen/blood-whole/1.0", - "specimen_name": "specimen-demo", - "status": "active", - "container_template_code": "container/tube/tube-generic-10ml/1.0", - "properties": {"source": "atlas-contract-test"}, - "atlas_refs": { - "order_euid": "ORD-123", - "patient_id": "PAT-123", - "kit_barcode": "KIT-123" - } - }' -``` - -Deep dive: [docs/apis.md](docs/apis.md) - -## Prefix Taxonomy - -Bloom EUID prefixes are governed display labels for operators and downstream -records. Prefixes are governance and display labels only; they do not drive -behavior, authorization, routing, template lookup, or workflow dispatch. Code -should use explicit template codes and semantic properties for behavior. - -| Family | Meaning | -| --- | --- | -| `BC*` | Containers | -| `BN*` | Contents, materials, and reagents | -| `BD*` | Data and evidence objects | -| `BR*` | Runs and executions | -| `BG*` | Generic helpers and external-object mappings | - -Concrete template kinds use three-letter prefixes. Two-letter prefixes are -generic class reserves only: `BC`, `BN`, `BD`, `BR`, and `BG`. New Bloom-owned -prefixes must not contain `I`, `L`, `O`, or `U`, and explicit numeric EUID -input must not use leading zeros. - -Current concrete wet-lab prefixes: - -| Template kind | Category | Prefix | -| --- | --- | --- | -| tube container | `container` | `BCT` | -| plate container | `container` | `BCP` | -| well container | `container` | `BCW` | -| bottle container | `container` | `BCB` | -| flowcell container | `container` | `BCF` | -| flowcell lane container | `container` | `BCE` | -| rack, box, or storage container | `container` | `BCR` | -| blood specimen/content | `content` | `BNB` | -| buccal specimen content | `content` | `BNS` | -| saliva specimen content | `content` | `BNA` | -| gDNA content | `content` | `BNG` | -| cfDNA content | `content` | `BNC` | -| sequencing library content | `content` | `BNQ` | -| sequencing library pool content | `content` | `BNP` | -| generic reagent content | `content` | `BNR` | -| sequencing index reagent content | `content` | `BNX` | -| control material content | `content` | `BNK` | -| gDNA quantification data | `data` | `BDQ` | -| extraction run evidence/data | `data` | `BDX` | -| extraction QC run evidence/data | `data` | `BDY` | -| library prep run evidence/data | `data` | `BDP` | -| pooling run evidence/data | `data` | `BDN` | -| library-index assignment data | `data` | `BDA` | -| transfer execution data | `data` | `BDT` | -| Illumina sequencing run | `run` | `BRM` | -| ONT sequencing run | `run` | `BRN` | -| Ultima sequencing run | `run` | `BRT` | -| PacBio sequencing run | `run` | `BRP` | -| Complete Genomics sequencing run | `run` | `BRC` | -| external object mapping | `generic` | `BGX` | - -Invalid historical proposals are explicitly replaced: `BCL` becomes `BCE`, -`BRI` becomes `BRM`, `BRO` becomes `BRN`, `BRU` becomes `BRT`, `BNL` becomes -`BNQ`, and `BDL` becomes `BDP`. Existing historical objects keep their already minted EUIDs; template prefix changes only affect future object creation. - -## GUI Overview - -Bloom's GUI is an operator and developer surface, not just a demo shell. The main current routes are: - -- `/`: dashboard -- `/search`: unified search -- `/queue_details`: queue runtime view -- `/equipment_overview`: equipment inventory -- `/reagent_overview`: reagent inventory -- `/create_object`: object creation wizard -- `/create_from_template`: template-driven instance creation -- `/admin`: admin, token, observability, and anomaly pages -- `/dindex2`: graph explorer - -The role model is simple: - -- `READ_ONLY`: can inspect data and manage own tokens. -- `READ_WRITE`: can mutate standard Bloom records and runtime actions. -- `ADMIN`: can use admin pages, admin token issuance, mounted TapDB admin, and graph mutation helpers. +The primary API is under `/api/v1/*`. Current route families include objects, containers, content, equipment, execution queue, batch operations, templates, subjects, lineages, stats, search, object creation, lab actions, user tokens, admin auth, external specimens, Atlas integration, beta lab integration, and graph APIs. -Group codes are orthogonal to that role ladder. In current code they are primarily feature gates for specific integrations and tokenized access, especially `API_ACCESS`, `ENABLE_ATLAS_API`, and `ENABLE_URSA_API`. +The lab-action route family includes: -Deep dive: [docs/gui.md](docs/gui.md) +- `POST /api/v1/lab-actions/extraction-plates` +- `POST /api/v1/lab-actions/extraction-qc-plates` +- `POST /api/v1/lab-actions/seq-library-plates` +- `POST /api/v1/lab-actions/seq-library-pools` +- `POST /api/v1/lab-actions/seq-runs` +- `POST /api/v1/lab-actions/sets` +- `GET /api/v1/lab-actions/sets/{set_euid}` +- `POST /api/v1/lab-actions/sets/{set_euid}/members` +- `POST /api/v1/lab-actions/plate-well-data` +- `POST /api/v1/lab-actions/spreadsheet-import` +- `GET /api/v1/lab-actions/seq-runs/{set_euid}/samplesheet` +- `GET /api/v1/lab-actions/plates/{plate_euid}/mapping.csv` +- `POST /api/v1/lab-actions/print-euids` -## Security And Auth +The generic run-set template is `set/run-set/generic/1.01`. It is used for sequencing-run sets and other temporary Bloom-owned sets of internal or external EUIDs. -Bloom uses YAML-first Cognito configuration for normal startup. If required TapDB or Cognito config is missing, startup validation fails fast. Browser auth uses `daylily-auth-cognito` Hosted UI session helpers and stores a normalized principal in the session rather than raw OAuth tokens. API access uses bearer tokens with Bloom-side RBAC and token-scope privilege caps. +Health and observability routes include `/healthz`, `/readyz`, `/health`, `/obs_services`, `/api_health`, `/endpoint_health`, `/db_health`, `/my_health`, and `/auth_health` when configured for Dayhoff observability. -Security-relevant current behavior: +## Testing Info -- GUI auth is Cognito-backed and configured from Bloom YAML. -- External integration routes require token auth and, for Atlas/Ursa surfaces, specific service groups in addition to general permissions. -- Mounted TapDB admin at `/admin/tapdb` is Bloom-admin-gated; it does not run an independent TapDB login flow when mounted. -- Outbound Bloom-to-Atlas event delivery is HMAC-signed and fail-open. +Focused checks: -For local tests and some unit test suites, auth bypass flags still exist in code. Those are test/dev escape hatches, not the supported operational path. - -## Testing - -Current repository facts: - -- `tests/` currently contains 60 `test_*.py` files. -- `pyproject.toml` sets the coverage gate to `fail_under = 39`. -- Playwright support is installed, but the checked-in E2E suite is intentionally narrow today. - -The current committed Playwright coverage is limited to browser login/logout round trips: - -- [tests/e2e/README.md](tests/e2e/README.md) -- [tests/e2e/test_auth_e2e.py](tests/e2e/test_auth_e2e.py) - -Those E2E tests verify: - -- a Cognito-backed login returns the user to `/` -- logout lands on `/login` or the Cognito Hosted UI logout/login pages -- a protected route redirects back through the auth flow after logout - -There are no checked-in rich Playwright report bundles or curated screenshot sets in the repo today, so the code and test files are the authoritative reference. +```bash +python -m pytest tests -q +python -m pytest tests/test_lsmc_ui_skin_system_contract.py -q +``` -## Contributing +Deployed browser evidence should target `https://bloom..dev.lsmc.bio` and include the dashboard, object search/detail, TapDB mount, graph, and auth redirect surfaces. Current `jemdev5` deployment evidence is linked from Dayhoff `docs/plans/20260618T132900Z_jemdev5_dayhoff_7067_live_deploy_ledger.md`. -Start from a feature branch, activate the repo-managed environment, and prefer Bloom's supported CLI path while working: +## Technical Details, History, And Linkouts -```bash -source ./activate -git switch -c codex/ -bloom config doctor -pytest --no-cov tests/test_execution_queue_api.py -q -ruff check bloom_lims tests -``` +- [`docs/apis.md`](docs/apis.md): API details. +- [`docs/gui.md`](docs/gui.md): GUI routes and screenshots when current. +- [`docs/lab_actions.md`](docs/lab_actions.md): extraction/QC/library/pooling/set/sequencing-run action flow, spreadsheet upload schemas, template matrix, and production rollout checklist. +- [`docs/architecture.md`](docs/architecture.md): domain model and runtime boundaries. +- [`docs/becoming_a_discoverable_service.md`](docs/becoming_a_discoverable_service.md): Dayhoff/Kahlo observability contract. +- [`docs/plans/`](docs/plans/): active ledgers. +- [`docs/old_docs/`](docs/old_docs/): historical material only. -When you are touching runtime behavior, update docs and examples to match the actual Bloom CLI. Older references in historical docs, activation banners, or service-catalog metadata may still mention retired commands like `bloom db init`; the current CLI help is authoritative. - -## Docs Navigation - -- [docs/architecture.md](docs/architecture.md): implementation structure, runtime layers, persistence, and integration boundaries -- [docs/apis.md](docs/apis.md): route groups, auth model, request/response examples, and messaging boundaries -- [docs/gui.md](docs/gui.md): screens, roles, browser auth flow, and graph/admin behavior -- [docs/how-tos.md](docs/how-tos.md): practical setup, config, test, and troubleshooting recipes -- [docs/becoming_a_discoverable_service.md](docs/becoming_a_discoverable_service.md): how Bloom fits Dayhoff's service catalog and deployment-scoped discovery model -- [docs/old_docs/README.md](docs/old_docs/README.md): historical background and retired planning material - -## Recommended Historical Reading - -These are worth reading for background, but they are not the current contract. Current code and the docs in this README's navigation section win when they disagree. - -- [docs/old_docs/README.md](docs/old_docs/README.md): the best map of what is archival versus still conceptually useful. -- [docs/old_docs/material_transfer_algebra_and_execution_envelope_constitution.md](docs/old_docs/material_transfer_algebra_and_execution_envelope_constitution.md): the strongest articulation of Bloom's material-primitive worldview. -- [docs/old_docs/ATLAS_BLOOM_CONTRACT_TESTS.md](docs/old_docs/ATLAS_BLOOM_CONTRACT_TESTS.md): good historical framing for Atlas-to-Bloom contract expectations. -- [docs/old_docs/tapdb_mount_completion_report.md](docs/old_docs/tapdb_mount_completion_report.md): useful context for why TapDB admin is embedded inside Bloom. -- [docs/old_docs/bloom_final_completion_report.md](docs/old_docs/bloom_final_completion_report.md): explains the queue-centric shift and the retirement of older workflow/workset product surfaces. -- [docs/old_docs/SEARCH_V2.md](docs/old_docs/SEARCH_V2.md): background on the unified search direction that the current code now implements under `/api/v1/search/v2`. - -## Glossary - -- **Bloom**: the service in this repo; authoritative for material primitives, lineage, and related queue/runtime state. -- **Dayhoff**: the deployment control plane that pins repos, activates them, starts services, and checks readiness. -- **TapDB**: the shared runtime/database layer Bloom delegates to for namespaced config, DB lifecycle, and the mounted admin app. -- **daycog / daylily-auth-cognito**: the shared Cognito library and CLI Bloom uses for browser auth and shared pool/app lifecycle. -- **Atlas**: the order, intake, and accession-oriented service that exchanges external references and status information with Bloom. -- **Dewey**: the artifact-oriented service Bloom calls when it needs to register or refer to analysis/storage outputs. -- **Ursa**: an adjacent Dayhoff service with its own integration gate in Bloom's RBAC/group model. -- **Zebra Day**: the label-printing service Bloom uses for printer discovery and print-job submission. -- **material primitive**: a lowest-level material record Bloom treats as first-class state, such as a specimen, sample, reagent, or container-backed material instance. -- **lineage**: the directed parent/child relationship graph that explains derivation, transfer, or containment history. -- **queue runtime**: the queue-centric beta execution surface Bloom exposes for current operator workflows. -- **deployment-scoped config**: config whose file path and runtime identity include the deploy name, such as `~/.config/bloom-/...`. -- **template pack**: the collection of template-backed definitions that describe which object categories, types, and subtypes may be created. -- **instance**: a concrete EUID-backed object created from a template or direct object-creation path. -- **workset / workflow**: older Bloom concepts that still exist historically in-repo but are not mounted as supported product API/GUI surfaces today. -- **Hosted UI**: Cognito's browser-facing login/logout flow used by Bloom's GUI session path. -- **discoverable service**: a service that follows Dayhoff's activation, bootstrap, start, and readiness conventions so the deployment bundle can locate and run it consistently. - +Current Bloom language should use Container and Material separation. Order, OrderTest, SubjectRef, AccessionCase, and fulfillment semantics are Atlas-owned or cross-service references, not Bloom-owned order lifecycle objects. diff --git a/bloom_lims/ai_agent_access.py b/bloom_lims/ai_agent_access.py new file mode 100644 index 0000000..2d18822 --- /dev/null +++ b/bloom_lims/ai_agent_access.py @@ -0,0 +1,159 @@ +"""Validation for Kahlo-issued read-only AI-agent tokens.""" + +from __future__ import annotations + +import hashlib +import json +import os +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from fastapi import Request + +AI_AGENT_TOKEN_PREFIX = "kahlo_ai_" + + +@dataclass(frozen=True) +class EndpointSpec: + endpoint_id: str + method: str + path_template: str + + +@dataclass(frozen=True) +class ValidatedAgentAccess: + token_id: str + agent_id: str + issued_by_email: str + endpoint_id: str + expires_at: str + + +class AgentTokenError(RuntimeError): + def __init__(self, detail: str, *, status_code: int = 401) -> None: + super().__init__(detail) + self.detail = detail + self.status_code = status_code + + +ENDPOINT_CATALOG: tuple[EndpointSpec, ...] = ( + EndpointSpec("bloom.search.query", "POST", "/api/v1/search/v2/query"), + EndpointSpec("bloom.objects.list", "GET", "/api/v1/objects"), + EndpointSpec("bloom.objects.detail", "GET", "/api/v1/objects/{euid}"), + EndpointSpec("bloom.containers.list", "GET", "/api/v1/containers"), + EndpointSpec("bloom.containers.detail", "GET", "/api/v1/containers/{euid}"), + EndpointSpec("bloom.content.list", "GET", "/api/v1/content"), + EndpointSpec("bloom.content.detail", "GET", "/api/v1/content/{euid}"), + EndpointSpec("bloom.graph.detail", "GET", "/api/v1/graph/{euid}"), +) + + +def is_ai_agent_token(token: str) -> bool: + return str(token or "").startswith(AI_AGENT_TOKEN_PREFIX) + + +def _truthy_env(name: str) -> bool: + return str(os.environ.get(name) or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _template_matches(template: str, path: str) -> bool: + template_parts = [part for part in template.strip("/").split("/") if part] + path_parts = [part for part in path.strip("/").split("/") if part] + if len(template_parts) != len(path_parts): + return False + for template_part, path_part in zip(template_parts, path_parts, strict=True): + if template_part.startswith("{") and template_part.endswith("}"): + if not path_part: + return False + continue + if template_part != path_part: + return False + return True + + +def _endpoint_id_for_request(method: str, path: str) -> str: + resolved_method = str(method or "").upper() + resolved_path = str(path or "").split("?", 1)[0].rstrip("/") or "/" + for spec in ENDPOINT_CATALOG: + if spec.method == resolved_method and _template_matches( + spec.path_template, resolved_path + ): + return spec.endpoint_id + raise AgentTokenError( + "AI-agent token is not authorized for this endpoint", status_code=403 + ) + + +def _parse_expiry(value: str) -> datetime: + raw = str(value or "").strip() + if raw.endswith("Z"): + raw = f"{raw[:-1]}+00:00" + parsed = datetime.fromisoformat(raw) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _load_grants(path: Path) -> list[dict[str, Any]]: + if not path.is_absolute(): + raise AgentTokenError( + "AI-agent grant store path must be absolute", status_code=500 + ) + if not path.exists(): + raise AgentTokenError("AI-agent grant store is missing", status_code=500) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise AgentTokenError( + "AI-agent grant store is malformed", status_code=500 + ) from exc + if not isinstance(payload, dict) or not isinstance(payload.get("tokens"), list): + raise AgentTokenError( + "AI-agent grant store must contain a tokens list", status_code=500 + ) + return [record for record in payload["tokens"] if isinstance(record, dict)] + + +def validate_ai_agent_request(request: Request, token: str) -> ValidatedAgentAccess: + if not is_ai_agent_token(token): + raise AgentTokenError("Not an AI-agent token") + if not _truthy_env("LSMC_AI_AGENT_ACCESS_ENABLED"): + raise AgentTokenError("AI-agent token access is not enabled", status_code=503) + raw_path = str(os.environ.get("LSMC_AI_AGENT_GRANTS_PATH") or "").strip() + if not raw_path: + raise AgentTokenError("LSMC_AI_AGENT_GRANTS_PATH is required", status_code=500) + + endpoint_id = _endpoint_id_for_request(request.method, request.url.path) + token_digest = hashlib.sha256(token.encode("utf-8")).hexdigest() + for record in _load_grants(Path(raw_path)): + if not secrets.compare_digest( + str(record.get("token_hash") or ""), token_digest + ): + continue + if record.get("revoked_at"): + raise AgentTokenError("AI-agent token has been revoked") + expires_at = _parse_expiry(str(record.get("expires_at") or "")) + if datetime.now(UTC) >= expires_at: + raise AgentTokenError("AI-agent token has expired") + endpoint_ids = [str(item) for item in record.get("endpoint_ids") or []] + if endpoint_id not in endpoint_ids: + raise AgentTokenError( + "AI-agent token is not authorized for this endpoint", status_code=403 + ) + validated = ValidatedAgentAccess( + token_id=str(record.get("token_id") or ""), + agent_id=str(record.get("agent_id") or ""), + issued_by_email=str(record.get("issued_by_email") or ""), + endpoint_id=endpoint_id, + expires_at=str(record.get("expires_at") or ""), + ) + request.state.auth_mode = "ai_agent_token" + request.state.agent_id = validated.agent_id + request.state.authorized_by_email = validated.issued_by_email + request.state.agent_token_id = validated.token_id + request.state.agent_endpoint_id = validated.endpoint_id + return validated + raise AgentTokenError("AI-agent token is unknown") diff --git a/bloom_lims/anomalies.py b/bloom_lims/anomalies.py index acb9435..9684423 100644 --- a/bloom_lims/anomalies.py +++ b/bloom_lims/anomalies.py @@ -15,7 +15,7 @@ from bloom_lims.config import get_settings from bloom_lims.observability import ProjectionMetadata -ANOMALY_TEMPLATE_CODE = "bloom/ops/anomaly-record/1.0/" +ANOMALY_TEMPLATE_CODE = "ops/anomaly-record/generic/1.0/" ANOMALY_PREFIX = "BAN" diff --git a/bloom_lims/api/v1/__init__.py b/bloom_lims/api/v1/__init__.py index 2591e88..5c8dd61 100644 --- a/bloom_lims/api/v1/__init__.py +++ b/bloom_lims/api/v1/__init__.py @@ -16,15 +16,18 @@ from .admin_auth import router as admin_auth_router from .async_tasks import router as async_tasks_router from .atlas_bridge import router as atlas_bridge_router +from .auth import preferences_router from .auth import router as auth_router from .batch import router as batch_router from .beta_lab import router as beta_lab_router +from .container_actions import router as container_actions_router from .containers import router as containers_router from .content import router as content_router from .equipment import router as equipment_router from .execution_queue import router as execution_queue_router from .external_specimens import router as external_specimens_router from .graph import router as graph_router +from .lab_actions import router as lab_actions_router from .lineages import router as lineages_router from .object_creation import router as object_creation_router from .objects import router as objects_router @@ -41,7 +44,9 @@ # Include sub-routers router.include_router(objects_router) router.include_router(auth_router) +router.include_router(preferences_router) router.include_router(containers_router) +router.include_router(container_actions_router) router.include_router(content_router) router.include_router(equipment_router) router.include_router(execution_queue_router) @@ -60,6 +65,7 @@ router.include_router(atlas_bridge_router) router.include_router(beta_lab_router) router.include_router(graph_router) +router.include_router(lab_actions_router) @router.get("/") @@ -73,6 +79,7 @@ async def api_v1_info(): "objects": "/api/v1/objects", "auth": "/api/v1/auth", "containers": "/api/v1/containers", + "container_actions": "/api/v1/container-actions", "content": "/api/v1/content", "equipment": "/api/v1/equipment", "execution": "/api/v1/execution", @@ -90,5 +97,6 @@ async def api_v1_info(): "external_atlas": "/api/v1/external/atlas", "external_atlas_beta": "/api/v1/external/atlas/beta", "graph": "/api/v1/graph", + "lab_actions": "/api/v1/lab-actions", }, } diff --git a/bloom_lims/api/v1/auth.py b/bloom_lims/api/v1/auth.py index fbfcbb0..e0a32e7 100644 --- a/bloom_lims/api/v1/auth.py +++ b/bloom_lims/api/v1/auth.py @@ -5,14 +5,42 @@ """ import logging +import os +from urllib.parse import quote -from fastapi import APIRouter, Depends +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request from .dependencies import APIUser, require_api_auth logger = logging.getLogger(__name__) +THEME_NAMES = {"original", "light", "dark", "ssf", "viridis", "viridis-dark"} router = APIRouter(prefix="/auth", tags=["Authentication"]) +preferences_router = APIRouter(tags=["Preferences"]) + + +def _broker_preferences_contract(email: str) -> tuple[str, dict[str, str]]: + raw_url = str(os.environ.get("LSMC_AUTH_BROKER_USER_PREFERENCES_URL") or "").strip() + token = str(os.environ.get("LSMC_AUTH_BROKER_SERVICE_TOKEN") or "").strip() + service_id = str(os.environ.get("LSMC_AUTH_BROKER_SERVICE_ID") or "bloom").strip() + if not raw_url: + raise HTTPException( + status_code=503, detail="Broker user preferences URL is not configured" + ) + if not token: + raise HTTPException( + status_code=503, detail="Broker service token is not configured" + ) + if "{email}" not in raw_url: + raise HTTPException( + status_code=503, + detail="Broker user preferences URL must include {email}", + ) + return raw_url.replace("{email}", quote(email, safe="")), { + "Authorization": f"Bearer {token}", + "X-LSMC-Service-ID": service_id, + } @router.get("/me") @@ -31,6 +59,60 @@ async def get_current_user(user: APIUser = Depends(require_api_auth)): } +@preferences_router.get("/me/preferences") +async def current_user_preferences(user: APIUser = Depends(require_api_auth)): + if not user.email: + raise HTTPException( + status_code=400, detail="Authenticated user email is required" + ) + url, headers = _broker_preferences_contract(user.email) + with httpx.Client(timeout=5.0) as client: + response = client.get(url, headers=headers) + if response.status_code >= 400: + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() + + +@preferences_router.put("/me/preferences") +async def update_current_user_preferences( + request: Request, + user: APIUser = Depends(require_api_auth), +): + if not user.email: + raise HTTPException( + status_code=400, detail="Authenticated user email is required" + ) + payload = await request.json() + theme = str(payload.get("theme") or "").strip() + if theme and theme not in THEME_NAMES: + raise HTTPException(status_code=400, detail="Unknown theme") + service_themes = payload.get("service_themes") + if service_themes is not None: + if not isinstance(service_themes, dict): + raise HTTPException( + status_code=400, detail="service_themes must be an object" + ) + for service_theme in service_themes.values(): + if ( + service_theme is not None + and str(service_theme).strip() not in THEME_NAMES + ): + raise HTTPException(status_code=400, detail="Unknown theme") + forward_payload = {} + if "theme" in payload: + forward_payload["theme"] = theme or None + if service_themes is not None: + forward_payload["service_themes"] = service_themes + url, headers = _broker_preferences_contract(user.email) + with httpx.Client(timeout=5.0) as client: + response = client.put(url, headers=headers, json=forward_payload) + if response.status_code < 400: + response = client.get(url, headers=headers) + if response.status_code >= 400: + raise HTTPException(status_code=response.status_code, detail=response.text) + return response.json() + + @router.post("/logout") async def logout(): """Logout current user. diff --git a/bloom_lims/api/v1/container_actions.py b/bloom_lims/api/v1/container_actions.py new file mode 100644 index 0000000..01cc548 --- /dev/null +++ b/bloom_lims/api/v1/container_actions.py @@ -0,0 +1,259 @@ +"""Generic Bloom container-action validation facade.""" + +from __future__ import annotations + +import csv +from io import StringIO +from typing import Any, Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field, ValidationError +from sqlalchemy import select + +from bloom_lims.domain.lab_actions import LabActionsService +from bloom_lims.schemas.lab_actions import ( + ExtractionPlateRequest, + ExtractionQcPlateRequest, + LabSetRequest, + PlateWellDataRequest, + PrintEuidRequest, + SeqLibraryPlateRequest, + SeqLibraryPoolRequest, + SeqRunSetRequest, +) +from bloom_lims.tapdb_adapter import BLOOMdb3, generic_instance + +from .dependencies import APIUser, require_write + +router = APIRouter(prefix="/container-actions", tags=["Container Actions"]) + + +class ContainerActionScanRequest(BaseModel): + rows: str | None = None + records: list[dict[str, Any]] = Field(default_factory=list) + default_target: str | None = None + child_name: str | None = None + child_description: str | None = None + child_content_type: str | None = None + + +ContainerActionOperation = Literal[ + "extraction_plate", + "extraction_qc_plate", + "plate_well_data", + "seq_library_plate", + "seq_library_pool", + "seq_run_set", + "lab_set", + "print_euids", +] + + +class ContainerActionExecuteRequest(ContainerActionScanRequest): + operation_type: ContainerActionOperation | None = None + operation_payload: dict[str, Any] | None = None + + +def _service_for_user(user: APIUser) -> LabActionsService: + return LabActionsService( + BLOOMdb3(app_username=user.email), + user_id=user.user_id, + email=user.email, + ) + + +def _split_source(value: str) -> tuple[str, str | None]: + source = str(value or "").strip() + if not source: + raise ValueError("source EUID is required") + if "." not in source: + return source, None + euid, well = source.rsplit(".", 1) + euid = euid.strip() + well = well.strip().upper() + if not euid or not well: + raise ValueError(f"Invalid source well reference: {value!r}") + return euid, well + + +def _normalize_record( + row: dict[str, Any], default_target: str | None +) -> dict[str, Any]: + source = ( + row.get("source") or row.get("source_euid") or row.get("euid") or row.get("0") + ) + target = ( + row.get("target") + or row.get("target_container_euid") + or row.get("target_container") + or row.get("1") + or default_target + ) + target_well = row.get("target_well") or row.get("well") or row.get("2") + source_euid, source_well = _split_source(str(source or "")) + target_text = str(target or "").strip() + if not target_text: + raise ValueError(f"Target is required for source {source_euid}") + create_type = None + target_euid = target_text + if target_text.upper().startswith("CREATE."): + create_type = target_text.split(".", 1)[1].strip() + target_euid = None + if not create_type: + raise ValueError(f"CREATE target requires a type for source {source_euid}") + return { + "source_euid": source_euid, + "source_well": source_well, + "target_euid": target_euid, + "create_type": create_type, + "target_well": str(target_well or "").strip().upper() or None, + "mapping_mode": "directed" if target_well else "auto", + } + + +def _parse_rows(payload: ContainerActionScanRequest) -> list[dict[str, Any]]: + records = list(payload.records) + if payload.rows: + sample = payload.rows[:1024] + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",\t") + except csv.Error: + dialect = csv.excel + reader = csv.reader(StringIO(payload.rows), dialect) + for row in reader: + if not row or not any(str(cell).strip() for cell in row): + continue + records.append({str(index): value for index, value in enumerate(row)}) + if not records: + raise ValueError("rows or records is required") + return [_normalize_record(row, payload.default_target) for row in records] + + +def _object_kind(euid: str, user: APIUser) -> dict[str, Any]: + db = BLOOMdb3(app_username=user.email) + try: + instance = db.session.execute( + select(generic_instance).where( + generic_instance.euid == euid, + generic_instance.is_deleted.is_(False), + ) + ).scalar_one_or_none() + if instance is None: + return {"euid": euid, "exists": False, "kind": None} + return { + "euid": euid, + "exists": True, + "kind": "/".join( + [ + str(getattr(instance, "category", "") or ""), + str(getattr(instance, "type", "") or ""), + str(getattr(instance, "subtype", "") or ""), + str(getattr(instance, "version", "") or ""), + ] + ), + } + finally: + db.close() + + +@router.post("/validate") +async def validate_container_action( + payload: ContainerActionScanRequest, + user: APIUser = Depends(require_write), +): + try: + planned = _parse_rows(payload) + source_types = { + row["source_euid"]: _object_kind(row["source_euid"], user) + for row in planned + } + target_types = { + row["target_euid"]: _object_kind(row["target_euid"], user) + for row in planned + if row["target_euid"] + } + return { + "valid": True, + "row_count": len(planned), + "planned": planned, + "source_types": source_types, + "target_types": target_types, + "creates": [row for row in planned if row["create_type"]], + } + except (ValueError, ValidationError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/execute") +async def execute_container_action( + payload: ContainerActionExecuteRequest, + user: APIUser = Depends(require_write), +): + validation = await validate_container_action(payload, user) + if not payload.operation_type: + raise HTTPException( + status_code=400, + detail={ + "message": ( + "operation_type is required. Container actions do not infer " + "execution behavior from scanned rows." + ), + "validation": validation, + }, + ) + if not payload.operation_payload: + raise HTTPException( + status_code=400, + detail={ + "message": "operation_payload is required for container-action execution.", + "operation_type": payload.operation_type, + "validation": validation, + }, + ) + + service = _service_for_user(user) + try: + operation_payload = payload.operation_payload + if payload.operation_type == "extraction_plate": + result = service.create_extraction_plate( + ExtractionPlateRequest.model_validate(operation_payload) + ) + elif payload.operation_type == "extraction_qc_plate": + result = service.fill_extraction_qc_plate( + ExtractionQcPlateRequest.model_validate(operation_payload) + ) + elif payload.operation_type == "plate_well_data": + result = service.attach_plate_well_data( + PlateWellDataRequest.model_validate(operation_payload) + ) + elif payload.operation_type == "seq_library_plate": + result = service.create_seq_library_plate( + SeqLibraryPlateRequest.model_validate(operation_payload) + ) + elif payload.operation_type == "seq_library_pool": + result = service.create_seq_library_pool( + SeqLibraryPoolRequest.model_validate(operation_payload) + ) + elif payload.operation_type == "seq_run_set": + result = service.create_seq_run_set( + SeqRunSetRequest.model_validate(operation_payload) + ) + elif payload.operation_type == "lab_set": + result = service.create_lab_set( + LabSetRequest.model_validate(operation_payload) + ) + elif payload.operation_type == "print_euids": + result = service.print_euids( + PrintEuidRequest.model_validate(operation_payload) + ) + else: # pragma: no cover - Literal validation should prevent this. + raise HTTPException(status_code=400, detail="Unsupported operation_type") + return { + "operation_type": payload.operation_type, + "validation": validation, + "result": result, + } + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + finally: + service.close() diff --git a/bloom_lims/api/v1/dependencies.py b/bloom_lims/api/v1/dependencies.py index e471ce9..a6c6de9 100644 --- a/bloom_lims/api/v1/dependencies.py +++ b/bloom_lims/api/v1/dependencies.py @@ -11,6 +11,11 @@ from fastapi import Depends, Header, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from bloom_lims.ai_agent_access import ( + AgentTokenError, + is_ai_agent_token, + validate_ai_agent_request, +) from bloom_lims.auth.rbac import ( API_ACCESS_GROUP, ENABLE_ATLAS_API_GROUP, @@ -286,6 +291,8 @@ async def get_api_user( if isinstance(user_data.get("groups"), list) else [] ) + request.state.auth_mode = "session" + request.state.authorized_by_email = user_data.get("email", "session-user") return _make_user( email=user_data.get("email", "session-user"), user_id=user_data.get("sub") or user_data.get("user_id"), @@ -296,11 +303,38 @@ async def get_api_user( if credentials: token = (credentials.credentials or "").strip() + if is_ai_agent_token(token): + try: + grant = validate_ai_agent_request(request, token) + except AgentTokenError as exc: + raise HTTPException( + status_code=exc.status_code, + detail=exc.detail, + headers={"WWW-Authenticate": "Bearer"} + if exc.status_code == 401 + else None, + ) from exc + return APIUser( + email=grant.issued_by_email or "ai-agent@kahlo.local", + user_id=f"ai-agent:{grant.agent_id}", + roles=[Role.READ_ONLY.value], + groups=["ai-agent"], + permissions=sorted(effective_permissions([Role.READ_ONLY.value])), + auth_source="ai_agent_token", + is_service_account=True, + token_scope="read:search", + token_id=grant.token_id, + ) if token.startswith(TOKEN_PREFIX): - return _authenticate_bloom_token(request, token) + user = _authenticate_bloom_token(request, token) + request.state.auth_mode = user.auth_source + request.state.authorized_by_email = user.email + return user if _cognito_get_user is not None: try: claims = _cognito_get_user(credentials) + request.state.auth_mode = "cognito" + request.state.authorized_by_email = claims.get("email", "token-user") return _make_user( email=claims.get("email", "token-user"), user_id=claims.get("sub"), diff --git a/bloom_lims/api/v1/external_specimens.py b/bloom_lims/api/v1/external_specimens.py index 67af94c..de70ee3 100644 --- a/bloom_lims/api/v1/external_specimens.py +++ b/bloom_lims/api/v1/external_specimens.py @@ -127,6 +127,7 @@ async def find_external_specimens_by_reference( trf_euid: str | None = Query(None), patient_id: str | None = Query(None), order_euid: str | None = Query(None), + order_test_euid: str | None = Query(None), shipment_euid: str | None = Query(None), kit_barcode: str | None = Query(None), atlas_tenant_id: str | None = Query(None), @@ -138,6 +139,7 @@ async def find_external_specimens_by_reference( trf_euid=trf_euid, patient_id=patient_id, order_euid=order_euid, + order_test_euid=order_test_euid, shipment_euid=shipment_euid, kit_barcode=kit_barcode, atlas_tenant_id=atlas_tenant_id, diff --git a/bloom_lims/api/v1/lab_actions.py b/bloom_lims/api/v1/lab_actions.py new file mode 100644 index 0000000..4698baa --- /dev/null +++ b/bloom_lims/api/v1/lab_actions.py @@ -0,0 +1,246 @@ +"""Bloom lab action APIs.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import PlainTextResponse + +from bloom_lims.domain.lab_actions import LabActionsService +from bloom_lims.schemas.lab_actions import ( + ExtractionPlateRequest, + ExtractionQcPlateRequest, + LabSetMembersRequest, + LabSetRequest, + PlateWellDataRequest, + PrintEuidRequest, + SeqLibraryPlateRequest, + SeqLibraryPoolRequest, + SeqRunSetRequest, +) +from bloom_lims.tapdb_adapter import BLOOMdb3 + +from .dependencies import APIUser, require_read, require_write + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/lab-actions", tags=["Lab Actions"]) + + +def _service_for_user(user: APIUser) -> LabActionsService: + return LabActionsService( + BLOOMdb3(app_username=user.email), + user_id=user.user_id, + email=user.email, + ) + + +def _raise_http(exc: Exception) -> None: + if isinstance(exc, ValueError): + raise HTTPException(status_code=400, detail=str(exc)) from exc + logger.exception("Bloom lab action failed") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/extraction-plates") +async def create_extraction_plate( + payload: ExtractionPlateRequest, + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + return service.create_extraction_plate(payload) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.post("/extraction-qc-plates") +async def fill_extraction_qc_plate( + payload: ExtractionQcPlateRequest, + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + return service.fill_extraction_qc_plate(payload) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.post("/plate-well-data") +async def attach_plate_well_data( + payload: PlateWellDataRequest, + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + return service.attach_plate_well_data(payload) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.post("/seq-library-plates") +async def create_seq_library_plate( + payload: SeqLibraryPlateRequest, + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + return service.create_seq_library_plate(payload) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.post("/seq-library-pools") +async def create_seq_library_pool( + payload: SeqLibraryPoolRequest, + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + return service.create_seq_library_pool(payload) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.post("/seq-runs") +async def create_seq_run_set( + payload: SeqRunSetRequest, + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + return service.create_seq_run_set(payload) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.post("/sets") +async def create_lab_set( + payload: LabSetRequest, + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + return service.create_lab_set(payload) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.get("/sets/{set_euid}") +async def get_lab_set( + set_euid: str, + user: APIUser = Depends(require_read), +): + service = _service_for_user(user) + try: + return service.get_lab_set(set_euid) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.post("/sets/{set_euid}/members") +async def add_lab_set_members( + set_euid: str, + payload: LabSetMembersRequest, + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + return service.add_lab_set_members(set_euid, payload) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.get("/seq-runs/{set_euid}/samplesheet") +async def download_seq_run_sample_sheet( + set_euid: str, + user: APIUser = Depends(require_read), +): + service = _service_for_user(user) + try: + content, filename, media_type = service.sequencing_sample_sheet_download( + set_euid + ) + return PlainTextResponse( + content, + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.get("/plates/{plate_euid}/mapping.csv") +async def download_plate_mapping_csv( + plate_euid: str, + user: APIUser = Depends(require_read), +): + service = _service_for_user(user) + try: + content = service.plate_mapping_csv(plate_euid) + return PlainTextResponse( + content, + media_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="{plate_euid}_mapping.csv"' + }, + ) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.post("/spreadsheet-import") +async def import_lab_action_spreadsheet( + file: UploadFile = File(...), + dry_run: bool = Query(True), + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + data = await file.read() + return service.import_spreadsheet( + filename=file.filename or "upload.xlsx", + data=data, + dry_run=dry_run, + ) + except Exception as exc: + _raise_http(exc) + finally: + service.close() + + +@router.post("/print-euids") +async def print_euids( + payload: PrintEuidRequest, + user: APIUser = Depends(require_write), +): + service = _service_for_user(user) + try: + return service.print_euids(payload) + except Exception as exc: + _raise_http(exc) + finally: + service.close() diff --git a/bloom_lims/api/v1/object_creation.py b/bloom_lims/api/v1/object_creation.py index 98ea55d..6eeb556 100644 --- a/bloom_lims/api/v1/object_creation.py +++ b/bloom_lims/api/v1/object_creation.py @@ -4,12 +4,13 @@ Endpoints to support the multi-step object creation workflow. """ +import copy import logging import re from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, Field from bloom_lims.config import get_settings from bloom_lims.integrations.atlas.events import emit_bloom_event @@ -72,6 +73,12 @@ class CreateObjectRequest(BaseModel): version: str name: Optional[str] = None properties: Optional[Dict[str, Any]] = None + count: int = Field( + default=1, + ge=1, + le=100, + description="Number of sibling objects to create from the same template.", + ) class CreateObjectResponse(BaseModel): @@ -83,6 +90,7 @@ class CreateObjectResponse(BaseModel): type: str subtype: str message: str + created_euids: list[str] = Field(default_factory=list) def get_bdb(username: str = "api-user"): @@ -97,6 +105,19 @@ def _template_payload(template_row) -> Dict[str, Any]: return template_payload(template_row) +def _is_singleton_template(template_row) -> bool: + payload = _template_payload(template_row) + singleton_value = payload.get("singleton") + return bool(getattr(template_row, "is_singleton", False)) or str( + singleton_value + ).strip().lower() in {"1", "true", "yes", "on"} + + +def _has_recursive_layout(template_row) -> bool: + layouts = _template_payload(template_row).get("instantiation_layouts") + return bool(layouts) + + def _sort_key(value: str | None) -> tuple[str, str]: text = str(value or "") return text.casefold(), text @@ -433,60 +454,88 @@ async def create_object( ) template = templates[0] - - # Prepare json_addl overrides - json_addl_overrides = {} - if request.properties: - json_addl_overrides["properties"] = request.properties - if request.name: - json_addl_overrides["properties"] = json_addl_overrides.get( - "properties", {} + if request.count > 1 and _is_singleton_template(template): + raise HTTPException( + status_code=400, + detail="count > 1 is not allowed for singleton templates", ) - json_addl_overrides["properties"]["name"] = request.name - - # Create instance from template using the create_instance method - new_instance = bloom_obj.create_instance( - template.euid, json_addl_overrides=json_addl_overrides - ) - - if not new_instance: + if request.count > 1 and _has_recursive_layout(template): raise HTTPException( - status_code=500, detail="Failed to create object instance" + status_code=400, + detail=( + "count > 1 is not allowed for recursive templates; " + "create recursive containers one at a time" + ), ) - bdb.session.commit() - bloom_obj.track_user_interaction( - new_instance.euid, - relationship_type="user_created", - user_id=user.user_id, - email=user.email, - ) - - if instance_semantic_category(new_instance) == "container": - emit_bloom_event( - "container.created", - { - "euid": new_instance.euid, - "container_euid": new_instance.euid, - "name": new_instance.name, - "category": new_instance.category, - "type": new_instance.type, - "subtype": new_instance.subtype, - "status": new_instance.bstatus, - "json_addl": new_instance.json_addl - if isinstance(new_instance.json_addl, dict) - else {}, - "is_deleted": bool(getattr(new_instance, "is_deleted", False)), - }, + created_instances = [] + for index in range(request.count): + # Prepare fresh json_addl overrides for every object. Reusing the same dict + # can leak generated child state across recursive template instantiations. + json_addl_overrides = {} + if request.properties: + json_addl_overrides["properties"] = copy.deepcopy(request.properties) + if request.name: + json_addl_overrides["properties"] = json_addl_overrides.get( + "properties", {} + ) + json_addl_overrides["properties"]["name"] = ( + f"{request.name} {index + 1}" if request.count > 1 else request.name + ) + + # Create the object and any nested children declared by the template. + created = bloom_obj.create_instances( + template.euid, json_addl_overrides=json_addl_overrides + ) + new_instance = created[0][0] if created and created[0] else None + if not new_instance: + raise HTTPException( + status_code=500, detail="Failed to create object instance" + ) + if request.name: + new_instance.name = json_addl_overrides["properties"]["name"] + bdb.session.flush() + bloom_obj.track_user_interaction( + new_instance.euid, + relationship_type="user_created", + user_id=user.user_id, + email=user.email, ) + if instance_semantic_category(new_instance) == "container": + emit_bloom_event( + "container.created", + { + "euid": new_instance.euid, + "container_euid": new_instance.euid, + "name": new_instance.name, + "category": new_instance.category, + "type": new_instance.type, + "subtype": new_instance.subtype, + "status": new_instance.bstatus, + "json_addl": new_instance.json_addl + if isinstance(new_instance.json_addl, dict) + else {}, + "is_deleted": bool(getattr(new_instance, "is_deleted", False)), + }, + ) + created_instances.append(new_instance) + + bdb.session.commit() + new_instance = created_instances[0] + created_euids = [instance.euid for instance in created_instances] return CreateObjectResponse( euid=new_instance.euid, name=new_instance.name or "", category=new_instance.category, type=new_instance.type, subtype=new_instance.subtype, - message=f"Successfully created {new_instance.euid}", + message=( + f"Successfully created {new_instance.euid}" + if request.count == 1 + else f"Successfully created {len(created_euids)} objects" + ), + created_euids=created_euids, ) except HTTPException: raise diff --git a/bloom_lims/api/v1/objects.py b/bloom_lims/api/v1/objects.py index 95bf1a5..77c6d25 100644 --- a/bloom_lims/api/v1/objects.py +++ b/bloom_lims/api/v1/objects.py @@ -4,6 +4,7 @@ CRUD endpoints for BloomObj (generic instances). """ +import copy import logging from typing import Any, Dict, Optional @@ -13,6 +14,7 @@ ObjectCreateSchema, ObjectUpdateSchema, ) +from bloom_lims.template_identity import template_payload from .dependencies import APIUser, require_read, require_write @@ -22,6 +24,19 @@ router = APIRouter(prefix="/objects", tags=["Objects"]) +def _is_singleton_template(template_row) -> bool: + payload = template_payload(template_row) + singleton_value = payload.get("singleton") + return bool(getattr(template_row, "is_singleton", False)) or str( + singleton_value + ).strip().lower() in {"1", "true", "yes", "on"} + + +def _has_recursive_layout(template_row) -> bool: + layouts = template_payload(template_row).get("instantiation_layouts") + return bool(layouts) + + @router.get("/", response_model=Dict[str, Any]) async def list_objects( type: Optional[str] = Query(None, description="Filter by type"), @@ -161,28 +176,58 @@ async def create_object( status_code=400, detail=f"No template found for {category}/{data.type}/{data.subtype or '*'}@{version}", ) + template = templates[0] + if data.count > 1 and _is_singleton_template(template): + raise HTTPException( + status_code=400, + detail="count > 1 is not allowed for singleton templates", + ) + if data.count > 1 and _has_recursive_layout(template): + raise HTTPException( + status_code=400, + detail=( + "count > 1 is not allowed for recursive templates; " + "create recursive containers one at a time" + ), + ) - json_overrides = data.json_addl or {} - props = json_overrides.get("properties") - if not isinstance(props, dict): - props = {} - props.setdefault("name", data.name) - json_overrides["properties"] = props + created_objects = [] + for index in range(data.count): + json_overrides = copy.deepcopy(data.json_addl or {}) + props = json_overrides.get("properties") + if not isinstance(props, dict): + props = {} + props.setdefault( + "name", f"{data.name} {index + 1}" if data.count > 1 else data.name + ) + json_overrides["properties"] = props + + created = bo.create_instances( + template.euid, json_addl_overrides=json_overrides + ) + obj = created[0][0] if created and created[0] else None + if obj is None: + raise HTTPException(status_code=500, detail="Failed to create object") + obj.name = props["name"] + bdb.session.flush() + bo.track_user_interaction( + obj.euid, + relationship_type="user_created", + user_id=user.user_id, + email=user.email, + ) + created_objects.append(obj) - obj = bo.create_instance(templates[0].euid, json_overrides) - obj.name = data.name bdb.session.commit() - bo.track_user_interaction( - obj.euid, - relationship_type="user_created", - user_id=user.user_id, - email=user.email, - ) + obj = created_objects[0] return { "success": True, "euid": obj.euid, - "message": "Object created successfully", + "created_euids": [created.euid for created in created_objects], + "message": "Object created successfully" + if data.count == 1 + else f"{len(created_objects)} objects created successfully", } except Exception as e: diff --git a/bloom_lims/app.py b/bloom_lims/app.py index d7eb2f0..efd1bef 100644 --- a/bloom_lims/app.py +++ b/bloom_lims/app.py @@ -5,8 +5,10 @@ from __future__ import annotations +import json import logging import os +import sys from contextlib import asynccontextmanager from time import monotonic from uuid import uuid4 @@ -40,6 +42,55 @@ ) +def _access_log_payload( + *, + request, + service_id: str, + status_code: int, + duration_ms: float, + route_template: str, +) -> dict[str, object]: + actor = ( + getattr(request.state, "authorized_by_email", None) + or getattr(request.state, "authorizing_human", None) + or getattr(request.state, "actor", None) + ) + ai_agent_id = getattr(request.state, "ai_agent_id", None) or getattr( + request.state, "agent_id", None + ) + return { + "event": "request_completed", + "request_id": getattr(request.state, "request_id", ""), + "correlation_id": getattr(request.state, "correlation_id", ""), + "service_id": service_id, + "actor": actor, + "ai_agent_id": ai_agent_id, + "authorizing_human": getattr(request.state, "authorizing_human", None) + or getattr(request.state, "authorized_by_email", None), + "ip": request.client.host if request.client else None, + "method": request.method, + "path": request.url.path, + "route": route_template or request.url.path, + "route_template": route_template or request.url.path, + "status": status_code, + "duration_ms": round(duration_ms, 2), + "denial_reason": getattr(request.state, "denial_reason", None) + or (f"http_{status_code}" if status_code in {401, 403} else None), + "auth_mode": getattr(request.state, "auth_mode", None), + } + + +def _emit_access_log(payload: dict[str, object], *, level: int = logging.INFO) -> None: + message = json.dumps(payload, sort_keys=True, separators=(",", ":")) + logging.getLogger("lsmc.access").log( + level, + message, + extra=payload, + ) + sys.stdout.write(f"{message}\n") + sys.stdout.flush() + + def _validate_required_config(settings) -> None: """Fail hard at startup if critical configuration is missing. @@ -116,23 +167,49 @@ async def _observability_request_context(request, call_next): except Exception: route = request.scope.get("route") route_template = getattr(route, "path", "") + duration_ms = (monotonic() - started) * 1000 if route_template: app.state.observability.record_http_request( method=request.method, route_template=route_template, status_code=500, - duration_ms=(monotonic() - started) * 1000, + duration_ms=duration_ms, ) + _emit_access_log( + _access_log_payload( + request=request, + service_id="bloom", + status_code=500, + duration_ms=duration_ms, + route_template=route_template, + ), + level=logging.ERROR, + ) raise route = request.scope.get("route") route_template = getattr(route, "path", "") + duration_ms = (monotonic() - started) * 1000 if route_template: app.state.observability.record_http_request( method=request.method, route_template=route_template, status_code=response.status_code, - duration_ms=(monotonic() - started) * 1000, + duration_ms=duration_ms, ) + _emit_access_log( + _access_log_payload( + request=request, + service_id="bloom", + status_code=response.status_code, + duration_ms=duration_ms, + route_template=route_template, + ), + level=logging.ERROR + if response.status_code >= 500 + else logging.WARNING + if response.status_code >= 400 + else logging.INFO, + ) return response # Request attribution context for TapDB-style DB metrics. @@ -193,14 +270,9 @@ async def _enforce_origin_allowlist(request, call_next): app.include_router(probe_router) app.include_router(observability_router) app.include_router(api_v1_router) - try: - from bloom_lims.gui.router import router as gui_router - except ModuleNotFoundError as exc: - logging.warning( - "Skipping GUI router due to missing optional dependency: %s", exc.name - ) - else: - app.include_router(gui_router) + from bloom_lims.gui.router import router as gui_router + + app.include_router(gui_router) register_exception_handlers(app) return app diff --git a/bloom_lims/auth/repositories/tapdb/groups.py b/bloom_lims/auth/repositories/tapdb/groups.py index 19a87dd..2c30f5b 100644 --- a/bloom_lims/auth/repositories/tapdb/groups.py +++ b/bloom_lims/auth/repositories/tapdb/groups.py @@ -22,8 +22,8 @@ ) from bloom_lims.config import get_settings -GROUP_TEMPLATE_CODE = "BBX/auth/user-group/1.0/" -GROUP_MEMBERSHIP_TEMPLATE_CODE = "BBX/auth/user-group-membership/1.0/" +GROUP_TEMPLATE_CODE = "auth/user-group/generic/1.0/" +GROUP_MEMBERSHIP_TEMPLATE_CODE = "auth/user-group-membership/generic/1.0/" GROUP_PREFIX = "BBX" GROUP_MEMBERSHIP_PREFIX = "BBX" diff --git a/bloom_lims/auth/repositories/tapdb/user_api_tokens.py b/bloom_lims/auth/repositories/tapdb/user_api_tokens.py index d59d853..49df31f 100644 --- a/bloom_lims/auth/repositories/tapdb/user_api_tokens.py +++ b/bloom_lims/auth/repositories/tapdb/user_api_tokens.py @@ -22,8 +22,8 @@ ) from bloom_lims.config import get_settings -TOKEN_TEMPLATE_CODE = "BBX/auth/user-api-token/1.0/" -TOKEN_USAGE_LOG_TEMPLATE_CODE = "BBX/auth/user-api-token-usage-log/1.0/" +TOKEN_TEMPLATE_CODE = "auth/user-api-token/generic/1.0/" +TOKEN_USAGE_LOG_TEMPLATE_CODE = "auth/user-api-token-usage-log/generic/1.0/" TOKEN_PREFIX = "BBX" TOKEN_USAGE_PREFIX = "BBX" diff --git a/bloom_lims/auth/repositories/tapdb/users.py b/bloom_lims/auth/repositories/tapdb/users.py index b45f93e..f1533ed 100644 --- a/bloom_lims/auth/repositories/tapdb/users.py +++ b/bloom_lims/auth/repositories/tapdb/users.py @@ -38,9 +38,9 @@ class TapdbUserRecord: END AS is_active FROM generic_instance gi WHERE gi.polymorphic_discriminator = 'actor_instance' - AND gi.category = 'SYS' - AND gi.type = 'actor' - AND gi.subtype = 'system_user' + AND gi.category = 'actor' + AND gi.type = 'user' + AND gi.subtype = 'system' AND COALESCE(gi.is_deleted, FALSE) = FALSE """ @@ -56,7 +56,7 @@ def _normalize_stored_role( role_value: Any, *, default: str | None = None ) -> str | None: def _coerce(value: Any) -> str | None: - candidate = str(value or "").strip() + candidate = str(value or "").strip().upper() if not candidate: return None values = normalize_roles([candidate]) @@ -179,9 +179,9 @@ def set_user_role(session: Session, identifier: str | int, role: str) -> bool: ), modified_dt = NOW() WHERE gi.polymorphic_discriminator = 'actor_instance' - AND gi.category = 'SYS' - AND gi.type = 'actor' - AND gi.subtype = 'system_user' + AND gi.category = 'actor' + AND gi.type = 'user' + AND gi.subtype = 'system' AND COALESCE(gi.is_deleted, FALSE) = FALSE AND gi.uid = :uid RETURNING gi.uid @@ -204,9 +204,9 @@ def set_user_role(session: Session, identifier: str | int, role: str) -> bool: ), modified_dt = NOW() WHERE gi.polymorphic_discriminator = 'actor_instance' - AND gi.category = 'SYS' - AND gi.type = 'actor' - AND gi.subtype = 'system_user' + AND gi.category = 'actor' + AND gi.type = 'user' + AND gi.subtype = 'system' AND COALESCE(gi.is_deleted, FALSE) = FALSE AND ( lower(COALESCE(gi.json_addl->>'login_identifier', '')) = :identifier diff --git a/bloom_lims/cli/db.py b/bloom_lims/cli/db.py index ee076a8..ae1ce71 100644 --- a/bloom_lims/cli/db.py +++ b/bloom_lims/cli/db.py @@ -48,7 +48,7 @@ db_app = typer.Typer(help="Database management commands routed through daylily-tapdb.") console = Console() -_TAPDB_CORE_TEMPLATE_PREFIXES = {"SYS", "MSG"} +_TAPDB_CORE_TEMPLATE_PREFIXES = {"ADT", "EDG", "GSE", "GVR", "MSG", "SYS", "TPX", "XRF"} _IDENTITY_PREFIXES: dict[str, str] = { "generic_template": GENERIC_TEMPLATE_PREFIX, "generic_instance_lineage": GENERIC_INSTANCE_LINEAGE_PREFIX, @@ -299,7 +299,7 @@ def _seed_tapdb_templates( seed_templates( bdb.session, core_templates, - overwrite=overwrite, + overwrite=True, core_config_dir=core_config_dir, domain_code=ctx.domain_code, owner_repo_name="daylily-tapdb", @@ -383,7 +383,26 @@ def _retire_obsolete_template_variants( domain_code: str, ) -> int: current = _active_template_categories_by_semantic_key(templates) - if not current: + current_semantic_types = { + ( + str((template.get("json_addl") or {}).get("semantic_category") or "") + .strip() + .lower(), + str(template.get("type") or "").strip(), + ) + for template in templates + if isinstance(template.get("json_addl"), dict) + and str( + (template.get("json_addl") or {}).get("semantic_category") or "" + ).strip() + and str(template.get("type") or "").strip() + } + current_prefixes = { + str(template.get("instance_prefix") or "").strip().upper() + for template in templates + if str(template.get("instance_prefix") or "").strip() + } + if not current and not current_prefixes: return 0 rows = ( @@ -405,9 +424,18 @@ def _retire_obsolete_template_variants( str(row.version or "").strip(), ) expected_category = current.get(key) + actual_category = str(row.category or "").strip().upper() + if actual_category in current_prefixes: + row.is_deleted = True + row.bstatus = "retired" + retired += 1 + continue if expected_category is None: + if (key[0], key[1]) in current_semantic_types: + row.is_deleted = True + row.bstatus = "retired" + retired += 1 continue - actual_category = str(row.category or "").strip().upper() if actual_category == expected_category: continue row.is_deleted = True diff --git a/bloom_lims/config/action/core.json b/bloom_lims/config/action/core.json index 5951321..dff7c65 100644 --- a/bloom_lims/config/action/core.json +++ b/bloom_lims/config/action/core.json @@ -47,7 +47,7 @@ "label_style": "" }, "curr_user": "", - "description": "Print Label2", + "description": "Print Label", "ui_schema": { "title": "Print Label", "fields": [] @@ -78,7 +78,58 @@ "description": "Add Parent or Child Relationships (aka lineages).", "ui_schema": { "title": "Add Parent or Child Relationship(s)", - "fields": [] + "fields": [ + { + "name": "lineage_type_to_create", + "label": "Create Relationship As", + "type": "select", + "required": true, + "options": [ + {"value": "parent", "label": "parent"}, + {"value": "child", "label": "child"} + ] + }, + { + "name": "relationship_type", + "label": "Relationship Type", + "type": "select", + "required": true, + "options": [ + {"value": "contains", "label": "contains"}, + {"value": "derived_from", "label": "derived_from"}, + {"value": "aliquot_of", "label": "aliquot_of"}, + {"value": "prepared_from", "label": "prepared_from"}, + {"value": "member_of", "label": "member_of"}, + {"value": "has_external_reference", "label": "has_external_reference"}, + {"value": "beta_extraction_batch_run", "label": "beta_extraction_batch_run"}, + {"value": "beta_extraction_run_input", "label": "beta_extraction_run_input"}, + {"value": "beta_extraction_plate", "label": "beta_extraction_plate"}, + {"value": "beta_extraction_well", "label": "beta_extraction_well"}, + {"value": "beta_extraction_output", "label": "beta_extraction_output"}, + {"value": "beta_extraction_run_output", "label": "beta_extraction_run_output"}, + {"value": "beta_post_extract_qc", "label": "beta_post_extract_qc"}, + {"value": "beta_library_prep_output", "label": "beta_library_prep_output"}, + {"value": "beta_library_material_output", "label": "beta_library_material_output"}, + {"value": "beta_library_qc", "label": "beta_library_qc"}, + {"value": "beta_pooling_run_output", "label": "beta_pooling_run_output"}, + {"value": "beta_pool_member", "label": "beta_pool_member"}, + {"value": "beta_pooling_run_input", "label": "beta_pooling_run_input"}, + {"value": "beta_sequencing_run", "label": "beta_sequencing_run"}, + {"value": "beta_sequenced_library_assignment", "label": "beta_sequenced_library_assignment"}, + {"value": "beta_assignment_source", "label": "beta_assignment_source"}, + {"value": "beta_assignment_library_material", "label": "beta_assignment_library_material"}, + {"value": "beta_assignment_barcode_reagent", "label": "beta_assignment_barcode_reagent"}, + {"value": "beta_run_artifact", "label": "beta_run_artifact"} + ] + }, + { + "name": "euids", + "label": "EUIDs (one per line)", + "type": "textarea", + "required": true, + "rows": 6 + } + ] } } } @@ -90,7 +141,8 @@ "method_name": "do_action_create_subject_and_anchor", "action_executed": "0", "max_executions": "-1", - "action_enabled": "1", + "action_enabled": "0", + "action_visible": "0", "capture_data": "yes", "captured_data": {}, "deactivate_actions_when_executed": [], diff --git a/bloom_lims/config/container/tube.json b/bloom_lims/config/container/tube.json index 7b7fdc4..d38a26b 100644 --- a/bloom_lims/config/container/tube.json +++ b/bloom_lims/config/container/tube.json @@ -39,7 +39,7 @@ "name": "10ml glass tube" }, "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [] } @@ -84,7 +84,7 @@ "name": "1.5ml eppi tube" }, "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [] } @@ -129,7 +129,7 @@ "name": "10ml tube generic" }, "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [] } @@ -175,7 +175,7 @@ }, "singleton": "0", "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [] } diff --git a/bloom_lims/config/container/well.json b/bloom_lims/config/container/well.json index a9d6e1b..77e7f58 100644 --- a/bloom_lims/config/container/well.json +++ b/bloom_lims/config/container/well.json @@ -36,7 +36,7 @@ }, "singleton": "0", "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [ "container/plate/fixed-plate.*?/.*?" diff --git a/bloom_lims/config/workflow_step/analysis.json b/bloom_lims/config/workflow_step/analysis.json index d815f0c..44973f7 100644 --- a/bloom_lims/config/workflow_step/analysis.json +++ b/bloom_lims/config/workflow_step/analysis.json @@ -22,44 +22,44 @@ "expected_inputs": [ { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } } ], "expected_outputs": [ { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } } ], diff --git a/bloom_lims/config/workflow_step/extraction-steps.json b/bloom_lims/config/workflow_step/extraction-steps.json index 925c5e6..ff54b1d 100644 --- a/bloom_lims/config/workflow_step/extraction-steps.json +++ b/bloom_lims/config/workflow_step/extraction-steps.json @@ -24,22 +24,22 @@ [ { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } } ] @@ -51,7 +51,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/cfdna/*" + "holds_content": "material/sample/cfdna/*" } } ] @@ -62,7 +62,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/cfdna/*" + "holds_content": "material/sample/cfdna/*" } } ] @@ -73,7 +73,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/cfdna/*" + "holds_content": "material/sample/cfdna/*" } } ] @@ -84,7 +84,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/cfdna/*" + "holds_content": "material/sample/cfdna/*" } } ] @@ -133,22 +133,22 @@ "expected_inputs": [ { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } } ], @@ -159,7 +159,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/gdna/*" + "holds_content": "material/sample/gdna/*" } } ] @@ -170,7 +170,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/gdna/*" + "holds_content": "material/sample/gdna/*" } } ] @@ -181,7 +181,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/gdna/*" + "holds_content": "material/sample/gdna/*" } } ] @@ -192,7 +192,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/gdna/*" + "holds_content": "material/sample/gdna/*" } } ] @@ -241,22 +241,22 @@ "expected_inputs": [ { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } } ], @@ -302,44 +302,44 @@ "expected_inputs": [ { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } } ], "expected_outputs": [ { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } } ], diff --git a/bloom_lims/container_entry.py b/bloom_lims/container_entry.py index 3cd6923..657ff4b 100644 --- a/bloom_lims/container_entry.py +++ b/bloom_lims/container_entry.py @@ -22,7 +22,13 @@ def _required_absolute_file(name: str) -> Path: return path -def _initialize_cli_runtime(config_path: Path) -> None: +def _start_server(**kwargs: object) -> None: + from bloom_lims.cli.server import start + + start(**kwargs) + + +def _initialize_runtime_context(config_path: Path) -> None: from cli_core_yo.errors import ContextNotInitializedError from cli_core_yo.runtime import get_context, initialize from cli_core_yo.xdg import resolve_paths @@ -31,19 +37,25 @@ def _initialize_cli_runtime(config_path: Path) -> None: try: get_context() + return except ContextNotInitializedError: - initialize(spec, resolve_paths(spec.xdg), config_path=config_path) - - -def _start_server(**kwargs: object) -> None: - from bloom_lims.cli.server import start - - start(**kwargs) + pass + + initialize( + spec, + resolve_paths(spec.xdg), + config_path=config_path, + invocation={"entrypoint": "container"}, + backend_name="container", + backend_kind="container", + runtime_guard_mode="off", + runtime_check_skipped=True, + ) def main() -> None: config_path = _required_absolute_file("BLOOM_CONFIG_PATH") - _initialize_cli_runtime(config_path) + _initialize_runtime_context(config_path) _start_server( port=int(_required_env("PORT")), host=_required_env("HOST"), diff --git a/bloom_lims/domain/base.py b/bloom_lims/domain/base.py index 87d019a..a55aec5 100644 --- a/bloom_lims/domain/base.py +++ b/bloom_lims/domain/base.py @@ -45,6 +45,9 @@ logger = logging.getLogger(__name__) +MAX_TEMPLATE_INSTANTIATION_DEPTH = 8 +MAX_TEMPLATE_INSTANTIATION_OBJECTS = 1000 + class BloomObj: def __init__( @@ -421,7 +424,7 @@ def get_most_recent_schema_audit_log_entry(self): ) # centralizing creation more cleanly. - def create_instance(self, template_euid, json_addl_overrides={}): + def create_instance(self, template_euid, json_addl_overrides=None): """Given an EUID for an object template, instantiate an instance from the template. No child objects defined by the tmplate will be generated. @@ -432,6 +435,7 @@ def create_instance(self, template_euid, json_addl_overrides={}): self.logger.debug(f"Creating instance from template EUID {template_euid}") + json_addl_overrides = dict(json_addl_overrides or {}) template = self.get_by_euid(template_euid) if not template: @@ -559,12 +563,12 @@ def _apply_default_graph_metadata(self, instance) -> None: "node_role": "fixed_plate_well", "role": "bloom_fixed_plate_well", "expected_fanout_max": 1, - "fanout_reason": "plate well contains at most one material", + "fanout_reason": "plate well holds at most one material", "expected_fanout": [ self._expected_fanout_entry( - relationship_types=["contains"], + relationship_types=["HOLDS_MATERIAL"], max_child_count=1, - reason="plate well contains at most one material", + reason="plate well holds at most one material", ) ], } @@ -583,10 +587,19 @@ def _apply_default_graph_metadata(self, instance) -> None: flag_modified(instance, "json_addl") # fix naming, instance_type==table_name_prefix - def create_instances(self, template_euid): + def create_instances( + self, + template_euid, + json_addl_overrides=None, + *, + max_depth=MAX_TEMPLATE_INSTANTIATION_DEPTH, + max_instances=MAX_TEMPLATE_INSTANTIATION_OBJECTS, + ): """ - IMPORTANTLY: this method creates the requested object from the template, and also will recurse one level to create any children objects defined by the template. - You get back an array with the first element being the parent the second an array of children. + IMPORTANTLY: this method creates the requested object from the template, + and recursively creates any child objects defined by instantiation_layouts. + You get back an array with the first element being the parent and the + second an array of all descendant children. Create instances from exsiting templates in the *_template table that the euid is a member of. The class subclassing is an experiment, see the docs (hopefully) for more, in short: @@ -596,7 +609,7 @@ def create_instances(self, template_euid): These are only used to seed the templates... the idea is to allow users to add subtypes as they see fit. (TBD) ~ TABLE_template/{type}/{subtype}/{version} is the pattern used to for the template table name - This is a recursive function that will only create one level of children instances. + Recursive instantiation is bounded by ``max_depth`` and ``max_instances``. Args: template_euid (_type_): a template euid of the pattern [A-Z][A-Z]T[0-9]+ , which is a nicety and nothing at all is ever inferred from the prefix. @@ -606,25 +619,83 @@ def create_instances(self, template_euid): [[],[]]: arr[0][:] are parents (presently, there is only ever 1 parent), arr[1][:] are children, if any. """ - self.logger.debug(f"Creating instances from template EUID {template_euid}") - template = self.get_by_euid( - template_euid - ) # needed to get this for the child recrods if any - parent_instance = self.create_instance(template_euid) ret_objs = [[], []] + state = {"created_count": 0} + parent_instance = self._create_instance_tree( + template_euid=template_euid, + json_addl_overrides=json_addl_overrides or {}, + ret_objs=ret_objs, + depth=0, + template_stack=(), + max_depth=max_depth, + max_instances=max_instances, + state=state, + ) ret_objs[0].append(parent_instance) + self.session.commit() + return ret_objs + + def _template_instantiation_key(self, template): + return str( + getattr(template, "euid", None) + or ":".join( + str(getattr(template, attr, "") or "") + for attr in ("category", "type", "subtype", "version") + ) + ) - # template_json_addl = template.json_addl - if template.json_addl.get("instantiation_layouts"): - ret_objs = self._process_instantiation_layouts( - template.json_addl["instantiation_layouts"], - parent_instance, + def _create_instance_tree( + self, + *, + template_euid, + json_addl_overrides, + ret_objs, + depth, + template_stack, + max_depth, + max_instances, + state, + ): + if depth > max_depth: + raise Exception( + f"Template instantiation exceeded max depth {max_depth}: " + + " -> ".join(template_stack + (str(template_euid),)) + ) + if state["created_count"] >= max_instances: + raise Exception( + f"Template instantiation exceeded max object count {max_instances}" + ) + + template = self.get_by_euid(template_euid) + if not template: + raise Exception(f"Template not found: {template_euid}") + + template_key = self._template_instantiation_key(template) + if template_key in template_stack: + raise Exception( + "Template instantiation cycle detected: " + + " -> ".join(template_stack + (template_key,)) + ) + + state["created_count"] += 1 + instance = self.create_instance( + template.euid, json_addl_overrides=json_addl_overrides or {} + ) + + layouts = template.json_addl.get("instantiation_layouts") + if layouts: + self._process_instantiation_layouts( + layouts, + instance, ret_objs, + depth=depth, + template_stack=template_stack + (template_key,), + max_depth=max_depth, + max_instances=max_instances, + state=state, ) - ##self.session.flush() - self.session.commit() - return ret_objs + return instance # I am of two minds re: if actions should be full objects, or pseudo-objects as they are now... def _create_action_ds(self, action_imports): @@ -717,9 +788,16 @@ def _process_instantiation_layouts( instantiation_layouts, parent_instance, ret_objs, + *, + depth=0, + template_stack=(), + max_depth=MAX_TEMPLATE_INSTANTIATION_DEPTH, + max_instances=MAX_TEMPLATE_INSTANTIATION_OBJECTS, + state=None, ): - # Revisit the lineage set creation, this will not behave as expected if the json templates define more than 1 level deep children. - ## or is this desireable, and the referenced children should reference thier children... crazy town begins at this level... + if state is None: + state = {"created_count": 0} + for row in instantiation_layouts: if isinstance(row, dict) and isinstance(row.get("child_templates"), list): relationship_type = str(row.get("relationship_type") or "generic") @@ -730,25 +808,18 @@ def _process_instantiation_layouts( if not layout_str: continue layout_ds = {"json_addl": child_cfg.get("json_addl") or {}} - child_instance = self._create_child_instance(layout_str, layout_ds) - parent_category = ( - instance_semantic_category(parent_instance) - or str(parent_instance.category or "").strip() + child_instance = self._create_child_instance( + layout_str, + layout_ds, + ret_objs=ret_objs, + depth=depth + 1, + template_stack=template_stack, + max_depth=max_depth, + max_instances=max_instances, + state=state, ) - lineage_record = self.Base.classes.generic_instance_lineage( - parent_instance_uid=parent_instance.uid, - child_instance_uid=child_instance.uid, - name=f"{parent_instance.name} :: {child_instance.name}", - type=parent_instance.type, - subtype=parent_instance.subtype, - version=parent_instance.version, - json_addl=parent_instance.json_addl, - bstatus=parent_instance.bstatus, - category=parent_category, - parent_type=parent_instance.polymorphic_discriminator, - child_type=child_instance.polymorphic_discriminator, - polymorphic_discriminator=f"{parent_category}_instance_lineage", - relationship_type=relationship_type, + lineage_record = self._new_instantiation_lineage( + parent_instance, child_instance, relationship_type ) self.session.add(lineage_record) ret_objs[1].append(child_instance) @@ -761,31 +832,47 @@ def _process_instantiation_layouts( for i in ds: layout_str = i layout_ds = ds[i] - child_instance = self._create_child_instance(layout_str, layout_ds) - parent_category = ( - instance_semantic_category(parent_instance) - or str(parent_instance.category or "").strip() + child_instance = self._create_child_instance( + layout_str, + layout_ds, + ret_objs=ret_objs, + depth=depth + 1, + template_stack=template_stack, + max_depth=max_depth, + max_instances=max_instances, + state=state, ) - lineage_record = self.Base.classes.generic_instance_lineage( - parent_instance_uid=parent_instance.uid, - child_instance_uid=child_instance.uid, - name=f"{parent_instance.name} :: {child_instance.name}", - type=parent_instance.type, - subtype=parent_instance.subtype, - version=parent_instance.version, - json_addl=parent_instance.json_addl, - bstatus=parent_instance.bstatus, - category=parent_category, - parent_type=parent_instance.polymorphic_discriminator, - child_type=child_instance.polymorphic_discriminator, - polymorphic_discriminator=f"{parent_category}_instance_lineage", + lineage_record = self._new_instantiation_lineage( + parent_instance, child_instance, "generic" ) self.session.add(lineage_record) - ##self.session.flush() ret_objs[1].append(child_instance) return ret_objs + def _new_instantiation_lineage( + self, parent_instance, child_instance, relationship_type + ): + parent_category = ( + instance_semantic_category(parent_instance) + or str(parent_instance.category or "").strip() + ) + return self.Base.classes.generic_instance_lineage( + parent_instance_uid=parent_instance.uid, + child_instance_uid=child_instance.uid, + name=f"{parent_instance.name} :: {child_instance.name}", + type=parent_instance.type, + subtype=parent_instance.subtype, + version=parent_instance.version, + json_addl=parent_instance.json_addl, + bstatus=parent_instance.bstatus, + category=parent_category, + parent_type=parent_instance.polymorphic_discriminator, + child_type=child_instance.polymorphic_discriminator, + polymorphic_discriminator=f"{parent_category}_instance_lineage", + relationship_type=str(relationship_type or "generic"), + ) + def create_generic_instance_lineage_by_euids( self, parent_instance_euid, child_instance_euid, relationship_type="generic" ): @@ -826,11 +913,31 @@ def create_lineage( ) def create_instance_by_code(self, layout_str, layout_ds): - ret_obj = self._create_child_instance(layout_str, layout_ds) + ret_obj = self._create_child_instance( + layout_str, + layout_ds, + ret_objs=[[], []], + depth=0, + template_stack=(), + max_depth=MAX_TEMPLATE_INSTANTIATION_DEPTH, + max_instances=MAX_TEMPLATE_INSTANTIATION_OBJECTS, + state={"created_count": 0}, + ) return ret_obj - def _create_child_instance(self, layout_str, layout_ds): + def _create_child_instance( + self, + layout_str, + layout_ds, + *, + ret_objs=None, + depth=0, + template_stack=(), + max_depth=MAX_TEMPLATE_INSTANTIATION_DEPTH, + max_instances=MAX_TEMPLATE_INSTANTIATION_OBJECTS, + state=None, + ): ( category, type_name, @@ -859,12 +966,20 @@ def _create_child_instance(self, layout_str, layout_ds): "Please ensure the database is seeded with templates." ) template = templates[0] - - new_instance = self.create_instance(template.euid) - update_recursive(new_instance.json_addl, defaults_ds) - flag_modified(new_instance, "json_addl") - ##self.session.flush() - self.session.commit() + if ret_objs is None: + ret_objs = [[], []] + if state is None: + state = {"created_count": 0} + new_instance = self._create_instance_tree( + template_euid=template.euid, + json_addl_overrides=defaults_ds, + ret_objs=ret_objs, + depth=depth, + template_stack=template_stack, + max_depth=max_depth, + max_instances=max_instances, + state=state, + ) return new_instance diff --git a/bloom_lims/domain/beta_actions.py b/bloom_lims/domain/beta_actions.py index 573b544..3ccd7fe 100644 --- a/bloom_lims/domain/beta_actions.py +++ b/bloom_lims/domain/beta_actions.py @@ -187,9 +187,7 @@ def record( return action_record def _ensure_action_template(self, action_key: str) -> generic_template: - template_code = ( - f"{BETA_ACTION_TEMPLATE_PREFIX}/{BETA_ACTION_GROUP}/{action_key}/1.0/" - ) + template_code = f"action/{BETA_ACTION_GROUP}/{action_key}/1.0/" return require_seeded_template( self.session, template_code, diff --git a/bloom_lims/domain/beta_lab.py b/bloom_lims/domain/beta_lab.py index 1c1a4b4..3a377ce 100644 --- a/bloom_lims/domain/beta_lab.py +++ b/bloom_lims/domain/beta_lab.py @@ -60,10 +60,10 @@ class BetaLabService( REL_MATERIAL_CONSUMPTION = "beta_material_consumption" REL_USED_INSTRUMENT = "beta_used_instrument" REL_USED_REAGENT = "beta_used_reagent" - POOL_TEMPLATE_CODE = "content/pool/sequencing-library/1.0" + POOL_TEMPLATE_CODE = "material/pool/sequencing-library/1.0" POOL_CONTAINER_TEMPLATE_CODE = "container/tube/tube-generic-10ml/1.0" LIBRARY_PREP_OUTPUT_TEMPLATE_CODE = "data/operation/library-prep/1.0" - LIBRARY_MATERIAL_TEMPLATE_CODE = "content/sample/sequencing-library/1.0" + LIBRARY_MATERIAL_TEMPLATE_CODE = "material/sample/sequencing-library/1.0" SEQUENCING_RUN_TEMPLATE_BY_SUBTYPE = { "illumina": "data/sequencing_run/illumina/1.0", "ont": "data/sequencing_run/ont/1.0", @@ -73,8 +73,8 @@ class BetaLabService( } LIBRARY_PLATE_TEMPLATE_CODE = "container/plate/sequencing-library-plate-96/1.0" EXTRACTION_TEMPLATE_BY_TYPE = { - "cfdna": "content/sample/cfdna/1.0", - "gdna": "content/sample/gdna/1.0", + "cfdna": "material/sample/cfdna/1.0", + "gdna": "material/sample/gdna/1.0", } LIB_PREP_QUEUE_BY_PLATFORM = { "ILMN": "ilmn_lib_prep", diff --git a/bloom_lims/domain/beta_lab_materials.py b/bloom_lims/domain/beta_lab_materials.py index dbeb80c..3a102e3 100644 --- a/bloom_lims/domain/beta_lab_materials.py +++ b/bloom_lims/domain/beta_lab_materials.py @@ -48,7 +48,7 @@ def register_accepted_material( node_role="accepted_container", expected_fanout=[ self._graph_expected_fanout_entry( - relationship_types=["contains"], + relationship_types=["HOLDS_MATERIAL"], max_child_count=1, reason="accepted specimen container holds one accepted beta material", ), @@ -76,7 +76,7 @@ def register_accepted_material( relationship_types=[ "beta_extraction_output", "beta_extraction_run_input", - "contains", + "HOLDS_MATERIAL", ], max_child_count=3, reason="accepted specimen feeds one extraction path in the ILMN proband beta", diff --git a/bloom_lims/domain/beta_lab_queue.py b/bloom_lims/domain/beta_lab_queue.py index 7e96725..3d80c34 100644 --- a/bloom_lims/domain/beta_lab_queue.py +++ b/bloom_lims/domain/beta_lab_queue.py @@ -739,7 +739,7 @@ def _resolve_queue_worker( def _find_execution_container(self, material): for lineage in get_child_lineages(material): - if lineage.is_deleted or lineage.relationship_type != "contains": + if lineage.is_deleted or lineage.relationship_type != "HOLDS_MATERIAL": continue parent = lineage.parent_instance if ( diff --git a/bloom_lims/domain/beta_lab_refs.py b/bloom_lims/domain/beta_lab_refs.py index 944234a..71c2112 100644 --- a/bloom_lims/domain/beta_lab_refs.py +++ b/bloom_lims/domain/beta_lab_refs.py @@ -4,6 +4,7 @@ from typing import Any +from bloom_lims.domain.v0_graph import attach_bloom_v0_edge, object_evidence from bloom_lims.tapdb_adapter import get_child_lineages, get_parent_lineages from bloom_lims.template_identity import instance_semantic_category @@ -22,9 +23,81 @@ class _BetaLabReferenceMixin: "beta_pool_member", "beta_sequenced_library_assignment", "beta_sequencing_run", + "HOLDS_MATERIAL", + "RUN_CONSUMED", + "RUN_PRODUCED", + "DERIVED_FROM", } ) + def _attach_v0_edge_to_lineage( + self, + lineage, + *, + edge_type: str, + source_euid: str, + target_euid: str, + source_role: str, + target_role: str, + evidence_refs: list[dict[str, Any]], + correlation_id: str, + causation_id: str, + source_system: str = "bloom", + target_system: str = "bloom", + ): + return attach_bloom_v0_edge( + lineage, + edge_type=edge_type, + source_euid=source_euid, + target_euid=target_euid, + source_role=source_role, + target_role=target_role, + source_system=source_system, + target_system=target_system, + evidence_refs=evidence_refs, + correlation_id=correlation_id, + causation_id=causation_id, + ) + + def _attach_bloom_v0_lineage( + self, + parent_euid: str, + child_euid: str, + *, + relationship_type: str, + edge_type: str, + source_euid: str, + target_euid: str, + source_role: str, + target_role: str, + source_system: str = "bloom", + target_system: str = "bloom", + extra_evidence_refs: list[dict[str, Any]] | None = None, + ): + lineage = self.bobj.create_generic_instance_lineage_by_euids( + parent_euid, + child_euid, + relationship_type=relationship_type, + ) + evidence_refs = [ + object_evidence(parent_euid, role="tapdb_lineage_parent"), + object_evidence(child_euid, role="tapdb_lineage_child"), + *(extra_evidence_refs or []), + ] + return self._attach_v0_edge_to_lineage( + lineage, + edge_type=edge_type, + source_euid=source_euid, + target_euid=target_euid, + source_role=source_role, + target_role=target_role, + source_system=source_system, + target_system=target_system, + evidence_refs=evidence_refs, + correlation_id=f"{relationship_type}:{parent_euid}:{child_euid}", + causation_id=f"bloom:{relationship_type}:{parent_euid}:{child_euid}", + ) + def _resolve_fulfillment_item_context( self, instance, @@ -88,11 +161,19 @@ def _fulfillment_item_refs_for_instance(self, instance) -> list[dict[str, str]]: if ref_type != self.PROCESS_ITEM_REFERENCE_TYPE: continue fulfillment_item_euid = str( - payload.get("atlas_test_fulfillment_item_euid") or "" + payload.get("atlas_test_fulfillment_item_euid") + or payload.get("atlas_fulfillment_slot_euid") + or "" + ).strip() + atlas_test_euid = str( + payload.get("atlas_test_euid") + or payload.get("atlas_order_test_euid") + or "" ).strip() - atlas_test_euid = str(payload.get("atlas_test_euid") or "").strip() atlas_tenant_id = str(payload.get("atlas_tenant_id") or "").strip() - atlas_trf_euid = str(payload.get("atlas_trf_euid") or "").strip() + atlas_trf_euid = str( + payload.get("atlas_trf_euid") or payload.get("atlas_order_euid") or "" + ).strip() if not ( fulfillment_item_euid and atlas_test_euid @@ -105,6 +186,15 @@ def _fulfillment_item_refs_for_instance(self, instance) -> list[dict[str, str]]: "atlas_trf_euid": atlas_trf_euid, "atlas_test_euid": atlas_test_euid, "atlas_test_fulfillment_item_euid": fulfillment_item_euid, + "atlas_order_euid": str( + payload.get("atlas_order_euid") or atlas_trf_euid + ).strip(), + "atlas_order_test_euid": str( + payload.get("atlas_order_test_euid") or atlas_test_euid + ).strip(), + "atlas_fulfillment_slot_euid": str( + payload.get("atlas_fulfillment_slot_euid") or fulfillment_item_euid + ).strip(), } return list(refs.values()) @@ -272,7 +362,29 @@ def _replace_fulfillment_item_references( atlas_tenant_id = str(atlas_context.get("atlas_tenant_id") or "").strip() atlas_trf_euid = str(atlas_context.get("atlas_trf_euid") or "").strip() + atlas_order_euid = str( + atlas_context.get("atlas_order_euid") + or atlas_context.get("order_euid") + or "" + ).strip() fulfillment_items = list(atlas_context.get("fulfillment_items") or []) + for fulfillment_slot in list(atlas_context.get("fulfillment_slots") or []): + atlas_fulfillment_slot_euid = str( + fulfillment_slot.get("atlas_fulfillment_slot_euid") or "" + ).strip() + atlas_order_test_euid = str( + fulfillment_slot.get("atlas_order_test_euid") or "" + ).strip() + if not (atlas_fulfillment_slot_euid and atlas_order_test_euid): + continue + fulfillment_items.append( + { + "atlas_test_euid": atlas_order_test_euid, + "atlas_order_test_euid": atlas_order_test_euid, + "atlas_test_fulfillment_item_euid": atlas_fulfillment_slot_euid, + "atlas_fulfillment_slot_euid": atlas_fulfillment_slot_euid, + } + ) direct_fulfillment_item_euid = str( atlas_context.get("atlas_test_fulfillment_item_euid") or "" ).strip() @@ -288,13 +400,23 @@ def _replace_fulfillment_item_references( for fulfillment_item in fulfillment_items: atlas_test_euid = str(fulfillment_item.get("atlas_test_euid") or "").strip() + atlas_order_test_euid = str( + fulfillment_item.get("atlas_order_test_euid") or atlas_test_euid or "" + ).strip() atlas_test_fulfillment_item_euid = str( - fulfillment_item.get("atlas_test_fulfillment_item_euid") or "" + fulfillment_item.get("atlas_test_fulfillment_item_euid") + or fulfillment_item.get("atlas_fulfillment_slot_euid") + or "" + ).strip() + atlas_fulfillment_slot_euid = str( + fulfillment_item.get("atlas_fulfillment_slot_euid") + or atlas_test_fulfillment_item_euid + or "" ).strip() if not ( atlas_tenant_id - and atlas_trf_euid - and atlas_test_euid + and (atlas_trf_euid or atlas_order_euid) + and (atlas_test_euid or atlas_order_test_euid) and atlas_test_fulfillment_item_euid ): continue @@ -305,8 +427,11 @@ def _replace_fulfillment_item_references( "foreign_reference": atlas_test_fulfillment_item_euid, "atlas_tenant_id": atlas_tenant_id, "atlas_trf_euid": atlas_trf_euid, + "atlas_order_euid": atlas_order_euid, "atlas_test_euid": atlas_test_euid, + "atlas_order_test_euid": atlas_order_test_euid, "atlas_test_fulfillment_item_euid": atlas_test_fulfillment_item_euid, + "atlas_fulfillment_slot_euid": atlas_fulfillment_slot_euid, "validation": {}, } created_payloads.append(properties) @@ -314,11 +439,37 @@ def _replace_fulfillment_item_references( self.EXTERNAL_REFERENCE_TEMPLATE_CODE, {"json_addl": {"properties": properties}}, ) - self.bobj.create_generic_instance_lineage_by_euids( + lineage = self.bobj.create_generic_instance_lineage_by_euids( instance.euid, ref_obj.euid, relationship_type=self.EXTERNAL_REFERENCE_RELATIONSHIP, ) + self._attach_v0_edge_to_lineage( + lineage, + edge_type="SLOT_SATISFIED_BY", + source_euid=atlas_fulfillment_slot_euid, + target_euid=instance.euid, + source_role="fulfillment_slot", + target_role="satisfying_bloom_object", + source_system="atlas", + target_system="bloom", + evidence_refs=[ + object_evidence( + atlas_fulfillment_slot_euid, + role="fulfillment_slot", + system="atlas", + ), + object_evidence(instance.euid, role="satisfying_bloom_object"), + object_evidence(ref_obj.euid, role="atlas_reference_link"), + ], + correlation_id=( + f"SLOT_SATISFIED_BY:{atlas_fulfillment_slot_euid}:{instance.euid}" + ), + causation_id=( + f"bloom:atlas_fulfillment_reference:" + f"{atlas_fulfillment_slot_euid}:{instance.euid}" + ), + ) self._sync_atlas_tapdb_graph_refs( instance, additional_payloads=created_payloads ) @@ -330,9 +481,30 @@ def _replace_container_entity_references( atlas_context: dict[str, Any], ) -> None: atlas_tenant_id = str(atlas_context.get("atlas_tenant_id") or "").strip() + atlas_order_euid = str( + atlas_context.get("atlas_order_euid") + or atlas_context.get("order_euid") + or "" + ).strip() + atlas_order_test_euid = str( + atlas_context.get("atlas_order_test_euid") + or atlas_context.get("order_test_euid") + or "" + ).strip() atlas_trf_euid = str(atlas_context.get("atlas_trf_euid") or "").strip() atlas_test_euid = str(atlas_context.get("atlas_test_euid") or "").strip() atlas_test_euids: list[str] = [] + atlas_order_test_euids: list[str] = [] + seen_order_tests: set[str] = set() + if atlas_order_test_euid: + seen_order_tests.add(atlas_order_test_euid) + atlas_order_test_euids.append(atlas_order_test_euid) + for value in list(atlas_context.get("atlas_order_test_euids") or []): + clean_value = str(value or "").strip() + if not clean_value or clean_value in seen_order_tests: + continue + seen_order_tests.add(clean_value) + atlas_order_test_euids.append(clean_value) seen_tests: set[str] = set() if atlas_test_euid: seen_tests.add(atlas_test_euid) @@ -345,6 +517,14 @@ def _replace_container_entity_references( atlas_test_euids.append(clean_value) fulfillment_items = list(atlas_context.get("fulfillment_items") or []) for fulfillment_item in fulfillment_items: + candidate_order_test = str( + fulfillment_item.get("atlas_order_test_euid") + or fulfillment_item.get("order_test_euid") + or "" + ).strip() + if candidate_order_test and candidate_order_test not in seen_order_tests: + seen_order_tests.add(candidate_order_test) + atlas_order_test_euids.append(candidate_order_test) candidate = str(fulfillment_item.get("atlas_test_euid") or "").strip() if not candidate or candidate in seen_tests: continue @@ -359,6 +539,54 @@ def _replace_container_entity_references( (self.ORGANIZATION_SITE_REFERENCE_TYPE, "atlas_organization_site_euid"), ) created_payloads: list[dict[str, Any]] = [] + self._delete_reference_type(instance, reference_type="order_test_euid") + self._delete_reference_type(instance, reference_type="order_euid") + if atlas_tenant_id and atlas_order_euid: + properties = { + "provider": "atlas", + "reference_type": "order_euid", + "reference_value": atlas_order_euid, + "foreign_reference": atlas_order_euid, + "atlas_tenant_id": atlas_tenant_id, + "order_euid": atlas_order_euid, + "atlas_order_euid": atlas_order_euid, + "validation": {}, + } + created_payloads.append(properties) + ref_obj = self.bobj.create_instance_by_code( + self.EXTERNAL_REFERENCE_TEMPLATE_CODE, + {"json_addl": {"properties": properties}}, + ) + self.bobj.create_generic_instance_lineage_by_euids( + instance.euid, + ref_obj.euid, + relationship_type=self.EXTERNAL_REFERENCE_RELATIONSHIP, + ) + if atlas_tenant_id: + for reference_value in atlas_order_test_euids: + properties = { + "provider": "atlas", + "reference_type": "order_test_euid", + "reference_value": reference_value, + "foreign_reference": reference_value, + "atlas_tenant_id": atlas_tenant_id, + "order_test_euid": reference_value, + "atlas_order_test_euid": reference_value, + "validation": {}, + } + if atlas_order_euid: + properties["order_euid"] = atlas_order_euid + properties["atlas_order_euid"] = atlas_order_euid + created_payloads.append(properties) + ref_obj = self.bobj.create_instance_by_code( + self.EXTERNAL_REFERENCE_TEMPLATE_CODE, + {"json_addl": {"properties": properties}}, + ) + self.bobj.create_generic_instance_lineage_by_euids( + instance.euid, + ref_obj.euid, + relationship_type=self.EXTERNAL_REFERENCE_RELATIONSHIP, + ) self._delete_reference_type(instance, reference_type=self.TEST_REFERENCE_TYPE) if atlas_tenant_id: for reference_value in atlas_test_euids: @@ -526,6 +754,21 @@ def _atlas_context_for_instance(self, instance) -> dict[str, Any]: reference_type=self.TRF_REFERENCE_TYPE, value_field="atlas_trf_euid", ) + atlas_order_euid = self._first_reachable_reference_value( + instance, + reference_type="order_euid", + value_field="order_euid", + ) + atlas_order_test_euid = self._first_reachable_reference_value( + instance, + reference_type="order_test_euid", + value_field="order_test_euid", + ) + direct_order_test_euids = self._reachable_reference_values( + instance, + reference_type="order_test_euid", + value_field="order_test_euid", + ) atlas_test_euid = self._first_reachable_reference_value( instance, reference_type=self.TEST_REFERENCE_TYPE, @@ -553,6 +796,16 @@ def _atlas_context_for_instance(self, instance) -> dict[str, Any]: ) atlas_test_euids: list[str] = [] seen_test_euids: set[str] = set() + atlas_order_test_euids: list[str] = [] + seen_order_test_euids: set[str] = set() + for direct_order_test_euid in direct_order_test_euids: + if direct_order_test_euid in seen_order_test_euids: + continue + seen_order_test_euids.add(direct_order_test_euid) + atlas_order_test_euids.append(direct_order_test_euid) + if atlas_order_test_euid and atlas_order_test_euid not in seen_order_test_euids: + seen_order_test_euids.add(atlas_order_test_euid) + atlas_order_test_euids.append(atlas_order_test_euid) for direct_test_euid in direct_test_euids: if direct_test_euid in seen_test_euids: continue @@ -581,6 +834,16 @@ def _atlas_context_for_instance(self, instance) -> dict[str, Any]: reference_type=self.TEST_REFERENCE_TYPE, value_field="atlas_tenant_id", ) + or self._first_reachable_reference_value( + instance, + reference_type="order_euid", + value_field="atlas_tenant_id", + ) + or self._first_reachable_reference_value( + instance, + reference_type="order_test_euid", + value_field="atlas_tenant_id", + ) or self._first_reachable_reference_value( instance, reference_type=self.TESTKIT_REFERENCE_TYPE, @@ -610,6 +873,9 @@ def _atlas_context_for_instance(self, instance) -> dict[str, Any]: if not fulfillment_items: return { "atlas_tenant_id": atlas_tenant_id, + "atlas_order_euid": atlas_order_euid, + "atlas_order_test_euid": atlas_order_test_euid, + "atlas_order_test_euids": atlas_order_test_euids, "atlas_trf_euid": atlas_trf_euid, "atlas_test_euid": atlas_test_euid, "atlas_test_euids": atlas_test_euids, @@ -630,10 +896,26 @@ def _atlas_context_for_instance(self, instance) -> dict[str, Any]: patient_ref["atlas_patient_euid"] if patient_ref is not None else "" ), "fulfillment_items": [], + "fulfillment_slots": [], } first = fulfillment_items[0] + fulfillment_slot_payloads = [ + { + "atlas_order_test_euid": item["atlas_order_test_euid"], + "atlas_fulfillment_slot_euid": item["atlas_fulfillment_slot_euid"], + } + for item in sorted( + fulfillment_items, + key=lambda item: item["atlas_test_fulfillment_item_euid"], + ) + if item.get("atlas_order_test_euid") + and item.get("atlas_fulfillment_slot_euid") + ] return { "atlas_tenant_id": first["atlas_tenant_id"], + "atlas_order_euid": atlas_order_euid, + "atlas_order_test_euid": atlas_order_test_euid, + "atlas_order_test_euids": atlas_order_test_euids, "atlas_trf_euid": first["atlas_trf_euid"] or atlas_trf_euid, "atlas_test_euid": atlas_test_euid or first["atlas_test_euid"], "atlas_test_euids": atlas_test_euids, @@ -665,6 +947,7 @@ def _atlas_context_for_instance(self, instance) -> dict[str, Any]: key=lambda item: item["atlas_test_fulfillment_item_euid"], ) ], + "fulfillment_slots": fulfillment_slot_payloads, } def _first_reachable_reference_value( diff --git a/bloom_lims/domain/beta_lab_stages.py b/bloom_lims/domain/beta_lab_stages.py index ae620b1..5b8969f 100644 --- a/bloom_lims/domain/beta_lab_stages.py +++ b/bloom_lims/domain/beta_lab_stages.py @@ -136,10 +136,15 @@ def create_extraction( extraction_run.euid, relationship_type="beta_extraction_batch_run", ) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( source.euid, extraction_run.euid, relationship_type="beta_extraction_run_input", + edge_type="RUN_CONSUMED", + source_euid=extraction_run.euid, + target_euid=source.euid, + source_role="workflow_run", + target_role="consumed_material", ) self.bobj.create_generic_instance_lineage_by_euids( plate.euid, @@ -205,20 +210,35 @@ def create_extraction( output.name = output_name output_props["name"] = output_name self._write_props(output, output_props) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( source.euid, output.euid, relationship_type="beta_extraction_output", + edge_type="DERIVED_FROM", + source_euid=output.euid, + target_euid=source.euid, + source_role="derived_material", + target_role="source_material", ) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( extraction_run.euid, output.euid, relationship_type="beta_extraction_run_output", + edge_type="RUN_PRODUCED", + source_euid=extraction_run.euid, + target_euid=output.euid, + source_role="workflow_run", + target_role="produced_material", ) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( well.euid, output.euid, - relationship_type="contains", + relationship_type="HOLDS_MATERIAL", + edge_type="HOLDS_MATERIAL", + source_euid=well.euid, + target_euid=output.euid, + source_role="container", + target_role="held_material", ) self._write_graph_metadata( output, @@ -231,7 +251,7 @@ def create_extraction( "beta_library_material_output", "beta_library_prep_output", "beta_post_extract_qc", - "contains", + "HOLDS_MATERIAL", ], max_child_count=6, reason="extraction output links its specimen, run, well, QC, and library material handoff", @@ -389,9 +409,7 @@ def record_post_extract_qc( "idempotency_key": idempotency_key or "", "occurred_at": self._timestamp(), }, - template_code=self.DATA_TEMPLATE_BY_BETA_KIND.get( - "post_extract_qc_result" - ), + template_code=self.DATA_TEMPLATE_BY_BETA_KIND.get("post_extract_qc_result"), ) if payload.quant_artifact_euid: qc_props = self._props(qc_record) @@ -523,7 +541,7 @@ def create_library_prep( idempotency_key=idempotency_key, ) library_well = ( - self._first_parent(library_material, "contains") + self._first_parent(library_material, "HOLDS_MATERIAL") if library_material is not None else None ) @@ -575,7 +593,7 @@ def create_library_prep( ) fulfillment_item_context = self._resolve_fulfillment_item_context(source) - source_well = self._first_parent(source, "contains") + source_well = self._first_parent(source, "HOLDS_MATERIAL") source_well_name = ( payload.library_well_name or self._well_name(source_well) or "A1" ) @@ -598,10 +616,15 @@ def create_library_prep( lib_output.name = lib_name lib_props["name"] = lib_name self._write_props(lib_output, lib_props) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( source.euid, lib_output.euid, relationship_type="beta_library_prep_output", + edge_type="DERIVED_FROM", + source_euid=lib_output.euid, + target_euid=source.euid, + source_role="derived_artifact", + target_role="source_material", ) self._write_graph_metadata( lib_output, @@ -653,10 +676,15 @@ def create_library_prep( library_material.name = library_material_name library_material_props["name"] = library_material_name self._write_props(library_material, library_material_props) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( source.euid, library_material.euid, relationship_type="beta_library_material_output", + edge_type="DERIVED_FROM", + source_euid=library_material.euid, + target_euid=source.euid, + source_role="derived_material", + target_role="source_material", ) self._write_graph_metadata( library_material, @@ -674,7 +702,7 @@ def create_library_prep( ), self._graph_expected_fanout_entry( relationship_types=[ - "contains", + "HOLDS_MATERIAL", "beta_used_reagent", "executed_on", "execution_subject_lease", @@ -705,10 +733,15 @@ def create_library_prep( library_well_props["beta_kind"] = "library_plate_well" library_well_props["idempotency_key"] = idempotency_key or "" self._write_props(library_well, library_well_props) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( library_well.euid, library_material.euid, - relationship_type="contains", + relationship_type="HOLDS_MATERIAL", + edge_type="HOLDS_MATERIAL", + source_euid=library_well.euid, + target_euid=library_material.euid, + source_role="container", + target_role="held_material", ) self._replace_fulfillment_item_references( lib_output, @@ -997,31 +1030,51 @@ def create_pool( }, template_code=self.DATA_TEMPLATE_BY_BETA_KIND.get("pooling_run"), ) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( pooling_run.euid, pool.euid, relationship_type="beta_pooling_run_output", + edge_type="RUN_PRODUCED", + source_euid=pooling_run.euid, + target_euid=pool.euid, + source_role="workflow_run", + target_role="produced_material", ) pool_container = self.bobj.create_instance_by_code( payload.pool_container_template_code or self.POOL_CONTAINER_TEMPLATE_CODE, {"json_addl": {"properties": {"name": f"{pool_name} tube"}}}, ) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( pool_container.euid, pool.euid, - relationship_type="contains", + relationship_type="HOLDS_MATERIAL", + edge_type="HOLDS_MATERIAL", + source_euid=pool_container.euid, + target_euid=pool.euid, + source_role="container", + target_role="held_material", ) for member in members: - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( member.euid, pool.euid, relationship_type="beta_pool_member", + edge_type="DERIVED_FROM", + source_euid=pool.euid, + target_euid=member.euid, + source_role="derived_material", + target_role="source_material", ) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( member.euid, pooling_run.euid, relationship_type="beta_pooling_run_input", + edge_type="RUN_CONSUMED", + source_euid=pooling_run.euid, + target_euid=member.euid, + source_role="workflow_run", + target_role="consumed_material", ) self._write_graph_metadata( pool, @@ -1035,7 +1088,7 @@ def create_pool( self._graph_expected_fanout_entry( relationship_types=[ "beta_sequencing_run", - "contains", + "HOLDS_MATERIAL", "executed_on", "execution_subject_lease", "execution_subject_record", @@ -1226,10 +1279,15 @@ def create_run( run_props = self._props(run) run_props["run_folder"] = f"{run.euid}/" self._write_props(run, run_props) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( pool.euid, run.euid, relationship_type="beta_sequencing_run", + edge_type="RUN_CONSUMED", + source_euid=run.euid, + target_euid=pool.euid, + source_role="workflow_run", + target_role="consumed_material", ) self._attach_execution_metadata_lineage(run, normalized_metadata) @@ -1266,16 +1324,26 @@ def create_run( assignment_record.euid, relationship_type="beta_sequenced_library_assignment", ) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( source.euid, assignment_record.euid, relationship_type="beta_assignment_source", + edge_type="RUN_CONSUMED", + source_euid=run.euid, + target_euid=source.euid, + source_role="workflow_run", + target_role="consumed_artifact", ) if library_material is not None: - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( library_material.euid, assignment_record.euid, relationship_type="beta_assignment_library_material", + edge_type="RUN_CONSUMED", + source_euid=run.euid, + target_euid=library_material.euid, + source_role="workflow_run", + target_role="consumed_material", ) if barcode_reagent is not None: self.bobj.create_generic_instance_lineage_by_euids( @@ -1343,10 +1411,15 @@ def create_run( }, template_code=self.DATA_TEMPLATE_BY_BETA_KIND.get("run_artifact"), ) - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( run.euid, artifact_record.euid, relationship_type="beta_run_artifact", + edge_type="RUN_PRODUCED", + source_euid=run.euid, + target_euid=artifact_record.euid, + source_role="workflow_run", + target_role="produced_artifact", ) self._attach_execution_metadata_lineage(artifact_record, artifact_metadata) @@ -1501,7 +1574,7 @@ def _extraction_response( self, extraction_output, *, replay: bool ) -> BetaExtractionResponse: source = self._first_parent(extraction_output, "beta_extraction_output") - well = self._first_parent(extraction_output, "contains") + well = self._first_parent(extraction_output, "HOLDS_MATERIAL") plate = self._first_parent(well, "contains") if well is not None else None run = self._first_parent(extraction_output, "beta_extraction_run_output") batch = ( diff --git a/bloom_lims/domain/beta_lab_store.py b/bloom_lims/domain/beta_lab_store.py index a4381a9..ca22b6a 100644 --- a/bloom_lims/domain/beta_lab_store.py +++ b/bloom_lims/domain/beta_lab_store.py @@ -73,12 +73,20 @@ def _ensure_container_link(self, container_euid: str, specimen_euid: str) -> Non child = lineage.child_instance if child is None or child.is_deleted: continue - if child.uid == specimen.uid and lineage.relationship_type == "contains": + if ( + child.uid == specimen.uid + and lineage.relationship_type == "HOLDS_MATERIAL" + ): return - self.bobj.create_generic_instance_lineage_by_euids( + self._attach_bloom_v0_lineage( container_euid, specimen_euid, - relationship_type="contains", + relationship_type="HOLDS_MATERIAL", + edge_type="HOLDS_MATERIAL", + source_euid=container_euid, + target_euid=specimen_euid, + source_role="container", + target_role="held_material", ) def _require_plate_well(self, plate, well_name: str): @@ -318,9 +326,9 @@ def _ensure_fixed_container_graph_metadata(self, instance) -> None: node_role="fixed_plate_well", expected_fanout=[ self._graph_expected_fanout_entry( - relationship_types=["contains"], + relationship_types=["HOLDS_MATERIAL"], max_child_count=1, - reason="plate well contains at most one material", + reason="plate well holds at most one material", ) ], ) diff --git a/bloom_lims/domain/containers.py b/bloom_lims/domain/containers.py index ed8f9c4..dfee826 100644 --- a/bloom_lims/domain/containers.py +++ b/bloom_lims/domain/containers.py @@ -7,16 +7,20 @@ Extracted from bloom_lims/bobjs.py for better code organization. """ +import json import logging from bloom_lims.domain.base import BloomObj +from bloom_lims.domain.v0_graph import attach_bloom_v0_edge, object_evidence logger = logging.getLogger(__name__) class BloomContainer(BloomObj): def __init__(self, bdb, is_deleted=False, cfg_printers=False, cfg_fedex=False): - super().__init__(bdb,is_deleted=is_deleted, cfg_printers=cfg_printers, cfg_fedex=cfg_fedex) + super().__init__( + bdb, is_deleted=is_deleted, cfg_printers=cfg_printers, cfg_fedex=cfg_fedex + ) def create_empty_container(self, template_euid): return self.create_instances(template_euid) @@ -24,10 +28,24 @@ def create_empty_container(self, template_euid): def link_content(self, container_euid, content_euid): # TapDB models container membership via lineage rows (container as parent, # contained instance/content as child), not an ORM "contents" relationship. - self.create_generic_instance_lineage_by_euids( + lineage = self.create_generic_instance_lineage_by_euids( parent_instance_euid=container_euid, child_instance_euid=content_euid, - relationship_type="contains", + relationship_type="HOLDS_MATERIAL", + ) + attach_bloom_v0_edge( + lineage, + edge_type="HOLDS_MATERIAL", + source_euid=container_euid, + target_euid=content_euid, + source_role="container", + target_role="material", + evidence_refs=[ + object_evidence(container_euid, role="container"), + object_evidence(content_euid, role="material"), + ], + correlation_id=f"bloom:{container_euid}:holds:{content_euid}", + causation_id=f"bloom:{container_euid}:link-content", ) self.session.commit() @@ -40,12 +58,16 @@ def unlink_content(self, container_euid, content_euid): lineage.is_deleted = True self.session.commit() return - raise Exception(f"Content {content_euid} not found in container {container_euid}") + raise Exception( + f"Content {content_euid} not found in container {container_euid}" + ) class BloomContainerPlate(BloomContainer): def __init__(self, bdb, is_deleted=False, cfg_printers=False, cfg_fedex=False): - super().__init__(bdb,is_deleted=is_deleted, cfg_printers=cfg_printers, cfg_fedex=cfg_fedex) + super().__init__( + bdb, is_deleted=is_deleted, cfg_printers=cfg_printers, cfg_fedex=cfg_fedex + ) def create_empty_plate(self, template_euid): return self.create_instances(template_euid) @@ -70,7 +92,7 @@ def organize_wells(self, wells, parent_container): try: layout = parent_container.json_addl["instantiation_layouts"] - except Exception as e: + except Exception: layout = json.loads(parent_container.json_addl)["instantiation_layouts"] num_rows = len(layout) num_cols = len(layout[0]) if num_rows > 0 else 0 @@ -82,12 +104,12 @@ def organize_wells(self, wells, parent_container): for well in wells: row_idx = ( int(json.loads(well.json_addl)["cont_address"]["row_idx"]) - if type(well.json_addl) == str() + if isinstance(well.json_addl, str) else int(well.json_addl["cont_address"]["row_idx"]) ) col_idx = ( int(json.loads(well.json_addl)["cont_address"]["col_idx"]) - if type(well.json_addl) == str() + if isinstance(well.json_addl, str) else int(well.json_addl["cont_address"]["col_idx"]) ) @@ -103,8 +125,6 @@ def organize_wells(self, wells, parent_container): return matrix - - __all__ = [ "BloomContainer", "BloomContainerPlate", diff --git a/bloom_lims/domain/execution_actions.py b/bloom_lims/domain/execution_actions.py index f1f7b9f..27896a0 100644 --- a/bloom_lims/domain/execution_actions.py +++ b/bloom_lims/domain/execution_actions.py @@ -228,7 +228,7 @@ def record( return action_record def _ensure_action_template(self, action_key: str) -> generic_template: - template_code = f"{EXECUTION_ACTION_TEMPLATE_PREFIX}/{EXECUTION_ACTION_GROUP}/{action_key}/1.0/" + template_code = f"action/{EXECUTION_ACTION_GROUP}/{action_key}/1.0/" return require_seeded_template( self.session, template_code, diff --git a/bloom_lims/domain/execution_queue.py b/bloom_lims/domain/execution_queue.py index 7d7e038..8b12f37 100644 --- a/bloom_lims/domain/execution_queue.py +++ b/bloom_lims/domain/execution_queue.py @@ -1479,8 +1479,12 @@ def current_queue_for_instance(self, instance) -> str | None: queue_key = self._authoritative_queue_key_for_instance(instance) if queue_key: return queue_key + category = instance_semantic_category(instance) + parent_relationship = ( + "contains" if category == "container" else "HOLDS_MATERIAL" + ) for lineage in get_child_lineages(instance): - if lineage.is_deleted or lineage.relationship_type != "contains": + if lineage.is_deleted or lineage.relationship_type != parent_relationship: continue parent = lineage.parent_instance if ( @@ -2050,8 +2054,12 @@ def _is_visible_in_queue(self, subject, queue, now: datetime) -> bool: visible_uids = {item.uid for item in self._visible_queue_items(queue, now)} if subject.uid in visible_uids: return True + category = instance_semantic_category(subject) + parent_relationship = ( + "contains" if category == "container" else "HOLDS_MATERIAL" + ) for lineage in get_child_lineages(subject): - if lineage.is_deleted or lineage.relationship_type != "contains": + if lineage.is_deleted or lineage.relationship_type != parent_relationship: continue parent = lineage.parent_instance if ( diff --git a/bloom_lims/domain/external_specimens.py b/bloom_lims/domain/external_specimens.py index 1939ec1..2fee82a 100644 --- a/bloom_lims/domain/external_specimens.py +++ b/bloom_lims/domain/external_specimens.py @@ -10,6 +10,7 @@ from bloom_lims.config import get_settings from bloom_lims.domain import BloomObj +from bloom_lims.domain.v0_graph import attach_bloom_v0_edge, object_evidence from bloom_lims.integrations.atlas.service import AtlasDependencyError, AtlasService from bloom_lims.schemas.external_specimens import ( AtlasReferences, @@ -33,6 +34,7 @@ class ExternalSpecimenService: "trf_euid", "patient_id", "order_euid", + "order_test_euid", "shipment_euid", "kit_barcode", "atlas_tenant_id", @@ -52,6 +54,10 @@ class ExternalSpecimenService: "reference_types": ("order_euid",), "value_field": "reference_value", }, + "order_test_euid": { + "reference_types": ("order_test_euid",), + "value_field": "reference_value", + }, "shipment_euid": { "reference_types": ("shipment_euid",), "value_field": "reference_value", @@ -76,6 +82,7 @@ class ExternalSpecimenService: _REFERENCE_RESPONSE_NORMALIZATION: dict[str, tuple[str, str]] = { "atlas_trf": ("atlas_trf_euid", "atlas_trf_euid"), "atlas_test": ("atlas_test_euid", "atlas_test_euid"), + "order_test_euid": ("order_test_euid", "reference_value"), "atlas_patient": ("atlas_patient_euid", "atlas_patient_euid"), "atlas_testkit": ("atlas_testkit_euid", "atlas_testkit_euid"), "atlas_shipment": ("atlas_shipment_euid", "atlas_shipment_euid"), @@ -299,12 +306,29 @@ def _ensure_container_link(self, container_euid: str, specimen_euid: str) -> Non for lineage in get_parent_lineages(container): if lineage.is_deleted: continue - if lineage.child_instance_uid == specimen.uid: + if ( + lineage.child_instance_uid == specimen.uid + and lineage.relationship_type == "HOLDS_MATERIAL" + ): return - self.bobj.create_generic_instance_lineage_by_euids( + lineage = self.bobj.create_generic_instance_lineage_by_euids( container_euid, specimen_euid, - relationship_type="contains", + relationship_type="HOLDS_MATERIAL", + ) + attach_bloom_v0_edge( + lineage, + edge_type="HOLDS_MATERIAL", + source_euid=container_euid, + target_euid=specimen_euid, + source_role="container", + target_role="material", + evidence_refs=[ + object_evidence(container_euid, role="container"), + object_evidence(specimen_euid, role="material"), + ], + correlation_id=f"bloom:{container_euid}:holds:{specimen_euid}", + causation_id=f"bloom:{specimen_euid}:external-specimen-container", ) def _find_by_idempotency_key(self, key: str): @@ -338,6 +362,7 @@ def _validate_atlas_refs( "trf_euid": refs.trf_euid, "patient_id": refs.patient_id, "order_euid": refs.order_euid, + "order_test_euid": refs.order_test_euid, "shipment_euid": refs.shipment_euid, "kit_barcode": refs.kit_barcode, "atlas_tenant_id": refs.atlas_tenant_id, @@ -594,7 +619,7 @@ def _expand_parent_uids_to_specimen_uids(self, parent_uids: set[int]) -> set[int .filter( lineage_cls.is_deleted.is_(False), lineage_cls.parent_instance_uid.in_(sorted(parent_uids)), - lineage_cls.relationship_type == "contains", + lineage_cls.relationship_type == "HOLDS_MATERIAL", instance_cls.domain_code == self.domain_code, instance_cls.is_deleted.is_(False), instance_category_filter(instance_cls, "content"), @@ -753,3 +778,23 @@ def _replace_external_references( ref_obj.euid, relationship_type=self.EXTERNAL_REFERENCE_RELATIONSHIP, ) + if reference_type in {"patient_id", "atlas_patient", "atlas_patient_euid"}: + subject_lineage = self.bobj.create_generic_instance_lineage_by_euids( + specimen.euid, + ref_obj.euid, + relationship_type="MATERIAL_FROM_SUBJECT", + ) + attach_bloom_v0_edge( + subject_lineage, + edge_type="MATERIAL_FROM_SUBJECT", + source_euid=specimen.euid, + target_euid=ref_obj.euid, + source_role="material", + target_role="subject_ref", + evidence_refs=[ + object_evidence(specimen.euid, role="material"), + object_evidence(ref_obj.euid, role="subject_ref"), + ], + correlation_id=f"bloom:{specimen.euid}:subject:{value}", + causation_id=f"bloom:{specimen.euid}:atlas-ref:{reference_type}", + ) diff --git a/bloom_lims/domain/lab_action_spreadsheets.py b/bloom_lims/domain/lab_action_spreadsheets.py new file mode 100644 index 0000000..fdf5587 --- /dev/null +++ b/bloom_lims/domain/lab_action_spreadsheets.py @@ -0,0 +1,194 @@ +"""Spreadsheet parsing helpers for Bloom lab-action uploads.""" + +from __future__ import annotations + +import csv +import io +import re +import zipfile +from dataclasses import dataclass +from pathlib import PurePosixPath +from typing import Any + +from defusedxml import ElementTree + + +@dataclass(frozen=True) +class ParsedSheet: + name: str + headers: list[str] + rows: list[dict[str, Any]] + + +_NS = { + "main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "rel": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "pkgrel": "http://schemas.openxmlformats.org/package/2006/relationships", +} + + +def normalize_header(value: Any) -> str: + """Normalize operator spreadsheet headers to stable snake_case keys.""" + text = str(value or "").strip().lower() + replacements = { + "continer": "container", + "conteiner": "container", + "templae": "template", + "destination": "target", + "(r)": "", + "(data)": "", + } + for bad, good in replacements.items(): + text = text.replace(bad, good) + text = text.replace("/", " ") + text = re.sub(r"[^a-z0-9]+", "_", text) + text = re.sub(r"_+", "_", text).strip("_") + return text + + +def _cell_column_index(cell_ref: str) -> int: + match = re.match(r"([A-Z]+)", cell_ref.upper()) + if not match: + return 0 + value = 0 + for char in match.group(1): + value = value * 26 + (ord(char) - ord("A") + 1) + return value - 1 + + +def _xml_text(element: ElementTree.Element | None) -> str: + if element is None: + return "" + return "".join(element.itertext()) + + +def _shared_strings(archive: zipfile.ZipFile) -> list[str]: + try: + root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml")) + except KeyError: + return [] + strings: list[str] = [] + for item in root.findall("main:si", _NS): + strings.append(_xml_text(item)) + return strings + + +def _sheet_paths(archive: zipfile.ZipFile) -> list[tuple[str, str]]: + workbook = ElementTree.fromstring(archive.read("xl/workbook.xml")) + rels = ElementTree.fromstring(archive.read("xl/_rels/workbook.xml.rels")) + rel_by_id = { + rel.attrib["Id"]: rel.attrib["Target"] + for rel in rels.findall("pkgrel:Relationship", _NS) + } + sheets: list[tuple[str, str]] = [] + for sheet in workbook.findall("main:sheets/main:sheet", _NS): + rel_id = sheet.attrib.get(f"{{{_NS['rel']}}}id", "") + target = rel_by_id.get(rel_id) + if not target: + continue + clean_target = target.lstrip("/") + path = ( + PurePosixPath(clean_target) + if clean_target.startswith("xl/") + else PurePosixPath("xl") / clean_target + ) + sheets.append((sheet.attrib.get("name", "Sheet"), str(path))) + return sheets + + +def _parse_xlsx_sheet( + archive: zipfile.ZipFile, path: str, shared_strings: list[str] +) -> list[list[Any]]: + root = ElementTree.fromstring(archive.read(path)) + rows: list[list[Any]] = [] + for row_el in root.findall(".//main:sheetData/main:row", _NS): + values: list[Any] = [] + for cell in row_el.findall("main:c", _NS): + col_index = _cell_column_index(cell.attrib.get("r", "A")) + while len(values) <= col_index: + values.append(None) + cell_type = cell.attrib.get("t") + value_el = cell.find("main:v", _NS) + if cell_type == "s": + raw = _xml_text(value_el) + values[col_index] = ( + shared_strings[int(raw)] if raw and raw.isdigit() else raw + ) + elif cell_type == "inlineStr": + values[col_index] = _xml_text(cell.find("main:is", _NS)) + else: + raw = _xml_text(value_el) + values[col_index] = raw if raw != "" else None + rows.append(values) + return rows + + +def _find_header_row(rows: list[list[Any]]) -> int | None: + best_index = None + best_score = 0 + for index, row in enumerate(rows[:20]): + headers = [normalize_header(value) for value in row] + non_empty = [header for header in headers if header] + score = len(set(non_empty)) + if any( + token in set(non_empty) + for token in { + "tube_euid", + "container_euid", + "source_well_euid", + "pool_tube_euid", + "annotation_template_euid", + } + ): + score += 10 + if score > best_score: + best_score = score + best_index = index + return best_index if best_score >= 2 else None + + +def _rows_to_sheet(name: str, rows: list[list[Any]]) -> ParsedSheet: + header_index = _find_header_row(rows) + if header_index is None: + return ParsedSheet(name=name, headers=[], rows=[]) + headers = [normalize_header(value) for value in rows[header_index]] + seen: dict[str, int] = {} + unique_headers: list[str] = [] + for index, header in enumerate(headers): + if not header: + header = f"unnamed_{index + 1}" + count = seen.get(header, 0) + seen[header] = count + 1 + unique_headers.append(header if count == 0 else f"{header}_{count + 1}") + parsed_rows: list[dict[str, Any]] = [] + for row in rows[header_index + 1 :]: + item = { + header: row[index] if index < len(row) else None + for index, header in enumerate(unique_headers) + } + if any(value not in (None, "") for value in item.values()): + parsed_rows.append(item) + return ParsedSheet(name=name, headers=unique_headers, rows=parsed_rows) + + +def parse_workbook(filename: str, data: bytes) -> list[ParsedSheet]: + """Parse CSV/XLSX data into normalized sheets. + + This function intentionally avoids optional Excel dependencies so deployed + Bloom containers can accept simple operator workbooks without changing the + runtime package set. + """ + suffix = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if suffix == "csv": + text = data.decode("utf-8-sig") + reader = csv.reader(io.StringIO(text)) + return [_rows_to_sheet("CSV", list(reader))] + if suffix != "xlsx": + raise ValueError("Spreadsheet upload must be .csv or .xlsx") + sheets: list[ParsedSheet] = [] + with zipfile.ZipFile(io.BytesIO(data)) as archive: + shared_strings = _shared_strings(archive) + for sheet_name, sheet_path in _sheet_paths(archive): + rows = _parse_xlsx_sheet(archive, sheet_path, shared_strings) + sheets.append(_rows_to_sheet(sheet_name, rows)) + return sheets diff --git a/bloom_lims/domain/lab_actions.py b/bloom_lims/domain/lab_actions.py new file mode 100644 index 0000000..a07cad2 --- /dev/null +++ b/bloom_lims/domain/lab_actions.py @@ -0,0 +1,1509 @@ +"""Bloom lab action orchestration over TapDB objects and lineage.""" + +from __future__ import annotations + +import csv +import io +import json +import re +from dataclasses import dataclass +from typing import Any + +from sqlalchemy.orm.attributes import flag_modified + +from bloom_lims.domain.base import BloomObj +from bloom_lims.domain.lab_action_spreadsheets import ParsedSheet, parse_workbook +from bloom_lims.domain.v0_graph import attach_bloom_v0_edge, object_evidence +from bloom_lims.integrations.zebra_day import ZebraDayService +from bloom_lims.schemas.lab_actions import ( + ExtractionPlateRequest, + ExtractionQcAssignment, + ExtractionQcPlateRequest, + ExtractionTubeAssignment, + LabSetMembersRequest, + LabSetRequest, + LibraryPlateAssignment, + PlateWellDataRecord, + PlateWellDataRequest, + PrintEuidRequest, + RunSetInput, + SeqLibraryPlateRequest, + SeqLibraryPoolRequest, + SeqRunSetRequest, + WellPosition, +) +from bloom_lims.template_identity import instance_semantic_category + +ROWS = tuple("ABCDEFGH") +COLS = tuple(range(1, 13)) +MAX_EXTRACTION_TUBES = 95 +RUN_SET_TEMPLATE_CODE = "set/run-set/generic/1.01/" +EXTRACTION_PLATE_TEMPLATE_CODE = "container/plate/fixed-plate-96/1.0/" +LIBRARY_PLATE_TEMPLATE_CODE = "container/plate/sequencing-library-plate-96/1.0/" +GDNA_TEMPLATE_CODE = "material/sample/gdna/1.0/" +LIBRARY_CONTENT_TEMPLATE_CODE = "material/sample/sequencing-library/1.0/" +POOL_TUBE_TEMPLATE_CODE = "container/tube/tube-generic-10ml/1.0/" +POOL_CONTENT_TEMPLATE_CODE = "material/pool/sequencing-library/1.0/" +GDNA_QUANT_TEMPLATE_CODE = "data/quantification/gdna/1.0/" +EXTRACTION_QC_TEMPLATE_CODE = "data/operation/extraction-qc/1.0/" +SEQUENCED_LIBRARY_ASSIGNMENT_TEMPLATE_CODE = ( + "data/library-index-assignment/sequencing-library/1.0/" +) + + +def re_split_multi(value: str) -> list[str]: + return re.split(r"[\n,|;]+", value) + + +@dataclass(frozen=True) +class WellMapping: + source_container_euid: str + source_content_euid: str + well_euid: str + well_name: str + output_content_euid: str + quant_euid: str | None = None + + +class LabActionsService: + """Service methods for temporary Bloom wet-lab action flows.""" + + def __init__(self, bdb, *, user_id: str | None = None, email: str | None = None): + self.bdb = bdb + self.bobj = BloomObj(bdb).set_actor_context(user_id=user_id, email=email) + + def close(self) -> None: + self.bdb.close() + + def _props(self, instance) -> dict[str, Any]: + payload = instance.json_addl if isinstance(instance.json_addl, dict) else {} + props = payload.get("properties") + return props if isinstance(props, dict) else {} + + def _write_props(self, instance, props: dict[str, Any]) -> None: + payload = instance.json_addl if isinstance(instance.json_addl, dict) else {} + payload["properties"] = props + instance.json_addl = payload + flag_modified(instance, "json_addl") + + def _require(self, euid: str): + instance = self.bobj.get_by_euid(euid) + if instance is None: + raise ValueError(f"Object not found: {euid}") + return instance + + def _lineages_from_parent(self, parent, relationship_type: str | None = None): + lineage_model = self.bdb.Base.classes.generic_instance_lineage + query = self.bdb.session.query(lineage_model).filter( + lineage_model.parent_instance_uid == parent.uid, + lineage_model.is_deleted.is_(False), + ) + if relationship_type: + query = query.filter(lineage_model.relationship_type == relationship_type) + return query.all() + + def _lineages_to_child(self, child, relationship_type: str | None = None): + lineage_model = self.bdb.Base.classes.generic_instance_lineage + query = self.bdb.session.query(lineage_model).filter( + lineage_model.child_instance_uid == child.uid, + lineage_model.is_deleted.is_(False), + ) + if relationship_type: + query = query.filter(lineage_model.relationship_type == relationship_type) + return query.all() + + def _create_by_code( + self, + template_code: str, + *, + name: str | None = None, + properties: dict[str, Any] | None = None, + status: str | None = None, + ): + props = dict(properties or {}) + if name is not None: + props["name"] = name + instance = self.bobj.create_instance_by_code( + template_code, {"json_addl": {"properties": props}} + ) + if name is not None: + instance.name = name + if status is not None: + instance.bstatus = status + self._write_props(instance, {**self._props(instance), **props}) + self.bdb.session.flush() + return instance + + def _create_lineage( + self, + parent_euid: str, + child_euid: str, + relationship_type: str, + *, + edge_type: str | None = None, + source_role: str = "source", + target_role: str = "target", + ): + lineage = self.bobj.create_generic_instance_lineage_by_euids( + parent_euid, child_euid, relationship_type=relationship_type + ) + if edge_type: + attach_bloom_v0_edge( + lineage, + edge_type=edge_type, + source_euid=parent_euid, + target_euid=child_euid, + source_role=source_role, + target_role=target_role, + evidence_refs=[ + object_evidence(parent_euid, role=source_role), + object_evidence(child_euid, role=target_role), + ], + correlation_id=f"bloom:{relationship_type}:{parent_euid}:{child_euid}", + causation_id=f"bloom:lab-actions:{relationship_type}", + ) + return lineage + + def _container_content(self, container) -> Any: + contents = [ + lineage.child_instance + for lineage in self._lineages_from_parent(container) + if instance_semantic_category(lineage.child_instance) == "content" + ] + if not contents: + raise ValueError(f"Container {container.euid} has no content") + if len(contents) > 1: + raise ValueError(f"Container {container.euid} has multiple contents") + return contents[0] + + def _plate_wells(self, plate) -> dict[str, Any]: + wells: dict[str, Any] = {} + for lineage in self._lineages_from_parent(plate, "contains"): + child = lineage.child_instance + if getattr(child, "type", "") != "well": + continue + props = child.json_addl if isinstance(child.json_addl, dict) else {} + address = props.get("cont_address") or {} + name = str(address.get("name") or child.name or "").strip().upper() + if name: + wells[name] = child + if not wells: + raise ValueError(f"Plate {plate.euid} has no linked wells") + return wells + + def _well_content(self, well): + return self._container_content(well) + + def _assert_well_empty(self, well) -> None: + contents = [ + lineage.child_instance + for lineage in self._lineages_from_parent(well) + if instance_semantic_category(lineage.child_instance) == "content" + ] + if contents: + raise ValueError(f"Destination well {well.euid} is already filled") + + def _position_names_auto(self, count: int) -> list[str]: + if count < 1 or count > MAX_EXTRACTION_TUBES: + raise ValueError(f"auto extraction accepts 1-{MAX_EXTRACTION_TUBES} tubes") + names = [f"{row}{col}" for row in ROWS for col in COLS] + return names[:count] + + def _normalize_assignments( + self, request: ExtractionPlateRequest + ) -> list[ExtractionTubeAssignment]: + if request.mode == "auto": + if len(set(request.tube_euids)) != len(request.tube_euids): + raise ValueError("duplicate tube_euids are not allowed") + positions = self._position_names_auto(len(request.tube_euids)) + return [ + ExtractionTubeAssignment( + tube_euid=tube_euid, + row=position[:1], + col=int(position[1:]), + ) + for tube_euid, position in zip(request.tube_euids, positions) + ] + if len({item.tube_euid for item in request.assignments}) != len( + request.assignments + ): + raise ValueError("duplicate assignment tube_euid values are not allowed") + seen_positions: set[str] = set() + for item in request.assignments: + if item.row is None or item.col is None: + raise ValueError("directed assignments require row and col") + position = WellPosition(row=item.row, col=item.col).name + if position in seen_positions: + raise ValueError(f"duplicate destination well: {position}") + seen_positions.add(position) + if len(request.assignments) > MAX_EXTRACTION_TUBES: + raise ValueError( + f"directed extraction accepts at most {MAX_EXTRACTION_TUBES} tubes" + ) + return request.assignments + + def _create_or_get_extraction_plate(self, request: ExtractionPlateRequest): + if request.plate_euid: + plate = self._require(request.plate_euid) + if ( + instance_semantic_category(plate) != "container" + or plate.type != "plate" + ): + raise ValueError(f"{request.plate_euid} is not a plate container") + return plate + return self._create_by_code( + EXTRACTION_PLATE_TEMPLATE_CODE, + name=request.plate_name or "extraction plate", + ) + + def _run_set( + self, + *, + requested: bool, + run_set: RunSetInput | None, + default_name: str, + member_euids: list[str], + ): + if not requested and run_set is None: + return None + payload = run_set or RunSetInput(name=default_name) + members = list(dict.fromkeys([*payload.members, *member_euids])) + instance = self._create_by_code( + RUN_SET_TEMPLATE_CODE, + name=payload.name or default_name, + properties={ + "name": payload.name or default_name, + "description": payload.description or "", + "members": members, + "external_members": payload.external_members, + "metadata": payload.metadata, + "status": payload.status, + }, + status=payload.status, + ) + for member in members: + if self.bobj.get_by_euid(member) is None: + continue + self._create_lineage(instance.euid, member, "run_set_member") + self._create_lineage(member, instance.euid, "associated_set") + return instance + + def _well_from_plate_position(self, plate_euid: str, row: str, col: int): + plate = self._require(plate_euid) + wells = self._plate_wells(plate) + position = WellPosition(row=row, col=col).name + well = wells.get(position) + if well is None: + raise ValueError(f"Destination well not found on plate: {position}") + return well + + def _well_for_data_record(self, record: PlateWellDataRecord): + if record.well_euid: + return self._require(record.well_euid) + return self._well_from_plate_position(record.plate_euid, record.row, record.col) + + def _well_for_qc_assignment( + self, assignment: ExtractionQcAssignment, source_plate_euid: str | None + ): + if assignment.source_well_euid: + return self._require(assignment.source_well_euid) + if not source_plate_euid: + raise ValueError( + "source_plate_euid is required when assignment uses row/col" + ) + return self._well_from_plate_position( + source_plate_euid, assignment.row, assignment.col + ) + + def _create_or_get_qc_plate(self, request: ExtractionQcPlateRequest): + if request.qc_plate_euid: + plate = self._require(request.qc_plate_euid) + if ( + instance_semantic_category(plate) != "container" + or plate.type != "plate" + ): + raise ValueError(f"{request.qc_plate_euid} is not a plate container") + return plate, [] + plate = self._create_by_code( + EXTRACTION_PLATE_TEMPLATE_CODE, + name=request.qc_plate_name or "extraction QC plate", + ) + return plate, [plate.euid] + + def _all_filled_well_qc_assignments( + self, source_plate_euid: str + ) -> list[ExtractionQcAssignment]: + plate = self._require(source_plate_euid) + assignments: list[ExtractionQcAssignment] = [] + for name, well in sorted(self._plate_wells(plate).items()): + if self._lineages_from_parent(well, "HOLDS_MATERIAL"): + assignments.append( + ExtractionQcAssignment( + source_well_euid=well.euid, + qc_row=name[:1], + qc_col=int(name[1:]), + ) + ) + if not assignments: + raise ValueError(f"Source plate {source_plate_euid} has no filled wells") + return assignments + + def create_lab_set(self, request: LabSetRequest) -> dict[str, Any]: + run_set = self._create_by_code( + RUN_SET_TEMPLATE_CODE, + name=request.name, + properties={ + "name": request.name, + "description": request.description or "", + "members": list(dict.fromkeys(request.members)), + "external_members": list(dict.fromkeys(request.external_members)), + "operator": request.operator or "", + "instrument": request.instrument or "", + "reagents": request.reagents, + "machine": request.machine or "", + "flowcell_barcode": request.flowcell_barcode or "", + "status": request.status, + "metadata": request.metadata, + }, + status=request.status, + ) + for member in dict.fromkeys(request.members): + if self.bobj.get_by_euid(member) is not None: + self._create_lineage(run_set.euid, member, "run_set_member") + self._create_lineage(member, run_set.euid, "associated_set") + self.bdb.session.commit() + return self.get_lab_set(run_set.euid) + + def add_lab_set_members( + self, set_euid: str, request: LabSetMembersRequest + ) -> dict[str, Any]: + run_set = self._require(set_euid) + props = self._props(run_set) + members = list(dict.fromkeys([*props.get("members", []), *request.members])) + external_members = list( + dict.fromkeys( + [*props.get("external_members", []), *request.external_members] + ) + ) + props["members"] = members + props["external_members"] = external_members + self._write_props(run_set, props) + for member in request.members: + if self.bobj.get_by_euid(member) is not None: + self._create_lineage(run_set.euid, member, "run_set_member") + self._create_lineage(member, run_set.euid, "associated_set") + self.bdb.session.commit() + return self.get_lab_set(set_euid) + + def get_lab_set(self, set_euid: str) -> dict[str, Any]: + run_set = self._require(set_euid) + props = self._props(run_set) + return { + "set_euid": run_set.euid, + "name": run_set.name, + "status": run_set.bstatus, + "properties": props, + "members": props.get("members", []), + "external_members": props.get("external_members", []), + } + + def fill_extraction_qc_plate( + self, request: ExtractionQcPlateRequest + ) -> dict[str, Any]: + assignments = request.assignments or self._all_filled_well_qc_assignments( + request.source_plate_euid + ) + if len(assignments) > 96: + raise ValueError("extraction QC plate can accept at most 96 assignments") + qc_plate, new_container_euids = self._create_or_get_qc_plate(request) + qc_wells = self._plate_wells(qc_plate) + used_qc_positions: set[str] = set() + mappings: list[dict[str, Any]] = [] + for index, assignment in enumerate(assignments): + source_well = self._well_for_qc_assignment( + assignment, request.source_plate_euid + ) + source_content = self._well_content(source_well) + if assignment.qc_row is not None and assignment.qc_col is not None: + qc_position = WellPosition( + row=assignment.qc_row, col=assignment.qc_col + ).name + else: + qc_position = WellPosition( + row=assignment.row or ROWS[index // 12], + col=assignment.col or (index % 12) + 1, + ).name + if qc_position in used_qc_positions: + raise ValueError(f"duplicate QC destination well: {qc_position}") + used_qc_positions.add(qc_position) + qc_well = qc_wells.get(qc_position) + if qc_well is None: + raise ValueError(f"QC well not found on plate: {qc_position}") + data = self._create_by_code( + EXTRACTION_QC_TEMPLATE_CODE, + name=f"{source_well.euid} extraction QC", + properties={ + "source_well_euid": source_well.euid, + "source_content_euid": source_content.euid, + "qc_plate_euid": qc_plate.euid, + "qc_well_euid": qc_well.euid, + "qc_well_name": qc_position, + "result": assignment.result or "", + "status": assignment.status or "recorded", + "data": assignment.data, + }, + status=assignment.status or "recorded", + ) + self._create_lineage(source_well.euid, qc_well.euid, "qc_source_well") + self._create_lineage(qc_well.euid, data.euid, "well_associated_data") + self._create_lineage(source_content.euid, data.euid, "qc_for_material") + mappings.append( + { + "source_well_euid": source_well.euid, + "source_content_euid": source_content.euid, + "qc_well_euid": qc_well.euid, + "qc_well_name": qc_position, + "data_euid": data.euid, + "result": assignment.result or "", + "status": assignment.status or "recorded", + } + ) + run_set = self._run_set( + requested=request.create_run_set, + run_set=request.run_set, + default_name=f"{qc_plate.euid} extraction QC set", + member_euids=[qc_plate.euid, *[item["data_euid"] for item in mappings]], + ) + self.bdb.session.commit() + return { + "qc_plate_euid": qc_plate.euid, + "run_set_euid": run_set.euid if run_set is not None else None, + "new_container_euids": new_container_euids, + "mappings": mappings, + } + + def attach_plate_well_data(self, request: PlateWellDataRequest) -> dict[str, Any]: + mappings: list[dict[str, Any]] = [] + plate_euids: set[str] = set() + for record in request.records: + well = self._well_for_data_record(record) + if record.plate_euid: + plate_euids.add(record.plate_euid) + target = well if record.target == "well" else self._well_content(well) + data = self._create_by_code( + request.data_template_code, + name=record.name or f"{well.euid} associated data", + properties={ + "well_euid": well.euid, + "target_euid": target.euid, + "target_kind": record.target, + "data": record.data, + }, + ) + self._create_lineage(target.euid, data.euid, request.relationship_type) + mappings.append( + { + "well_euid": well.euid, + "plate_euid": record.plate_euid, + "target_euid": target.euid, + "data_euid": data.euid, + "relationship_type": request.relationship_type, + } + ) + run_set = self._run_set( + requested=request.create_run_set, + run_set=request.run_set, + default_name="plate well data set", + member_euids=[item["data_euid"] for item in mappings], + ) + self.bdb.session.commit() + sorted_plate_euids = sorted(plate_euids) + return { + "plate_euid": sorted_plate_euids[0] + if len(sorted_plate_euids) == 1 + else None, + "plate_euids": sorted_plate_euids, + "run_set_euid": run_set.euid if run_set is not None else None, + "mappings": mappings, + } + + def create_extraction_plate( + self, request: ExtractionPlateRequest + ) -> dict[str, Any]: + assignments = self._normalize_assignments(request) + plate = self._create_or_get_extraction_plate(request) + wells = self._plate_wells(plate) + mappings: list[WellMapping] = [] + new_container_euids = [] if request.plate_euid else [plate.euid] + + for assignment in assignments: + position = WellPosition(row=assignment.row, col=assignment.col).name + if position not in wells: + raise ValueError(f"Destination well not found on plate: {position}") + well = wells[position] + self._assert_well_empty(well) + tube = self._require(assignment.tube_euid) + if instance_semantic_category(tube) != "container": + raise ValueError(f"{tube.euid} is not a container") + tube_content = self._container_content(tube) + gdna = self._create_by_code( + GDNA_TEMPLATE_CODE, + name=f"{tube_content.name or tube_content.euid} gDNA", + properties={ + "source_tube_euid": tube.euid, + "source_content_euid": tube_content.euid, + "well_euid": well.euid, + "well_name": position, + "extraction_plate_euid": plate.euid, + }, + ) + self._create_lineage(tube.euid, well.euid, "extraction_source_container") + self._create_lineage( + well.euid, + gdna.euid, + "HOLDS_MATERIAL", + edge_type="HOLDS_MATERIAL", + source_role="container", + target_role="held_material", + ) + self._create_lineage( + tube_content.euid, + gdna.euid, + "DERIVED_FROM", + edge_type="DERIVED_FROM", + source_role="source_material", + target_role="derived_material", + ) + quant_euid = None + if assignment.quant: + quant = self._create_by_code( + GDNA_QUANT_TEMPLATE_CODE, + name=f"{gdna.euid} quant", + properties={ + "gdna_euid": gdna.euid, + "well_euid": well.euid, + **assignment.quant, + }, + ) + quant_euid = quant.euid + self._create_lineage(gdna.euid, quant.euid, "quantifies") + mappings.append( + WellMapping( + source_container_euid=tube.euid, + source_content_euid=tube_content.euid, + well_euid=well.euid, + well_name=position, + output_content_euid=gdna.euid, + quant_euid=quant_euid, + ) + ) + + run_set = self._run_set( + requested=request.create_run_set, + run_set=request.run_set, + default_name=f"{plate.euid} extraction set", + member_euids=[plate.euid, *[item.output_content_euid for item in mappings]], + ) + self.bdb.session.commit() + return { + "plate_euid": plate.euid, + "run_set_euid": run_set.euid if run_set is not None else None, + "new_container_euids": new_container_euids, + "mappings": [item.__dict__ for item in mappings], + } + + def create_seq_library_plate( + self, request: SeqLibraryPlateRequest + ) -> dict[str, Any]: + if request.mode == "plate_1_to_1": + source_plate = self._require(request.source_plate_euid) + source_wells = self._plate_wells(source_plate) + assignments = [ + LibraryPlateAssignment( + source_well_euid=well.euid, + row=name[:1], + col=int(name[1:]), + ) + for name, well in sorted(source_wells.items()) + if self._lineages_from_parent(well, "HOLDS_MATERIAL") + ] + else: + assignments = request.assignments + for assignment in assignments: + if assignment.row is None or assignment.col is None: + raise ValueError("directed assignments require row and col") + + if not assignments: + raise ValueError("No source wells were provided or filled") + plate = self._create_by_code( + LIBRARY_PLATE_TEMPLATE_CODE, + name=request.plate_name or "seq library plate", + ) + dest_wells = self._plate_wells(plate) + used_destinations: set[str] = set() + mappings = [] + for assignment in assignments: + position = WellPosition(row=assignment.row, col=assignment.col).name + if position in used_destinations: + raise ValueError(f"duplicate destination well: {position}") + used_destinations.add(position) + source_well = self._require(assignment.source_well_euid) + source_content = self._well_content(source_well) + dest_well = dest_wells.get(position) + if dest_well is None: + raise ValueError(f"Destination well not found on plate: {position}") + self._assert_well_empty(dest_well) + library = self._create_by_code( + LIBRARY_CONTENT_TEMPLATE_CODE, + name=f"{source_content.name or source_content.euid} seq library", + properties={ + "source_well_euid": source_well.euid, + "source_content_euid": source_content.euid, + "library_plate_euid": plate.euid, + "library_well_euid": dest_well.euid, + "library_well_name": position, + "index_barcode": assignment.index_barcode or "", + "index_euid": assignment.index_euid or "", + "data": assignment.data, + }, + ) + self._create_lineage( + dest_well.euid, + library.euid, + "HOLDS_MATERIAL", + edge_type="HOLDS_MATERIAL", + source_role="container", + target_role="held_material", + ) + self._create_lineage( + source_content.euid, + library.euid, + "DERIVED_FROM", + edge_type="DERIVED_FROM", + source_role="source_material", + target_role="derived_material", + ) + self._create_lineage( + source_well.euid, dest_well.euid, "library_source_well" + ) + if assignment.index_euid: + index = self._require(assignment.index_euid) + self._create_lineage(library.euid, index.euid, "uses_index") + mappings.append( + { + "source_well_euid": source_well.euid, + "source_content_euid": source_content.euid, + "library_well_euid": dest_well.euid, + "library_content_euid": library.euid, + "well_name": position, + "index_barcode": assignment.index_barcode or "", + "index_euid": assignment.index_euid or "", + } + ) + run_set = self._run_set( + requested=request.create_run_set, + run_set=request.run_set, + default_name=f"{plate.euid} library set", + member_euids=[ + plate.euid, + *[item["library_content_euid"] for item in mappings], + ], + ) + self.bdb.session.commit() + return { + "plate_euid": plate.euid, + "run_set_euid": run_set.euid if run_set is not None else None, + "new_container_euids": [plate.euid], + "mappings": mappings, + } + + def _resolve_pool_input(self, euid: str) -> tuple[Any | None, Any]: + instance = self._require(euid) + semantic = instance_semantic_category(instance) + if semantic == "content": + parents = [ + lineage.parent_instance + for lineage in self._lineages_to_child(instance) + if instance_semantic_category(lineage.parent_instance) == "container" + ] + return (parents[0] if parents else None), instance + if semantic == "container": + return instance, self._container_content(instance) + raise ValueError(f"{euid} is not a container or content input") + + def create_seq_library_pool(self, request: SeqLibraryPoolRequest) -> dict[str, Any]: + if len(set(request.input_euids)) != len(request.input_euids): + raise ValueError("duplicate input_euids are not allowed") + inputs = [self._resolve_pool_input(euid) for euid in request.input_euids] + if request.pool_tube_euid: + pool_tube = self._require(request.pool_tube_euid) + self._assert_well_empty(pool_tube) + new_container_euids: list[str] = [] + else: + pool_tube = self._create_by_code( + POOL_TUBE_TEMPLATE_CODE, + name=f"{request.pool_name or request.platform.lower()} pool tube", + ) + new_container_euids = [pool_tube.euid] + pool = self._create_by_code( + POOL_CONTENT_TEMPLATE_CODE, + name=request.pool_name or f"{request.platform.lower()} sequencing pool", + properties={ + "platform": request.platform, + "member_euids": [content.euid for _, content in inputs], + "metadata": request.metadata, + "pool_tube_euid": pool_tube.euid, + }, + ) + self._create_lineage( + pool_tube.euid, + pool.euid, + "HOLDS_MATERIAL", + edge_type="HOLDS_MATERIAL", + source_role="container", + target_role="held_material", + ) + for container, content in inputs: + self._create_lineage( + content.euid, + pool.euid, + "DERIVED_FROM", + edge_type="DERIVED_FROM", + source_role="source_material", + target_role="derived_material", + ) + if container is not None: + self._create_lineage( + container.euid, pool_tube.euid, "pooled_from_container" + ) + run_set = self._run_set( + requested=request.create_run_set, + run_set=request.run_set, + default_name=f"{pool.euid} pool set", + member_euids=[pool_tube.euid, pool.euid], + ) + self.bdb.session.commit() + return { + "pool_tube_euid": pool_tube.euid, + "pool_content_euid": pool.euid, + "run_set_euid": run_set.euid if run_set is not None else None, + "new_container_euids": new_container_euids, + "member_euids": [content.euid for _, content in inputs], + } + + def create_seq_run_set(self, request: SeqRunSetRequest) -> dict[str, Any]: + pool_tube = self._require(request.pool_tube_euid) + pool_content = ( + self._require(request.pool_content_euid) + if request.pool_content_euid + else self._container_content(pool_tube) + ) + props = { + "name": request.name or f"{request.platform} sequencing run", + "description": request.description or "", + "members": [pool_tube.euid, pool_content.euid, *request.reagent_euids], + "external_members": [], + "operator": request.operator or "", + "instrument": request.instrument_euid or "", + "reagents": request.reagent_euids, + "machine": request.machine or "", + "flowcell_barcode": request.flowcell_barcode, + "status": request.status, + "platform": request.platform, + "pool_tube_euid": pool_tube.euid, + "pool_content_euid": pool_content.euid, + "metadata": request.metadata, + } + run_set = self._create_by_code( + RUN_SET_TEMPLATE_CODE, + name=props["name"], + properties=props, + status=request.status, + ) + self._create_lineage(run_set.euid, pool_tube.euid, "run_uses_pool") + self._create_lineage(run_set.euid, pool_content.euid, "run_uses_pool") + if request.instrument_euid: + self._create_lineage( + run_set.euid, request.instrument_euid, "run_uses_instrument" + ) + for reagent_euid in request.reagent_euids: + self._create_lineage(run_set.euid, reagent_euid, "run_uses_reagent") + assignments = self._create_sequenced_library_assignments( + run_set=run_set, + pool_content=pool_content, + flowcell_barcode=request.flowcell_barcode, + ) + self.bdb.session.commit() + return { + "set_euid": run_set.euid, + "status": run_set.bstatus, + "platform": request.platform, + "pool_tube_euid": pool_tube.euid, + "pool_content_euid": pool_content.euid, + "assignments": assignments, + } + + def _create_sequenced_library_assignments( + self, + *, + run_set, + pool_content, + flowcell_barcode: str, + ) -> list[dict[str, str]]: + assignments: list[dict[str, str]] = [] + source_libraries = [ + lineage.parent_instance + for lineage in self._lineages_to_child(pool_content, "DERIVED_FROM") + ] + for index, library in enumerate(source_libraries, start=1): + props = self._props(library) + barcode = str(props.get("index_barcode") or "").strip() + lane = str(props.get("lane") or props.get("lane_index") or "1").strip() + assignment = self._create_by_code( + SEQUENCED_LIBRARY_ASSIGNMENT_TEMPLATE_CODE, + name=f"{run_set.euid}:{lane}:{barcode or library.euid}", + properties={ + "flowcell_id": flowcell_barcode, + "lane": lane, + "library_barcode": barcode, + "library_prep_output_euid": library.euid, + "library_material_euid": library.euid, + "barcode_reagent_euid": str(props.get("index_euid") or "").strip(), + "metadata": { + "created_by": "bloom_lab_actions", + "pool_content_euid": pool_content.euid, + }, + }, + ) + self._create_lineage( + run_set.euid, + assignment.euid, + "beta_sequenced_library_assignment", + ) + self._create_lineage( + library.euid, + assignment.euid, + "beta_assignment_source", + edge_type="RUN_CONSUMED", + source_role="sequencing_run_set", + target_role="library_material", + ) + assignments.append( + { + "assignment_euid": assignment.euid, + "library_content_euid": library.euid, + "flowcell_id": flowcell_barcode, + "lane": lane, + "library_barcode": barcode, + } + ) + return assignments + + def plate_mapping_rows(self, plate_euid: str) -> list[dict[str, str]]: + plate = self._require(plate_euid) + wells = self._plate_wells(plate) + rows: list[dict[str, str]] = [] + for well_name, well in sorted(wells.items()): + row = well_name[:1] + col = well_name[1:] + well_contents = [ + lineage.child_instance + for lineage in self._lineages_from_parent(well) + if instance_semantic_category(lineage.child_instance) == "content" + ] + source_tubes = [ + lineage.parent_instance.euid + for lineage in self._lineages_to_child( + well, "extraction_source_container" + ) + ] + for content in well_contents or [None]: + parent_contents = [] + child_euids = [] + if content is not None: + parent_contents = [ + lineage.parent_instance.euid + for lineage in self._lineages_to_child(content) + ] + child_euids = [ + lineage.child_instance.euid + for lineage in self._lineages_from_parent(content) + ] + rows.append( + { + "tube_euid": "|".join(source_tubes), + "plate_euid": plate.euid, + "well_euid": well.euid, + "well_row": row, + "well_col": col, + "well_content_euid": content.euid + if content is not None + else "", + "parent_content_euid": "|".join(parent_contents), + "one_degree_parent_euids": "|".join(parent_contents), + "one_degree_child_euids": "|".join(child_euids), + } + ) + return rows + + def plate_mapping_csv(self, plate_euid: str) -> str: + rows = self.plate_mapping_rows(plate_euid) + output = io.StringIO() + fieldnames = [ + "tube_euid", + "plate_euid", + "well_euid", + "well_row", + "well_col", + "well_content_euid", + "parent_content_euid", + "one_degree_parent_euids", + "one_degree_child_euids", + ] + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + return output.getvalue() + + def sequencing_sample_sheet(self, set_euid: str) -> str: + run_set = self._require(set_euid) + props = self._props(run_set) + platform = str(props.get("platform") or "").strip() + pool_content = self._require(str(props.get("pool_content_euid") or "")) + library_inputs = [ + lineage.parent_instance + for lineage in self._lineages_to_child(pool_content, "DERIVED_FROM") + ] + data_rows = [] + for library in library_inputs: + library_props = self._props(library) + source_gdna = [ + lineage.parent_instance + for lineage in self._lineages_to_child(library, "DERIVED_FROM") + ] + source_material = source_gdna[0] if source_gdna else None + source_specimen = ( + [ + lineage.parent_instance + for lineage in self._lineages_to_child( + source_material, "DERIVED_FROM" + ) + ][0] + if source_material is not None + and self._lineages_to_child(source_material, "DERIVED_FROM") + else None + ) + data_rows.append( + { + "Sample_ID": library.euid, + "Sample_Name": library.name or library.euid, + "index": str(library_props.get("index_barcode") or ""), + "Description": json.dumps( + { + "run_set_euid": run_set.euid, + "library_euid": library.euid, + "source_gdna_euid": source_material.euid + if source_material is not None + else "", + "source_specimen_content_euid": source_specimen.euid + if source_specimen is not None + else "", + }, + separators=(",", ":"), + ), + } + ) + if platform == "ONT": + return self._ont_sample_sheet( + run_set=run_set, props=props, data_rows=data_rows + ) + if platform != "ILMN": + raise ValueError( + f"Sample sheet download is implemented for ILMN and ONT, not {platform or 'unknown'}" + ) + return self._illumina_sample_sheet( + run_set=run_set, props=props, data_rows=data_rows + ) + + def sequencing_sample_sheet_download(self, set_euid: str) -> tuple[str, str, str]: + run_set = self._require(set_euid) + props = self._props(run_set) + platform = str(props.get("platform") or "").strip() + content = self.sequencing_sample_sheet(set_euid) + if platform == "ONT": + return content, f"{set_euid}_ONT_manifest.tsv", "text/tab-separated-values" + return ( + content, + f"{set_euid}_{platform or 'sequencing'}_SampleSheet.csv", + "text/csv", + ) + + def _illumina_sample_sheet( + self, *, run_set, props: dict[str, Any], data_rows: list[dict[str, str]] + ) -> str: + output = io.StringIO() + output.write("[Header]\n") + output.write(f"RunSetEUID,{run_set.euid}\n") + output.write(f"Flowcell,{props.get('flowcell_barcode') or ''}\n") + output.write(f"Operator,{props.get('operator') or ''}\n") + output.write("\n[Reads]\n151\n151\n\n[Data]\n") + writer = csv.DictWriter( + output, fieldnames=["Sample_ID", "Sample_Name", "index", "Description"] + ) + writer.writeheader() + writer.writerows(data_rows) + return output.getvalue() + + def _ont_sample_sheet( + self, *, run_set, props: dict[str, Any], data_rows: list[dict[str, str]] + ) -> str: + output = io.StringIO() + fieldnames = [ + "sample_id", + "alias", + "barcode", + "run_set_euid", + "library_euid", + "source_gdna_euid", + "source_specimen_content_euid", + "flowcell_barcode", + "operator", + ] + writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter="\t") + writer.writeheader() + for row in data_rows: + description = json.loads(row.get("Description") or "{}") + writer.writerow( + { + "sample_id": row.get("Sample_ID") or "", + "alias": row.get("Sample_Name") or row.get("Sample_ID") or "", + "barcode": row.get("index") or "", + "run_set_euid": run_set.euid, + "library_euid": description.get("library_euid") + or row.get("Sample_ID") + or "", + "source_gdna_euid": description.get("source_gdna_euid") or "", + "source_specimen_content_euid": description.get( + "source_specimen_content_euid" + ) + or "", + "flowcell_barcode": props.get("flowcell_barcode") or "", + "operator": props.get("operator") or "", + } + ) + return output.getvalue() + + def _split_values(self, value: Any) -> list[str]: + if value in (None, ""): + return [] + return [ + token.strip() + for token in re_split_multi(str(value)) + if token and token.strip() + ] + + def _row_metadata(self, row: dict[str, Any], *prefixes: str) -> dict[str, Any]: + metadata: dict[str, Any] = {} + for key, value in row.items(): + if value in (None, ""): + continue + for prefix in prefixes: + if key.startswith(prefix): + metadata[key.removeprefix(prefix)] = value + return metadata + + def _sheet_action(self, sheet: ParsedSheet) -> str: + name = sheet.name.strip().lower().replace("-", " ").replace("_", " ") + headers = set(sheet.headers) + if name == "csv": + if {"pool_tube_euid", "flowcell_barcode"} & headers: + return "seq_run_set" + if {"input_euid", "input_euids"} & headers: + return "seq_pool" + if ( + "source_well_euid" in headers + and {"index_barcode", "index_euid"} & headers + ): + return "seq_library_plate" + if {"source_well_euid", "qc_row", "qc_col"} & headers: + return "extraction_qc" + if {"well_euid", "data_template_code"} & headers: + return "plate_well_data" + if {"set_name", "members", "member_euids"} & headers: + return "sets" + if "tube_euid" in headers: + return "extraction_plate" + if name in {"extraction plate", "extraction"}: + return "extraction_plate" + if name in {"extraction qc", "extractionqc", "extraction qc plate"}: + return "extraction_qc" + if name in {"seq library plate", "sequencing library plate", "library plate"}: + return "seq_library_plate" + if name in {"seq pool", "sequencing pool", "seq library pool", "pool"}: + return "seq_pool" + if name in {"seq run", "seq run set", "sequencing run", "sequencing run set"}: + return "seq_run_set" + if name in {"sets", "set", "run sets", "run set"}: + return "sets" + if name in {"plate well data", "well data", "annotation"}: + return ( + "plate_well_data" + if name != "annotation" + else "container_annotation_preview" + ) + if name in {"bulk create", "transfer"}: + return "container_interaction_preview" + return "unknown" + + def _request_from_extraction_sheet( + self, sheet: ParsedSheet + ) -> ExtractionPlateRequest: + assignments: list[ExtractionTubeAssignment] = [] + tube_euids: list[str] = [] + plate_name: str | None = None + for row in sheet.rows: + tube_euid = str( + row.get("tube_euid") or row.get("container_euid") or "" + ).strip() + if not tube_euid: + continue + plate_name = plate_name or str(row.get("plate_name") or "").strip() or None + row_name = ( + row.get("row") or row.get("well_row") or row.get("child_container_row") + ) + col_value = ( + row.get("col") + or row.get("well_col") + or row.get("child_container_column") + ) + if row_name not in (None, "") or col_value not in (None, ""): + assignments.append( + ExtractionTubeAssignment( + tube_euid=tube_euid, + row=row_name, + col=col_value, + quant=self._row_metadata(row, "quant_", "qc_"), + ) + ) + else: + tube_euids.append(tube_euid) + if assignments: + return ExtractionPlateRequest( + mode="directed", + assignments=assignments, + plate_name=plate_name or "extraction plate", + ) + return ExtractionPlateRequest( + mode="auto", + tube_euids=tube_euids, + plate_name=plate_name or "extraction plate", + ) + + def _request_from_qc_sheet(self, sheet: ParsedSheet) -> ExtractionQcPlateRequest: + source_plate_euid = None + qc_plate_name = None + assignments: list[ExtractionQcAssignment] = [] + for row in sheet.rows: + source_plate_euid = ( + source_plate_euid + or str( + row.get("source_plate_euid") or row.get("plate_euid") or "" + ).strip() + or None + ) + qc_plate_name = ( + qc_plate_name or str(row.get("qc_plate_name") or "").strip() or None + ) + assignments.append( + ExtractionQcAssignment( + source_well_euid=str( + row.get("source_well_euid") or row.get("well_euid") or "" + ).strip() + or None, + row=row.get("row") or row.get("well_row"), + col=row.get("col") or row.get("well_col"), + qc_row=row.get("qc_row") or row.get("target_row"), + qc_col=row.get("qc_col") or row.get("target_col"), + result=str(row.get("result") or "").strip() or None, + status=str(row.get("status") or "").strip() or None, + data=self._row_metadata(row, "data_", "qc_", "metric_"), + ) + ) + return ExtractionQcPlateRequest( + source_plate_euid=source_plate_euid, + qc_plate_name=qc_plate_name or "extraction QC plate", + assignments=assignments, + ) + + def _request_from_library_sheet(self, sheet: ParsedSheet) -> SeqLibraryPlateRequest: + source_plate_euid = None + plate_name = None + assignments: list[LibraryPlateAssignment] = [] + for row in sheet.rows: + source_plate_euid = ( + source_plate_euid + or str(row.get("source_plate_euid") or "").strip() + or None + ) + plate_name = plate_name or str(row.get("plate_name") or "").strip() or None + source_well_euid = str( + row.get("source_well_euid") or row.get("well_euid") or "" + ).strip() + if source_well_euid: + assignments.append( + LibraryPlateAssignment( + source_well_euid=source_well_euid, + row=row.get("row") or row.get("well_row"), + col=row.get("col") or row.get("well_col"), + index_barcode=str(row.get("index_barcode") or "").strip() + or None, + index_euid=str(row.get("index_euid") or "").strip() or None, + data=self._row_metadata(row, "data_", "library_"), + ) + ) + if assignments: + return SeqLibraryPlateRequest( + mode="directed", + assignments=assignments, + plate_name=plate_name or "seq library plate", + ) + return SeqLibraryPlateRequest( + mode="plate_1_to_1", + source_plate_euid=source_plate_euid, + plate_name=plate_name or "seq library plate", + ) + + def _request_from_pool_sheet(self, sheet: ParsedSheet) -> SeqLibraryPoolRequest: + input_euids: list[str] = [] + platform = "ILMN" + pool_tube_euid = None + pool_name = None + metadata: dict[str, Any] = {} + for row in sheet.rows: + input_euids.extend( + self._split_values( + row.get("input_euid") + or row.get("input_euids") + or row.get("well_euid") + or row.get("tube_euid") + or row.get("container_euid") + or row.get("content_euid") + ) + ) + platform = str(row.get("platform") or platform).strip() or platform + pool_tube_euid = ( + pool_tube_euid or str(row.get("pool_tube_euid") or "").strip() or None + ) + pool_name = pool_name or str(row.get("pool_name") or "").strip() or None + metadata.update(self._row_metadata(row, "metadata_", "pool_")) + return SeqLibraryPoolRequest( + input_euids=list(dict.fromkeys(input_euids)), + platform=platform, + pool_tube_euid=pool_tube_euid, + pool_name=pool_name or "seq library", + metadata=metadata, + ) + + def _request_from_run_sheet(self, sheet: ParsedSheet) -> SeqRunSetRequest: + if not sheet.rows: + raise ValueError("Seq Run Set sheet has no rows") + row = sheet.rows[0] + return SeqRunSetRequest( + pool_tube_euid=str(row.get("pool_tube_euid") or "").strip(), + pool_content_euid=str(row.get("pool_content_euid") or "").strip() or None, + platform=str(row.get("platform") or "ILMN").strip() or "ILMN", + operator=str(row.get("operator") or "").strip() or None, + instrument_euid=str(row.get("instrument_euid") or "").strip() or None, + machine=str(row.get("machine") or "").strip() or None, + flowcell_barcode=str( + row.get("flowcell_barcode") or row.get("flowcell") or "" + ).strip(), + reagent_euids=self._split_values( + row.get("reagent_euids") or row.get("reagents") + ), + status=str(row.get("status") or "created").strip() or "created", + name=str(row.get("name") or "").strip() or None, + description=str(row.get("description") or "").strip() or None, + metadata=self._row_metadata(row, "metadata_", "run_"), + ) + + def _request_from_data_sheet(self, sheet: ParsedSheet) -> PlateWellDataRequest: + records: list[PlateWellDataRecord] = [] + data_template_code = "data/operation/extraction-qc/1.0/" + relationship_type = "well_associated_data" + for row in sheet.rows: + data_template_code = str( + row.get("data_template_code") + or row.get("annotation_template_euid") + or data_template_code + ).strip() + relationship_type = str( + row.get("relationship_type") or relationship_type + ).strip() + records.append( + PlateWellDataRecord( + well_euid=str( + row.get("well_euid") or row.get("child_container_euid") or "" + ).strip() + or None, + plate_euid=str( + row.get("plate_euid") or row.get("container_euid") or "" + ).strip() + or None, + row=row.get("row") + or row.get("well_row") + or row.get("child_container_row"), + col=row.get("col") + or row.get("well_col") + or row.get("child_container_column"), + name=str( + row.get("name") or row.get("annotation_name") or "" + ).strip() + or None, + target="content" + if "content" + in str( + row.get("target") + or row.get("annotate_container_child_container_content") + or "content" + ) + .strip() + .lower() + else "well", + data={ + **self._row_metadata(row, "data_", "annotation_", "metric_"), + **( + {"value": row.get("annotation_value")} + if row.get("annotation_value") not in (None, "") + else {} + ), + }, + ) + ) + return PlateWellDataRequest( + data_template_code=data_template_code, + relationship_type=relationship_type, + records=records, + ) + + def _requests_from_sets_sheet(self, sheet: ParsedSheet) -> list[LabSetRequest]: + requests: list[LabSetRequest] = [] + for row in sheet.rows: + name = str(row.get("name") or row.get("set_name") or "").strip() + if not name: + continue + requests.append( + LabSetRequest( + name=name, + description=str(row.get("description") or "").strip() or None, + members=self._split_values( + row.get("members") or row.get("member_euids") + ), + external_members=self._split_values(row.get("external_members")), + operator=str(row.get("operator") or "").strip() or None, + instrument=str( + row.get("instrument") or row.get("instrument_euid") or "" + ).strip() + or None, + reagents=self._split_values( + row.get("reagents") or row.get("reagent_euids") + ), + machine=str(row.get("machine") or "").strip() or None, + flowcell_barcode=str(row.get("flowcell_barcode") or "").strip() + or None, + status=str(row.get("status") or "created").strip() or "created", + metadata=self._row_metadata(row, "metadata_", "set_"), + ) + ) + return requests + + def import_spreadsheet( + self, *, filename: str, data: bytes, dry_run: bool = True + ) -> dict[str, Any]: + sheets = parse_workbook(filename, data) + actions: list[dict[str, Any]] = [] + for sheet in sheets: + action = self._sheet_action(sheet) + entry: dict[str, Any] = { + "sheet": sheet.name, + "action": action, + "headers": sheet.headers, + "row_count": len(sheet.rows), + } + if action in { + "container_interaction_preview", + "container_annotation_preview", + "unknown", + }: + entry["preview_rows"] = sheet.rows[:25] + actions.append(entry) + continue + if action == "extraction_plate": + request = self._request_from_extraction_sheet(sheet) + entry["request"] = request.model_dump(mode="json") + if not dry_run: + entry["result"] = self.create_extraction_plate(request) + elif action == "extraction_qc": + request = self._request_from_qc_sheet(sheet) + entry["request"] = request.model_dump(mode="json") + if not dry_run: + entry["result"] = self.fill_extraction_qc_plate(request) + elif action == "seq_library_plate": + request = self._request_from_library_sheet(sheet) + entry["request"] = request.model_dump(mode="json") + if not dry_run: + entry["result"] = self.create_seq_library_plate(request) + elif action == "seq_pool": + request = self._request_from_pool_sheet(sheet) + entry["request"] = request.model_dump(mode="json") + if not dry_run: + entry["result"] = self.create_seq_library_pool(request) + elif action == "seq_run_set": + request = self._request_from_run_sheet(sheet) + entry["request"] = request.model_dump(mode="json") + if not dry_run: + entry["result"] = self.create_seq_run_set(request) + elif action == "plate_well_data": + request = self._request_from_data_sheet(sheet) + entry["request"] = request.model_dump(mode="json") + if not dry_run: + entry["result"] = self.attach_plate_well_data(request) + elif action == "sets": + requests = self._requests_from_sets_sheet(sheet) + entry["request"] = [item.model_dump(mode="json") for item in requests] + if not dry_run: + entry["result"] = [self.create_lab_set(item) for item in requests] + actions.append(entry) + return {"filename": filename, "dry_run": dry_run, "actions": actions} + + def print_euids(self, request: PrintEuidRequest) -> dict[str, Any]: + results = [] + for euid in request.euids: + self._require(euid) + if request.dry_run: + results.append( + { + "dry_run": True, + "lab": request.lab, + "printer_id": request.printer_id, + "label_zpl_style": request.label_zpl_style, + "euid": euid, + "copies": request.copies, + } + ) + continue + service = ZebraDayService() + results.append( + service.submit_print_job( + lab=request.lab, + printer_id=request.printer_id, + label_zpl_style=request.label_zpl_style, + euid=euid, + print_n=request.copies, + ) + ) + return { + "printed": 0 if request.dry_run else len(results), + "dry_run": request.dry_run, + "results": results, + } diff --git a/bloom_lims/domain/v0_graph.py b/bloom_lims/domain/v0_graph.py new file mode 100644 index 0000000..f05a43d --- /dev/null +++ b/bloom_lims/domain/v0_graph.py @@ -0,0 +1,99 @@ +"""Bloom helpers for the locked LSMC v0 graph contract.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy.orm.attributes import flag_modified + +LSMC_V0_EDGE_TYPES = { + "CONTAINS", + "HOLDS_MATERIAL", + "MATERIAL_FROM_SUBJECT", + "SLOT_SATISFIED_BY", + "RUN_CONSUMED", + "RUN_PRODUCED", + "DERIVED_FROM", + "USES_EVIDENCE", +} + + +def _mark_json_addl_dirty(lineage: Any) -> None: + try: + flag_modified(lineage, "json_addl") + except (AttributeError, TypeError, KeyError): + # Unit tests and small fakes are not SQLAlchemy-mapped rows. + return + + +def _normalize_evidence_refs(value: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not value: + raise ValueError("v0 edge evidence_refs is required") + refs: list[dict[str, Any]] = [] + for item in value: + euid = str(item.get("euid") or "").strip() + if not euid: + raise ValueError("v0 edge evidence reference requires euid") + cleaned = dict(item) + cleaned["euid"] = euid + refs.append(cleaned) + return refs + + +def attach_bloom_v0_edge( + lineage: Any, + *, + edge_type: str, + source_euid: str, + target_euid: str, + evidence_refs: list[dict[str, Any]], + correlation_id: str, + causation_id: str, + edge_state: str = "active", + source_role: str = "source", + target_role: str = "target", + source_system: str = "bloom", + target_system: str = "bloom", +) -> Any: + canonical = str(edge_type or "").strip().upper() + if canonical not in LSMC_V0_EDGE_TYPES: + raise ValueError(f"Unsupported LSMC v0 edge type: {edge_type!r}") + if not str(correlation_id or "").strip(): + raise ValueError("v0 edge correlation_id is required") + if not str(causation_id or "").strip(): + raise ValueError("v0 edge causation_id is required") + json_addl = lineage.json_addl if isinstance(lineage.json_addl, dict) else {} + properties = json_addl.get("properties") + if not isinstance(properties, dict): + properties = {} + json_addl["properties"] = properties + asserted_at = datetime.now(UTC).isoformat() + properties["v0_edge"] = { + "contract": "LSMC_V0", + "edge_type": canonical, + "semantic_source": { + "system": str(source_system or "bloom").strip(), + "euid": str(source_euid).strip(), + "role": str(source_role or "source").strip(), + }, + "semantic_target": { + "system": str(target_system or "bloom").strip(), + "euid": str(target_euid).strip(), + "role": str(target_role or "target").strip(), + }, + "asserted_by_system": "bloom", + "asserted_at": asserted_at, + "evidence_refs": _normalize_evidence_refs(evidence_refs), + "correlation_id": str(correlation_id).strip(), + "causation_id": str(causation_id).strip(), + "validity": {"valid_from": asserted_at, "valid_to": None}, + "edge_state": str(edge_state or "active").strip(), + } + lineage.json_addl = json_addl + _mark_json_addl_dirty(lineage) + return lineage + + +def object_evidence(euid: str, *, role: str, system: str = "bloom") -> dict[str, Any]: + return {"system": system, "euid": str(euid).strip(), "role": role} diff --git a/bloom_lims/etc/prefix_ownership_registry.json b/bloom_lims/etc/prefix_ownership_registry.json index f187f31..9c703fd 100644 --- a/bloom_lims/etc/prefix_ownership_registry.json +++ b/bloom_lims/etc/prefix_ownership_registry.json @@ -7,6 +7,9 @@ "BAC": { "issuer_app_code": "bloom" }, + "BAN": { + "issuer_app_code": "bloom" + }, "BAR": { "issuer_app_code": "bloom" }, @@ -166,6 +169,12 @@ "EDG": { "issuer_app_code": "daylily-tapdb" }, + "GVR": { + "issuer_app_code": "daylily-tapdb" + }, + "GSE": { + "issuer_app_code": "daylily-tapdb" + }, "MSG": { "issuer_app_code": "daylily-tapdb" }, @@ -174,6 +183,9 @@ }, "TPX": { "issuer_app_code": "daylily-tapdb" + }, + "XRF": { + "issuer_app_code": "daylily-tapdb" } } }, diff --git a/bloom_lims/gui/actions.py b/bloom_lims/gui/actions.py index 32d7cdb..d695dd2 100644 --- a/bloom_lims/gui/actions.py +++ b/bloom_lims/gui/actions.py @@ -4,6 +4,43 @@ from bloom_lims.domain import BloomObj +RELATIONSHIP_TYPE_OPTIONS = [ + {"value": "contains", "label": "contains"}, + {"value": "derived_from", "label": "derived_from"}, + {"value": "aliquot_of", "label": "aliquot_of"}, + {"value": "prepared_from", "label": "prepared_from"}, + {"value": "member_of", "label": "member_of"}, + {"value": "has_external_reference", "label": "has_external_reference"}, + {"value": "beta_extraction_batch_run", "label": "beta_extraction_batch_run"}, + {"value": "beta_extraction_run_input", "label": "beta_extraction_run_input"}, + {"value": "beta_extraction_plate", "label": "beta_extraction_plate"}, + {"value": "beta_extraction_well", "label": "beta_extraction_well"}, + {"value": "beta_extraction_output", "label": "beta_extraction_output"}, + {"value": "beta_extraction_run_output", "label": "beta_extraction_run_output"}, + {"value": "beta_post_extract_qc", "label": "beta_post_extract_qc"}, + {"value": "beta_library_prep_output", "label": "beta_library_prep_output"}, + {"value": "beta_library_material_output", "label": "beta_library_material_output"}, + {"value": "beta_library_qc", "label": "beta_library_qc"}, + {"value": "beta_pooling_run_output", "label": "beta_pooling_run_output"}, + {"value": "beta_pool_member", "label": "beta_pool_member"}, + {"value": "beta_pooling_run_input", "label": "beta_pooling_run_input"}, + {"value": "beta_sequencing_run", "label": "beta_sequencing_run"}, + { + "value": "beta_sequenced_library_assignment", + "label": "beta_sequenced_library_assignment", + }, + {"value": "beta_assignment_source", "label": "beta_assignment_source"}, + { + "value": "beta_assignment_library_material", + "label": "beta_assignment_library_material", + }, + { + "value": "beta_assignment_barcode_reagent", + "label": "beta_assignment_barcode_reagent", + }, + {"value": "beta_run_artifact", "label": "beta_run_artifact"}, +] + def _normalize_action_slug(action_data: dict) -> str: method_name = str(action_data.get("method_name") or "").strip() @@ -46,8 +83,9 @@ def _default_ui_fields_for_action(action_data: dict) -> list[dict]: { "name": "relationship_type", "label": "Relationship Type", - "type": "text", + "type": "select", "required": True, + "options": RELATIONSHIP_TYPE_OPTIONS, }, { "name": "euids", @@ -74,9 +112,15 @@ def hydrate_dynamic_action_groups(action_groups: dict, bobdb: BloomObj) -> dict: actions = group_data.get("actions", {}) if not isinstance(actions, dict): continue - for action_data in actions.values(): + for action_key, action_data in list(actions.items()): if not isinstance(action_data, dict): continue + if ( + action_data.get("action_visible") == "0" + or _normalize_action_slug(action_data) == "create_subject_and_anchor" + ): + actions.pop(action_key, None) + continue captured = action_data.get("captured_data") if not isinstance(captured, dict): captured = {} diff --git a/bloom_lims/gui/deps.py b/bloom_lims/gui/deps.py index bdf92ab..f770a49 100644 --- a/bloom_lims/gui/deps.py +++ b/bloom_lims/gui/deps.py @@ -59,9 +59,9 @@ def _load_shared_display_timezone(email: str) -> str: FROM generic_instance gi WHERE gi.is_deleted = FALSE AND gi.polymorphic_discriminator = 'actor_instance' - AND gi.category = 'generic' - AND gi.type = 'actor' - AND gi.subtype = 'system_user' + AND gi.category = 'actor' + AND gi.type = 'user' + AND gi.subtype = 'system' AND ( lower(COALESCE(gi.json_addl->>'login_identifier', '')) = :identifier OR lower(COALESCE(gi.json_addl->>'email', '')) = :identifier @@ -104,9 +104,9 @@ def persist_display_timezone(email: str, display_timezone: str | None) -> bool: modified_dt = NOW() WHERE gi.is_deleted = FALSE AND gi.polymorphic_discriminator = 'actor_instance' - AND gi.category = 'generic' - AND gi.type = 'actor' - AND gi.subtype = 'system_user' + AND gi.category = 'actor' + AND gi.type = 'user' + AND gi.subtype = 'system' AND ( lower(COALESCE(gi.json_addl->>'login_identifier', '')) = :identifier OR lower(COALESCE(gi.json_addl->>'email', '')) = :identifier diff --git a/bloom_lims/gui/routes/auth.py b/bloom_lims/gui/routes/auth.py index a93a3ea..2c9165a 100644 --- a/bloom_lims/gui/routes/auth.py +++ b/bloom_lims/gui/routes/auth.py @@ -788,5 +788,5 @@ async def auth_logout_post(request: Request, response: Response): @router.get("/logout") -async def logout(request: Request, response: Response): - return await _logout_response(request, response) +async def logout(): + return RedirectResponse(url="/auth/logout", status_code=status.HTTP_303_SEE_OTHER) diff --git a/bloom_lims/gui/routes/modern.py b/bloom_lims/gui/routes/modern.py index b8fb067..27a57c3 100644 --- a/bloom_lims/gui/routes/modern.py +++ b/bloom_lims/gui/routes/modern.py @@ -162,6 +162,32 @@ async def create_object_wizard(request: Request, _auth=Depends(require_auth)): return HTMLResponse(content=template.render(context), status_code=200) +@router.get("/lab-actions", response_class=HTMLResponse) +async def lab_actions_wizard(request: Request, _auth=Depends(require_auth)): + user_data = request.session.get("user_data", {}) + template = templates.get_template("modern/lab_actions.html") + context = { + "request": request, + "udat": user_data, + "user": user_data, + "page_title": "Lab Actions", + } + return HTMLResponse(content=template.render(context), status_code=200) + + +@router.get("/container-actions", response_class=HTMLResponse) +async def container_actions_wizard(request: Request, _auth=Depends(require_auth)): + user_data = request.session.get("user_data", {}) + template = templates.get_template("modern/container_actions.html") + context = { + "request": request, + "udat": user_data, + "user": user_data, + "page_title": "Container Actions", + } + return HTMLResponse(content=template.render(context), status_code=200) + + @router.get("/help", response_class=HTMLResponse) async def help_page(request: Request): user_data = request.session.get("user_data", {}) diff --git a/bloom_lims/gui/routes/operations.py b/bloom_lims/gui/routes/operations.py index 518e540..550124b 100644 --- a/bloom_lims/gui/routes/operations.py +++ b/bloom_lims/gui/routes/operations.py @@ -547,13 +547,30 @@ async def update_preference(request: Request, auth: dict = Depends(require_auth) @router.get("/queue_details", response_class=HTMLResponse) async def queue_details( - request: Request, queue_euid, page=1, _auth=Depends(require_auth) + request: Request, + queue_euid: str | None = Query(default=None), + euid: str | None = Query(default=None), + page=1, + _auth=Depends(require_auth), ): + queue_euid = str(queue_euid or euid or "").strip() + user_data = request.session.get("user_data", {}) + if not queue_euid: + template = templates.get_template("modern/queue_retired.html") + return HTMLResponse( + content=template.render( + { + "request": request, + "udat": user_data, + "queue_euid": "", + } + ) + ) + page = int(page) if page < 1: page = 1 per_page = 500 - user_logged_in = True if "user_data" in request.session else False bobdb = BloomObj(BLOOMdb3(app_username=request.session["user_data"]["email"])) queue = bobdb.get_by_euid(queue_euid) qm = [] @@ -563,8 +580,6 @@ async def queue_details( queue_details = queue.sort_by_euid(qm) queue_details_list = queue_details[(page - 1) * per_page : page * per_page] pagination = {"next": page + 1, "prev": page - 1, "euid": queue_euid} - user_data = request.session.get("user_data", {}) - queue.items = queue_details_list template = templates.get_template("modern/queue_details.html") diff --git a/bloom_lims/integrations/tapdb_mount.py b/bloom_lims/integrations/tapdb_mount.py index f5a3e02..4ed417e 100644 --- a/bloom_lims/integrations/tapdb_mount.py +++ b/bloom_lims/integrations/tapdb_mount.py @@ -2,25 +2,19 @@ from __future__ import annotations -import importlib import logging import os from dataclasses import dataclass from pathlib import Path -from typing import Any, Awaitable, Callable +from typing import Any +from urllib.parse import quote +from daylily_tapdb.web import TapdbHostBridge, TapdbHostNavLink, create_tapdb_gui_app from fastapi import FastAPI -from starlette.datastructures import Headers -from starlette.responses import JSONResponse, RedirectResponse from bloom_lims.config import apply_runtime_environment logger = logging.getLogger(__name__) -_BLOOM_TAPDB_SCOPE_USER_KEY = "bloom_tapdb_user" - -ASGIReceive = Callable[[], Awaitable[dict[str, Any]]] -ASGISend = Callable[[dict[str, Any]], Awaitable[None]] -ASGIApp = Callable[[dict[str, Any], ASGIReceive, ASGISend], Awaitable[None]] @dataclass(frozen=True) @@ -41,7 +35,7 @@ def _is_truthy(raw: str | None, *, default: bool) -> bool: def _normalize_mount_path(raw_path: str | None) -> str: mount_path = str(raw_path or "").strip() if not mount_path: - mount_path = "/admin/tapdb" + mount_path = "/tapdb" if not mount_path.startswith("/"): mount_path = f"/{mount_path}" if len(mount_path) > 1: @@ -57,12 +51,6 @@ def load_tapdb_mount_config() -> TapDBMountConfig: return TapDBMountConfig(enabled=enabled, mount_path=mount_path) -def _prefers_json(scope: dict[str, Any]) -> bool: - headers = Headers(scope=scope) - accept = str(headers.get("accept") or "").lower() - return "application/json" in accept - - def _resolve_bloom_user_data(scope: dict[str, Any]) -> dict[str, Any] | None: session = scope.get("session") if not isinstance(session, dict): @@ -86,7 +74,7 @@ def _is_admin_user(user_data: dict[str, Any]) -> bool: return any(str(item).strip().upper() == "ADMIN" for item in roles) -def _tapdb_admin_user_from_bloom_user_data(user_data: dict[str, Any]) -> dict[str, Any]: +def _tapdb_user_from_bloom_user_data(user_data: dict[str, Any]) -> dict[str, Any]: email = str(user_data.get("email") or "").strip().lower() display_name = str( user_data.get("display_name") or user_data.get("name") or email @@ -102,69 +90,43 @@ def _tapdb_admin_user_from_bloom_user_data(user_data: dict[str, Any]) -> dict[st } -def _configure_embedded_tapdb_auth( - admin_main_module: Any, admin_auth_module: Any -) -> None: - if getattr(admin_main_module, "_bloom_embedded_auth_configured", False): - return +def _request_next_path(request: Any) -> str: + next_path = str(request.url.path or "/") + if request.url.query: + next_path = f"{next_path}?{request.url.query}" + return next_path - async def _get_current_user(request: Any) -> dict[str, Any] | None: - user = request.scope.get(_BLOOM_TAPDB_SCOPE_USER_KEY) - if isinstance(user, dict) and str(user.get("email") or "").strip(): - return user - return None - admin_auth_module.get_current_user = _get_current_user - admin_main_module.get_current_user = _get_current_user - setattr(admin_main_module, "_bloom_embedded_auth_configured", True) - - -class BloomAdminGuardedASGI: - """ASGI guard that enforces Bloom admin checks before forwarding.""" - - def __init__(self, inner_app: ASGIApp): - self._inner_app = inner_app - - async def __call__( - self, - scope: dict[str, Any], - receive: ASGIReceive, - send: ASGISend, - ) -> None: - if scope.get("type") != "http": - await self._inner_app(scope, receive, send) - return - - user_data = _resolve_bloom_user_data(scope) - if user_data is None: - if _prefers_json(scope): - response = JSONResponse( - status_code=401, - content={"detail": "Authentication required"}, - ) - else: - response = RedirectResponse(url="/login", status_code=303) - await response(scope, receive, send) - return - - if not _is_admin_user(user_data): - if _prefers_json(scope): - response = JSONResponse( - status_code=403, - content={"detail": "Admin privileges required"}, - ) - else: - response = RedirectResponse( - url="/user_home?admin_required=1", status_code=303 - ) - await response(scope, receive, send) - return - - forward_scope = dict(scope) - forward_scope[_BLOOM_TAPDB_SCOPE_USER_KEY] = ( - _tapdb_admin_user_from_bloom_user_data(user_data) - ) - await self._inner_app(forward_scope, receive, send) +def _tapdb_host_user(request: Any) -> dict[str, Any] | None: + user_data = _resolve_bloom_user_data(request.scope) + if user_data is None: + return None + return _tapdb_user_from_bloom_user_data(user_data) + + +def _build_tapdb_host_bridge() -> TapdbHostBridge: + return TapdbHostBridge( + auth_mode="host_session", + service_name="bloom", + app_name="Bloom", + shell_title="Bloom", + shell_subtitle="TapDB substrate", + home_url="/", + login_url=lambda request: ( + f"/login?next={quote(_request_next_path(request), safe='/')}" + ), + logout_url="/logout", + change_password_url=None, + resolve_user=_tapdb_host_user, + nav_links=( + TapdbHostNavLink(label="Dashboard", href="/"), + TapdbHostNavLink(label="Queue", href="/queue"), + TapdbHostNavLink(label="Graph", href="/graph"), + TapdbHostNavLink(label="Admin", href="/admin"), + ), + extra_stylesheets=("/static/modern/css/bloom_modern.css",), + extra_context=lambda _request: {"bloom_embedded": True}, + ) def _load_tapdb_admin_app( @@ -172,35 +134,18 @@ def _load_tapdb_admin_app( config_path: str, client_id: str, database_name: str, -) -> ASGIApp: +): if not str(config_path or "").strip(): raise RuntimeError("TapDB admin mount requires an explicit tapdb_config_path.") - resolved_config_path = Path(config_path).expanduser().resolve() - - from daylily_tapdb.cli.context import set_cli_context - - set_cli_context( - config_path=resolved_config_path, - client_id=client_id, - database_name=database_name, + candidate_config_path = Path(config_path).expanduser() + if not candidate_config_path.is_absolute(): + raise RuntimeError("TapDB admin mount requires an absolute tapdb_config_path.") + resolved_config_path = candidate_config_path.resolve() + _ = client_id, database_name + return create_tapdb_gui_app( + config_path=str(resolved_config_path), + host_bridge=_build_tapdb_host_bridge(), ) - cognito_module = importlib.import_module("admin.cognito") - db_pool_module = importlib.import_module("admin.db_pool") - module = importlib.import_module("admin.main") - auth_module = importlib.import_module("admin.auth") - - try: - _configure_embedded_tapdb_auth(module, auth_module) - _ = cognito_module, db_pool_module - except Exception as exc: # pragma: no cover - exercised by startup failure paths - raise RuntimeError("Failed importing TapDB admin FastAPI app") from exc - - tapdb_admin_app = getattr(module, "app", None) - if tapdb_admin_app is None: - raise RuntimeError("TapDB admin module does not expose FastAPI app") - if not callable(tapdb_admin_app): - raise RuntimeError("TapDB admin app is not callable as ASGI application") - return tapdb_admin_app def mount_tapdb_admin_subapp(app: FastAPI) -> TapDBMountConfig | None: @@ -221,8 +166,6 @@ def mount_tapdb_admin_subapp(app: FastAPI) -> TapDBMountConfig | None: "Bloom startup aborted: TapDB mount is enabled but TapDB admin app failed to load" ) from exc - app.mount( - config.mount_path, BloomAdminGuardedASGI(tapdb_admin_app), name="tapdb_admin" - ) - logger.info("Mounted TapDB admin app at %s", config.mount_path) + app.mount(config.mount_path, tapdb_admin_app, name="tapdb_gui") + logger.info("Mounted TapDB GUI app at %s", config.mount_path) return config diff --git a/bloom_lims/schemas/beta_lab.py b/bloom_lims/schemas/beta_lab.py index ea589d6..1cbe634 100644 --- a/bloom_lims/schemas/beta_lab.py +++ b/bloom_lims/schemas/beta_lab.py @@ -26,6 +26,12 @@ class AtlasFulfillmentItemReference(BaseModel): atlas_test_euid: str atlas_test_fulfillment_item_euid: str + atlas_order_test_euid: str | None = None + + +class AtlasFulfillmentSlotReference(BaseModel): + atlas_order_test_euid: str + atlas_fulfillment_slot_euid: str class AtlasCollectionEventSnapshot(BaseModel): @@ -42,6 +48,9 @@ class AtlasCollectionEventSnapshot(BaseModel): class AtlasFulfillmentContext(BaseModel): atlas_tenant_id: str + atlas_order_euid: str | None = None + atlas_order_test_euid: str | None = None + atlas_order_test_euids: list[str] = Field(default_factory=list) atlas_trf_euid: str | None = None atlas_test_euid: str | None = None atlas_test_euids: list[str] = Field(default_factory=list) @@ -52,6 +61,7 @@ class AtlasFulfillmentContext(BaseModel): atlas_collection_event_euid: str | None = None collection_event_snapshot: AtlasCollectionEventSnapshot | None = None fulfillment_items: list[AtlasFulfillmentItemReference] = Field(default_factory=list) + fulfillment_slots: list[AtlasFulfillmentSlotReference] = Field(default_factory=list) @model_validator(mode="after") def validate_context(self) -> "AtlasFulfillmentContext": @@ -59,6 +69,44 @@ def validate_context(self) -> "AtlasFulfillmentContext": raise ValueError("atlas_tenant_id is required") if self.atlas_trf_euid is not None and not self.atlas_trf_euid.strip(): raise ValueError("atlas_trf_euid must not be empty when provided") + if self.atlas_order_euid is not None and not self.atlas_order_euid.strip(): + raise ValueError("atlas_order_euid must not be empty when provided") + if ( + self.atlas_order_test_euid is not None + and not self.atlas_order_test_euid.strip() + ): + raise ValueError("atlas_order_test_euid must not be empty when provided") + normalized_order_test_euids: list[str] = [] + seen_order_test_euids: set[str] = set() + primary_order_test_euid = str(self.atlas_order_test_euid or "").strip() + if primary_order_test_euid: + seen_order_test_euids.add(primary_order_test_euid) + normalized_order_test_euids.append(primary_order_test_euid) + for item in self.atlas_order_test_euids: + clean_item = str(item or "").strip() + if not clean_item or clean_item in seen_order_test_euids: + continue + seen_order_test_euids.add(clean_item) + normalized_order_test_euids.append(clean_item) + for item in self.fulfillment_slots: + clean_item = str(item.atlas_order_test_euid or "").strip() + if clean_item and clean_item not in seen_order_test_euids: + seen_order_test_euids.add(clean_item) + normalized_order_test_euids.append(clean_item) + for item in self.fulfillment_items: + clean_item = str(item.atlas_order_test_euid or "").strip() + if clean_item and clean_item not in seen_order_test_euids: + seen_order_test_euids.add(clean_item) + normalized_order_test_euids.append(clean_item) + self.atlas_order_test_euids = normalized_order_test_euids + if self.atlas_order_test_euid is None and self.atlas_order_test_euids: + self.atlas_order_test_euid = self.atlas_order_test_euids[0] + if ( + self.atlas_order_test_euid or self.atlas_order_test_euids + ) and not self.atlas_order_euid: + raise ValueError( + "atlas_order_euid is required when atlas_order_test_euid is provided" + ) if self.atlas_test_euid is not None and not self.atlas_test_euid.strip(): raise ValueError("atlas_test_euid must not be empty when provided") normalized_test_euids: list[str] = [] @@ -128,7 +176,7 @@ def validate_context(self) -> "AtlasFulfillmentContext": class BetaAcceptedMaterialCreateRequest(BaseModel): - specimen_template_code: str = Field(default="content/specimen/blood-whole/1.0") + specimen_template_code: str = Field(default="material/specimen/blood-whole/1.0") specimen_name: str | None = None container_euid: str | None = None container_template_code: str = Field(default="container/tube/tube-generic-10ml/1.0") @@ -348,9 +396,9 @@ class BetaRunArtifactInput(BaseModel): class BetaRunCreateRequest(BaseModel): pool_euid: str platform: Literal["ILMN", "ONT", "Ultima", "PacBio", "CompleteGenomics"] - run_subtype: Literal[ - "illumina", "ont", "ultima", "pacbio", "completegenomics" - ] = Field(default="illumina") + run_subtype: Literal["illumina", "ont", "ultima", "pacbio", "completegenomics"] = ( + Field(default="illumina") + ) flowcell_id: str run_name: str | None = None status: Literal["started", "completed"] = Field(default="completed") diff --git a/bloom_lims/schemas/external_specimens.py b/bloom_lims/schemas/external_specimens.py index 615fe0b..041c7be 100644 --- a/bloom_lims/schemas/external_specimens.py +++ b/bloom_lims/schemas/external_specimens.py @@ -11,6 +11,7 @@ class AtlasReferences(BaseModel): trf_euid: str | None = None patient_id: str | None = None order_euid: str | None = None + order_test_euid: str | None = None shipment_euid: str | None = None kit_barcode: str | None = None atlas_tenant_id: str | None = None @@ -19,7 +20,7 @@ class AtlasReferences(BaseModel): class ExternalSpecimenCreateRequest(BaseModel): - specimen_template_code: str = Field(default="content/specimen/generic/1.0") + specimen_template_code: str = Field(default="material/specimen/generic/1.0") specimen_name: str | None = None container_euid: str | None = None container_template_code: str = Field(default="container/tube/generic/1.0") @@ -33,6 +34,7 @@ def validate_references(self) -> "ExternalSpecimenCreateRequest": if not any( [ refs.order_euid, + refs.order_test_euid, refs.patient_id, refs.shipment_euid, refs.kit_barcode, diff --git a/bloom_lims/schemas/lab_actions.py b/bloom_lims/schemas/lab_actions.py new file mode 100644 index 0000000..bb0b283 --- /dev/null +++ b/bloom_lims/schemas/lab_actions.py @@ -0,0 +1,274 @@ +"""Schemas for Bloom lab action APIs.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + +PlatformName = Literal["ILMN", "ONT", "Ultima", "PacBio", "CompleteGenomics"] +RunSetStatus = Literal["created", "running", "abandoned", "complete", "error"] + + +class WellPosition(BaseModel): + row: str + col: int + + @field_validator("row", mode="before") + @classmethod + def normalize_row(cls, value): + row = str(value or "").strip().upper() + if row not in tuple("ABCDEFGH"): + raise ValueError("row must be A-H") + return row + + @field_validator("col", mode="before") + @classmethod + def normalize_col(cls, value): + col = int(value) + if col < 1 or col > 12: + raise ValueError("col must be 1-12") + return col + + @property + def name(self) -> str: + return f"{self.row}{self.col}" + + +class ExtractionTubeAssignment(BaseModel): + tube_euid: str + row: str | None = None + col: int | None = None + quant: dict[str, Any] | None = None + + @model_validator(mode="after") + def validate_position_pair(self) -> "ExtractionTubeAssignment": + if (self.row is None) ^ (self.col is None): + raise ValueError("row and col must be provided together") + if self.row is not None and self.col is not None: + WellPosition(row=self.row, col=self.col) + return self + + +class RunSetInput(BaseModel): + name: str | None = None + description: str | None = None + members: list[str] = Field(default_factory=list) + external_members: list[str] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + status: RunSetStatus = "created" + + +class ExtractionPlateRequest(BaseModel): + mode: Literal["auto", "directed"] = "auto" + tube_euids: list[str] = Field(default_factory=list) + assignments: list[ExtractionTubeAssignment] = Field(default_factory=list) + plate_euid: str | None = None + plate_name: str | None = None + create_run_set: bool = False + run_set: RunSetInput | None = None + print_new_container_barcodes: bool = False + + @model_validator(mode="after") + def validate_input(self) -> "ExtractionPlateRequest": + if self.mode == "auto": + if self.assignments: + raise ValueError("auto mode accepts tube_euids, not assignments") + if not self.tube_euids: + raise ValueError("tube_euids is required for auto mode") + if self.mode == "directed": + if self.tube_euids: + raise ValueError("directed mode accepts assignments, not tube_euids") + if not self.assignments: + raise ValueError("assignments is required for directed mode") + return self + + +class LibraryPlateAssignment(BaseModel): + source_well_euid: str + row: str | None = None + col: int | None = None + index_barcode: str | None = None + index_euid: str | None = None + data: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_position_pair(self) -> "LibraryPlateAssignment": + if (self.row is None) ^ (self.col is None): + raise ValueError("row and col must be provided together") + if self.row is not None and self.col is not None: + WellPosition(row=self.row, col=self.col) + return self + + +class SeqLibraryPlateRequest(BaseModel): + mode: Literal["plate_1_to_1", "directed"] = "plate_1_to_1" + source_plate_euid: str | None = None + assignments: list[LibraryPlateAssignment] = Field(default_factory=list) + plate_name: str | None = None + create_run_set: bool = False + run_set: RunSetInput | None = None + print_new_container_barcodes: bool = False + + @model_validator(mode="after") + def validate_input(self) -> "SeqLibraryPlateRequest": + if self.mode == "plate_1_to_1" and not self.source_plate_euid: + raise ValueError("source_plate_euid is required for plate_1_to_1 mode") + if self.mode == "directed" and not self.assignments: + raise ValueError("assignments is required for directed mode") + return self + + +class ExtractionQcAssignment(BaseModel): + source_well_euid: str | None = None + row: str | None = None + col: int | None = None + qc_row: str | None = None + qc_col: int | None = None + result: str | None = None + status: str | None = None + data: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_source_position(self) -> "ExtractionQcAssignment": + if self.source_well_euid and (self.row is not None or self.col is not None): + raise ValueError("provide source_well_euid or row/col, not both") + if not self.source_well_euid and (self.row is None or self.col is None): + raise ValueError("source_well_euid or row/col is required") + if (self.row is None) ^ (self.col is None): + raise ValueError("row and col must be provided together") + if self.row is not None and self.col is not None: + WellPosition(row=self.row, col=self.col) + if (self.qc_row is None) ^ (self.qc_col is None): + raise ValueError("qc_row and qc_col must be provided together") + if self.qc_row is not None and self.qc_col is not None: + WellPosition(row=self.qc_row, col=self.qc_col) + return self + + +class ExtractionQcPlateRequest(BaseModel): + source_plate_euid: str | None = None + qc_plate_euid: str | None = None + qc_plate_name: str | None = None + assignments: list[ExtractionQcAssignment] = Field(default_factory=list) + create_run_set: bool = False + run_set: RunSetInput | None = None + + @model_validator(mode="after") + def validate_input(self) -> "ExtractionQcPlateRequest": + if not self.source_plate_euid and not self.assignments: + raise ValueError("source_plate_euid or assignments is required") + return self + + +class PlateWellDataRecord(BaseModel): + well_euid: str | None = None + plate_euid: str | None = None + row: str | None = None + col: int | None = None + name: str | None = None + target: Literal["well", "content"] = "content" + data: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_target(self) -> "PlateWellDataRecord": + if self.well_euid and ( + self.plate_euid or self.row is not None or self.col is not None + ): + raise ValueError("provide well_euid or plate_euid with row/col, not both") + if not self.well_euid: + if not self.plate_euid or self.row is None or self.col is None: + raise ValueError("well_euid or plate_euid with row/col is required") + WellPosition(row=self.row, col=self.col) + return self + + +class PlateWellDataRequest(BaseModel): + data_template_code: str = "data/operation/extraction-qc/1.0/" + relationship_type: str = "well_associated_data" + records: list[PlateWellDataRecord] + create_run_set: bool = False + run_set: RunSetInput | None = None + + @field_validator("records") + @classmethod + def validate_records(cls, value): + if not value: + raise ValueError("records must not be empty") + return value + + +class SeqLibraryPoolRequest(BaseModel): + input_euids: list[str] + platform: PlatformName = "ILMN" + pool_tube_euid: str | None = None + pool_name: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + create_run_set: bool = False + run_set: RunSetInput | None = None + print_new_container_barcodes: bool = False + + @field_validator("input_euids") + @classmethod + def validate_inputs(cls, value): + if not value: + raise ValueError("input_euids must not be empty") + return value + + +class SeqRunSetRequest(BaseModel): + pool_tube_euid: str + pool_content_euid: str | None = None + platform: PlatformName = "ILMN" + operator: str | None = None + instrument_euid: str | None = None + machine: str | None = None + flowcell_barcode: str + reagent_euids: list[str] = Field(default_factory=list) + status: RunSetStatus = "created" + name: str | None = None + description: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class LabSetRequest(BaseModel): + name: str + description: str | None = None + members: list[str] = Field(default_factory=list) + external_members: list[str] = Field(default_factory=list) + operator: str | None = None + instrument: str | None = None + reagents: list[str] = Field(default_factory=list) + machine: str | None = None + flowcell_barcode: str | None = None + status: RunSetStatus = "created" + metadata: dict[str, Any] = Field(default_factory=dict) + + +class LabSetMembersRequest(BaseModel): + members: list[str] = Field(default_factory=list) + external_members: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_members(self) -> "LabSetMembersRequest": + if not self.members and not self.external_members: + raise ValueError("members or external_members is required") + return self + + +class PrintEuidRequest(BaseModel): + euids: list[str] + lab: str + printer_id: str + label_zpl_style: str + copies: int = Field(default=1, ge=1, le=20) + dry_run: bool = False + + @field_validator("euids") + @classmethod + def validate_euids(cls, value): + if not value: + raise ValueError("euids must not be empty") + if len(value) > 500: + raise ValueError("euids cannot exceed 500") + return value diff --git a/bloom_lims/schemas/objects.py b/bloom_lims/schemas/objects.py index c70581f..784fdb4 100644 --- a/bloom_lims/schemas/objects.py +++ b/bloom_lims/schemas/objects.py @@ -3,7 +3,8 @@ """ from datetime import datetime -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional + from pydantic import Field, field_validator, model_validator from .base import BloomBaseSchema, TimestampMixin, validate_euid @@ -12,19 +13,21 @@ class JsonAddlSchema(BloomBaseSchema): """ Schema for json_addl field validation. - + The json_addl field stores additional metadata as JSON. This schema validates common fields while allowing extension. """ - + # Common optional fields notes: Optional[str] = Field(None, max_length=10000, description="General notes") tags: Optional[List[str]] = Field(None, description="Tags for categorization") - custom_fields: Optional[Dict[str, Any]] = Field(None, description="Custom field values") - + custom_fields: Optional[Dict[str, Any]] = Field( + None, description="Custom field values" + ) + # Allow extra fields for flexibility model_config = {"extra": "allow"} - + @field_validator("tags", mode="before") @classmethod def normalize_tags(cls, v): @@ -40,10 +43,19 @@ class ObjectBaseSchema(BloomBaseSchema): """Base schema for BloomObj/generic_instance objects.""" name: str = Field(..., min_length=1, max_length=500, description="Object name") - type: str = Field(..., min_length=1, max_length=100, description="Object type (e.g., sample, plate)") + type: str = Field( + ..., + min_length=1, + max_length=100, + description="Object type (e.g., sample, plate)", + ) subtype: Optional[str] = Field(None, max_length=100, description="Object subtype") - category: str = Field(default="instance", max_length=50, description="Object category") - json_addl: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Additional JSON data") + category: str = Field( + default="instance", max_length=50, description="Object category" + ) + json_addl: Optional[Dict[str, Any]] = Field( + default_factory=dict, description="Additional JSON data" + ) @field_validator("type", mode="before") @classmethod @@ -52,7 +64,7 @@ def normalize_type(cls, v): if v: return str(v).strip().lower() return v - + @field_validator("json_addl", mode="before") @classmethod def ensure_json_addl_dict(cls, v): @@ -61,6 +73,7 @@ def ensure_json_addl_dict(cls, v): return {} if isinstance(v, str): import json + try: return json.loads(v) except json.JSONDecodeError: @@ -70,12 +83,24 @@ def ensure_json_addl_dict(cls, v): class ObjectCreateSchema(ObjectBaseSchema): """Schema for creating a new BloomObj.""" - - parent_euid: Optional[str] = Field(None, description="Parent object EUID for lineage") + + parent_euid: Optional[str] = Field( + None, description="Parent object EUID for lineage" + ) lineage_euid: Optional[str] = Field(None, description="Lineage EUID to attach to") - container_euid: Optional[str] = Field(None, description="Container EUID for placement") - container_position: Optional[str] = Field(None, description="Position in container (e.g., A1)") - + container_euid: Optional[str] = Field( + None, description="Container EUID for placement" + ) + container_position: Optional[str] = Field( + None, description="Position in container (e.g., A1)" + ) + count: int = Field( + default=1, + ge=1, + le=100, + description="Number of sibling objects to create from the same template", + ) + @field_validator("parent_euid", "lineage_euid", "container_euid", mode="before") @classmethod def validate_euids(cls, v): @@ -88,29 +113,45 @@ def validate_euids(cls, v): class ObjectUpdateSchema(BloomBaseSchema): """Schema for updating an existing BloomObj.""" - name: Optional[str] = Field(None, min_length=1, max_length=500, description="Object name") - json_addl: Optional[Dict[str, Any]] = Field(None, description="Additional JSON data (merged)") + name: Optional[str] = Field( + None, min_length=1, max_length=500, description="Object name" + ) + json_addl: Optional[Dict[str, Any]] = Field( + None, description="Additional JSON data (merged)" + ) status: Optional[str] = Field(None, max_length=50, description="Object status") is_deleted: Optional[bool] = Field(None, description="Soft delete flag") - created_dt: Optional[datetime] = Field(None, description="Creation datetime (admin only)") + created_dt: Optional[datetime] = Field( + None, description="Creation datetime (admin only)" + ) @model_validator(mode="after") def check_at_least_one_field(self): """Ensure at least one field is provided for update.""" - if not any([self.name, self.json_addl, self.status, self.is_deleted is not None, self.created_dt]): + if not any( + [ + self.name, + self.json_addl, + self.status, + self.is_deleted is not None, + self.created_dt, + ] + ): raise ValueError("At least one field must be provided for update") return self class ObjectResponseSchema(ObjectBaseSchema, TimestampMixin): """Schema for BloomObj API responses.""" - + euid: str = Field(..., description="Entity Unique Identifier") status: str = Field(default="active", description="Object status") is_deleted: bool = Field(default=False, description="Soft delete flag") is_singleton: bool = Field(default=False, description="Singleton instance flag") - polymorphic_discriminator: Optional[str] = Field(None, description="Type discriminator") - + polymorphic_discriminator: Optional[str] = Field( + None, description="Type discriminator" + ) + # Relationship info (optional) parent_euid: Optional[str] = Field(None, description="Parent object EUID") lineage_euid: Optional[str] = Field(None, description="Lineage EUID") @@ -129,9 +170,15 @@ class ObjectQueryParams(BloomBaseSchema): parent_euid: Optional[str] = Field(None, description="Filter by parent") lineage_euid: Optional[str] = Field(None, description="Filter by lineage") container_euid: Optional[str] = Field(None, description="Filter by container") - created_after: Optional[datetime] = Field(None, description="Filter by creation date") - created_before: Optional[datetime] = Field(None, description="Filter by creation date") - include_deleted: bool = Field(default=False, description="Include soft-deleted objects") + created_after: Optional[datetime] = Field( + None, description="Filter by creation date" + ) + created_before: Optional[datetime] = Field( + None, description="Filter by creation date" + ) + include_deleted: bool = Field( + default=False, description="Include soft-deleted objects" + ) @field_validator("type", "subtype", mode="before") @classmethod diff --git a/bloom_lims/tapdb_metrics.py b/bloom_lims/tapdb_metrics.py index 37ab779..a4f4fca 100644 --- a/bloom_lims/tapdb_metrics.py +++ b/bloom_lims/tapdb_metrics.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Iterable, Optional +import yaml from daylily_tapdb.cli.db_config import get_admin_settings from sqlalchemy import event from sqlalchemy.engine import Engine @@ -47,7 +48,7 @@ ) -def _parse_bool(value: object, *, default: bool) -> bool: +def _parse_bool(value: object, *, default: bool | None) -> bool | None: if value is None: return default raw = str(value).strip().lower() @@ -80,13 +81,47 @@ def _resolved_bloom_runtime( return resolved_env, config_path, admin_settings +def _explicit_config_metrics_enabled(config_path: Path) -> bool | None: + try: + loaded = yaml.safe_load(config_path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise RuntimeError( + f"Failed to parse Bloom TapDB metrics config: {exc}" + ) from exc + if not isinstance(loaded, dict): + return None + target = loaded.get("target") + if not isinstance(target, dict) or "metrics_enabled" not in target: + return None + raw = target.get("metrics_enabled") + parsed = _parse_bool(raw, default=None) + if parsed is None: + raise RuntimeError( + "Bloom TapDB metrics config value target.metrics_enabled must be " + f"a boolean or boolean string, got {raw!r}" + ) + return parsed + + def metrics_enabled(env_name: str | None = None) -> bool: - enabled = _resolved_bloom_runtime(env_name)[2].get("metrics_enabled") + _resolved_env, config_path, admin_settings = _resolved_bloom_runtime(env_name) + explicit = _explicit_config_metrics_enabled(config_path) + if explicit is not None: + return explicit + enabled = admin_settings.get("metrics_enabled") if isinstance(enabled, bool): return enabled - if "pytest" in sys.modules: - return False - return True + if enabled is None: + if "pytest" in sys.modules: + return False + return True + parsed = _parse_bool(enabled, default=None) + if parsed is None: + raise RuntimeError( + "Bloom TapDB metrics config value target.metrics_enabled must be " + f"a boolean or boolean string, got {enabled!r}" + ) + return parsed def _sanitize_tsv(value: object) -> str: @@ -251,11 +286,11 @@ def _run(self) -> None: def _get_writer(env_name: str) -> Optional[TSVMetricsWriter]: - if not metrics_enabled(): - return None env = str(env_name or "").strip().lower() if not env: raise RuntimeError("Bloom TapDB metrics writer requires explicit env_name") + if not metrics_enabled(env): + return None with _writer_lock: writer = _writers_by_env.get(env) if writer is None: diff --git a/config/tapdb_templates/bloom/templates.json b/config/tapdb_templates/bloom/templates.json index c6feaaa..77d6319 100644 --- a/config/tapdb_templates/bloom/templates.json +++ b/config/tapdb_templates/bloom/templates.json @@ -4,7 +4,7 @@ { "name": "Claim Material In Queue", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "claim_material_in_queue", "version": "1.0", @@ -27,7 +27,7 @@ { "name": "Consume Material", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "consume_material", "version": "1.0", @@ -50,7 +50,7 @@ { "name": "Create Empty Tube", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "create_empty_tube", "version": "1.0", @@ -73,7 +73,7 @@ { "name": "Create Extraction", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "create_extraction", "version": "1.0", @@ -96,7 +96,7 @@ { "name": "Create Library Prep", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "create_library_prep", "version": "1.0", @@ -119,7 +119,7 @@ { "name": "Create Pool", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "create_pool", "version": "1.0", @@ -142,7 +142,7 @@ { "name": "Record Library QC", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "record_library_qc", "version": "1.0", @@ -165,7 +165,7 @@ { "name": "Create Run", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "create_run", "version": "1.0", @@ -188,7 +188,7 @@ { "name": "Move Material To Queue", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "move_material_to_queue", "version": "1.0", @@ -211,7 +211,7 @@ { "name": "Record Post Extract QC", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "record_post_extract_qc", "version": "1.0", @@ -234,7 +234,7 @@ { "name": "Register Accepted Material", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "register_accepted_material", "version": "1.0", @@ -257,7 +257,7 @@ { "name": "Release Claim", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "release_claim", "version": "1.0", @@ -280,7 +280,7 @@ { "name": "Release Reservation", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "release_reservation", "version": "1.0", @@ -303,7 +303,7 @@ { "name": "Reserve Material", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "reserve_material", "version": "1.0", @@ -326,7 +326,7 @@ { "name": "Update Specimen", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "update_specimen", "version": "1.0", @@ -349,7 +349,7 @@ { "name": "Update Tube", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "beta_lab", "subtype": "update_tube", "version": "1.0", @@ -372,7 +372,7 @@ { "name": "action/core/add-relationships/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "core", "subtype": "add-relationships", "version": "1.0", @@ -408,7 +408,7 @@ { "name": "action/core/create-subject-and-anchor/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "core", "subtype": "create-subject-and-anchor", "version": "1.0", @@ -444,7 +444,7 @@ { "name": "action/core/print_barcode_label/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "core", "subtype": "print_barcode_label", "version": "1.0", @@ -488,7 +488,7 @@ { "name": "action/core/set_object_status/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "core", "subtype": "set_object_status", "version": "1.0", @@ -528,7 +528,7 @@ { "name": "action/data/log-temperature/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "data", "subtype": "log-temperature", "version": "1.0", @@ -571,7 +571,7 @@ { "name": "action/data/record_cfdna_quant_outcome/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "data", "subtype": "record_cfdna_quant_outcome", "version": "1.0", @@ -614,7 +614,7 @@ { "name": "action/equipment/add-container-to/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "equipment", "subtype": "add-container-to", "version": "1.0", @@ -661,7 +661,7 @@ { "name": "action/equipment/download-inventory-tsv/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "equipment", "subtype": "download-inventory-tsv", "version": "1.0", @@ -707,7 +707,7 @@ { "name": "action/equipment/log-temperature/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "equipment", "subtype": "log-temperature", "version": "1.0", @@ -760,7 +760,7 @@ { "name": "action/equipment/remove-container-from/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "equipment", "subtype": "remove-container-from", "version": "1.0", @@ -806,7 +806,7 @@ { "name": "Cancel Subject Execution", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "cancel_subject_execution", "version": "1.0", @@ -829,7 +829,7 @@ { "name": "Claim Queue Item", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "claim_queue_item", "version": "1.0", @@ -852,7 +852,7 @@ { "name": "Complete Queue Execution", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "complete_queue_execution", "version": "1.0", @@ -875,7 +875,7 @@ { "name": "Expire Queue Lease", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "expire_queue_lease", "version": "1.0", @@ -898,7 +898,7 @@ { "name": "Fail Queue Execution", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "fail_queue_execution", "version": "1.0", @@ -921,7 +921,7 @@ { "name": "Heartbeat Worker", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "heartbeat_worker", "version": "1.0", @@ -944,7 +944,7 @@ { "name": "Place Execution Hold", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "place_execution_hold", "version": "1.0", @@ -967,7 +967,7 @@ { "name": "Register Worker", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "register_worker", "version": "1.0", @@ -990,7 +990,7 @@ { "name": "Release Execution Hold", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "release_execution_hold", "version": "1.0", @@ -1013,7 +1013,7 @@ { "name": "Release Queue Lease", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "release_queue_lease", "version": "1.0", @@ -1036,7 +1036,7 @@ { "name": "Renew Queue Lease", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "renew_queue_lease", "version": "1.0", @@ -1059,7 +1059,7 @@ { "name": "Requeue Subject", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "execution_queue", "subtype": "requeue_subject", "version": "1.0", @@ -1082,7 +1082,7 @@ { "name": "action/file/download_file/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "file", "subtype": "download_file", "version": "1.0", @@ -1118,7 +1118,7 @@ { "name": "action/move-queues/move-among-ay-top-queues/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "move-queues", "subtype": "move-among-ay-top-queues", "version": "1.0", @@ -1162,7 +1162,7 @@ { "name": "action/object/set-child-object/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "object", "subtype": "set-child-object", "version": "1.0", @@ -1210,7 +1210,7 @@ { "name": "action/plate/download_plate_map/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "plate", "subtype": "download_plate_map", "version": "1.0", @@ -1255,7 +1255,7 @@ { "name": "action/plate/stamp_plates_into_plate/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "plate", "subtype": "stamp_plates_into_plate", "version": "1.0", @@ -1313,7 +1313,7 @@ { "name": "action/plate/stamp_plates_into_plate__extraction_plate_filled/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "plate", "subtype": "stamp_plates_into_plate__extraction_plate_filled", "version": "1.0", @@ -1371,7 +1371,7 @@ { "name": "action/pool/create-pool-from-containers/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "pool", "subtype": "create-pool-from-containers", "version": "1.0", @@ -1438,7 +1438,7 @@ { "name": "action/pool/create_pool_from_containers__extraction_plate_filled/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "pool", "subtype": "create_pool_from_containers__extraction_plate_filled", "version": "1.0", @@ -1505,7 +1505,7 @@ { "name": "action/workflow/destroy_specimen_containers/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow", "subtype": "destroy_specimen_containers", "version": "1.0", @@ -1559,7 +1559,7 @@ { "name": "action/workflow_step_analysis/download_quant_data/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_analysis", "subtype": "download_quant_data", "version": "1.0", @@ -1605,7 +1605,7 @@ { "name": "action/workflow_step_destroy/complete_specimen_destroy/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_destroy", "subtype": "complete_specimen_destroy", "version": "1.0", @@ -1649,7 +1649,7 @@ { "name": "action/workflow_step_extraction/cfdna_post_extraction_quant/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_extraction", "subtype": "cfdna_post_extraction_quant", "version": "1.0", @@ -1711,7 +1711,7 @@ { "name": "action/workflow_step_plate_operations/cfdna_quant/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_plate_operations", "subtype": "cfdna_quant", "version": "1.0", @@ -1773,7 +1773,7 @@ { "name": "action/workflow_step_plate_operations/stamp_copy_plate/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_plate_operations", "subtype": "stamp_copy_plate", "version": "1.0", @@ -1826,7 +1826,7 @@ { "name": "action/workflow_step_queue/add-gdna-to-gdna-normalization-queue/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "add-gdna-to-gdna-normalization-queue", "version": "1.0", @@ -1869,7 +1869,7 @@ { "name": "action/workflow_step_queue/add-to-blood-gdna-extraction-queue/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "add-to-blood-gdna-extraction-queue", "version": "1.0", @@ -1912,7 +1912,7 @@ { "name": "action/workflow_step_queue/add-to-buccal-gdna-extraction-queue/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "add-to-buccal-gdna-extraction-queue", "version": "1.0", @@ -1955,7 +1955,7 @@ { "name": "action/workflow_step_queue/create_test_req_and_link_child_workflow_step/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "create_test_req_and_link_child_workflow_step", "version": "1.0", @@ -2019,7 +2019,7 @@ { "name": "action/workflow_step_queue/existing-plate-fill-auto/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "existing-plate-fill-auto", "version": "1.0", @@ -2061,7 +2061,7 @@ { "name": "action/workflow_step_queue/existing-plate-fill-directed/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "existing-plate-fill-directed", "version": "1.0", @@ -2103,7 +2103,7 @@ { "name": "action/workflow_step_queue/fill_plate_directed/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "fill_plate_directed", "version": "1.0", @@ -2152,7 +2152,7 @@ { "name": "action/workflow_step_queue/fill_plate_undirected/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "fill_plate_undirected", "version": "1.0", @@ -2205,7 +2205,7 @@ { "name": "action/workflow_step_queue/link_tubes_auto/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "link_tubes_auto", "version": "1.0", @@ -2276,7 +2276,7 @@ { "name": "action/workflow_step_queue/plate-create-fill-auto/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "plate-create-fill-auto", "version": "1.0", @@ -2318,7 +2318,7 @@ { "name": "action/workflow_step_queue/plate-create-fill-directed/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "plate-create-fill-directed", "version": "1.0", @@ -2360,7 +2360,7 @@ { "name": "action/workflow_step_queue/save-quant-data/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "save-quant-data", "version": "1.0", @@ -2402,7 +2402,7 @@ { "name": "action/workflow_step_queue/ycreate_test_req_and_link_child_workflow_step/1.0", "polymorphic_discriminator": "action_template", - "category": "BAC", + "category": "action", "type": "workflow_step_queue", "subtype": "ycreate_test_req_and_link_child_workflow_step", "version": "1.0", @@ -2464,7 +2464,7 @@ { "name": "Bloom Authenticated User", "polymorphic_discriminator": "actor_template", - "category": "BAR", + "category": "actor", "type": "generic", "subtype": "bloom-user", "version": "1.0", @@ -2475,10 +2475,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Bloom Authenticated User", "expected_inputs": [], @@ -2497,7 +2497,7 @@ { "name": "Patient", "polymorphic_discriminator": "actor_template", - "category": "BAR", + "category": "actor", "type": "generic", "subtype": "patient", "version": "1.0", @@ -2508,10 +2508,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Patient", "expected_inputs": [], @@ -2534,7 +2534,7 @@ { "name": "Clinician", "polymorphic_discriminator": "actor_template", - "category": "BAR", + "category": "actor", "type": "generic", "subtype": "physician", "version": "1.0", @@ -2545,10 +2545,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -2581,7 +2581,7 @@ { "name": "Execution System Worker", "polymorphic_discriminator": "actor_template", - "category": "BAR", + "category": "actor", "type": "system", "subtype": "worker", "version": "1.0", @@ -2592,9 +2592,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Execution System Worker", "expected_inputs": [], @@ -2628,7 +2628,7 @@ { "name": "Bloom User", "polymorphic_discriminator": "generic_template", - "category": "BAR", + "category": "actor", "type": "user", "subtype": "bloom-user", "version": "1.0", @@ -2653,10 +2653,10 @@ "group_order": "1", "group_name": "Core Actions", "actions": { - "BAC/core/add-relationships/1.0": {}, - "BAC/core/create-subject-and-anchor/1.0": {}, - "BAC/core/print_barcode_label/1.0": {}, - "BAC/core/set_object_status/1.0": {} + "action/core/add-relationships/1.0": {}, + "action/core/create-subject-and-anchor/1.0": {}, + "action/core/print_barcode_label/1.0": {}, + "action/core/set_object_status/1.0": {} } } }, @@ -2667,9 +2667,9 @@ { "name": "Bloom User API Token Usage Log", "polymorphic_discriminator": "generic_template", - "category": "BBX", - "type": "auth", - "subtype": "user-api-token-usage-log", + "category": "auth", + "type": "user-api-token-usage-log", + "subtype": "generic", "version": "1.0", "instance_prefix": "BBX", "instance_polymorphic_identity": "generic_instance", @@ -2684,9 +2684,9 @@ { "name": "Bloom User API Token", "polymorphic_discriminator": "generic_template", - "category": "BBX", - "type": "auth", - "subtype": "user-api-token", + "category": "auth", + "type": "user-api-token", + "subtype": "generic", "version": "1.0", "instance_prefix": "BBX", "instance_polymorphic_identity": "generic_instance", @@ -2701,7 +2701,7 @@ { "name": "Generic External Object Link", "polymorphic_discriminator": "generic_template", - "category": "BGX", + "category": "generic", "type": "generic", "subtype": "external_object_link", "version": "1.0", @@ -2725,9 +2725,9 @@ { "name": "Bloom User Group Membership", "polymorphic_discriminator": "generic_template", - "category": "BBX", - "type": "auth", - "subtype": "user-group-membership", + "category": "auth", + "type": "user-group-membership", + "subtype": "generic", "version": "1.0", "instance_prefix": "BBX", "instance_polymorphic_identity": "generic_instance", @@ -2742,9 +2742,9 @@ { "name": "Bloom User Group", "polymorphic_discriminator": "generic_template", - "category": "BBX", - "type": "auth", - "subtype": "user-group", + "category": "auth", + "type": "user-group", + "subtype": "generic", "version": "1.0", "instance_prefix": "BBX", "instance_polymorphic_identity": "generic_instance", @@ -2759,7 +2759,7 @@ { "name": "24 fixed well plate", "polymorphic_discriminator": "container_template", - "category": "BCP", + "category": "container", "type": "plate", "subtype": "fixed-plate-24", "version": "1.0", @@ -2770,12 +2770,12 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "download_plate_map": "BAC/plate/download_plate_map/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0", - "stamp_plates_into_plate": "BAC/plate/stamp_plates_into_plate/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "download_plate_map": "action/plate/download_plate_map/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0", + "stamp_plates_into_plate": "action/plate/stamp_plates_into_plate/1.0" }, "cogs": { "allocation_type": "", @@ -3300,7 +3300,7 @@ { "name": "96 well fixed plate", "polymorphic_discriminator": "container_template", - "category": "BCP", + "category": "container", "type": "plate", "subtype": "fixed-plate-96", "version": "1.0", @@ -3311,12 +3311,12 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "download_plate_map": "BAC/plate/download_plate_map/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0", - "stamp_plates_into_plate": "BAC/plate/stamp_plates_into_plate/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "download_plate_map": "action/plate/download_plate_map/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0", + "stamp_plates_into_plate": "action/plate/stamp_plates_into_plate/1.0" }, "cogs": { "allocation_type": "", @@ -4934,7 +4934,7 @@ { "name": "4 position Rack", "polymorphic_discriminator": "container_template", - "category": "BCR", + "category": "container", "type": "rack", "subtype": "tube-rack-4-empty", "version": "1.0", @@ -4945,10 +4945,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -4987,7 +4987,7 @@ { "name": "4 position Rack", "polymorphic_discriminator": "container_template", - "category": "BCR", + "category": "container", "type": "rack", "subtype": "tube-rack-generic", "version": "1.0", @@ -4998,10 +4998,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5094,7 +5094,7 @@ { "name": "Shelf", "polymorphic_discriminator": "container_template", - "category": "BCR", + "category": "container", "type": "shelf", "subtype": "generic", "version": "1.0", @@ -5105,11 +5105,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_container_to": "BAC/equipment/add-container-to/1.0", - "download_inventory_tsv": "BAC/equipment/download-inventory-tsv/1.0", - "log_temperature": "BAC/equipment/log-temperature/1.0", - "remove_container_from": "BAC/equipment/remove-container-from/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_container_to": "action/equipment/add-container-to/1.0", + "download_inventory_tsv": "action/equipment/download-inventory-tsv/1.0", + "log_temperature": "action/equipment/log-temperature/1.0", + "remove_container_from": "action/equipment/remove-container-from/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5150,7 +5150,7 @@ { "name": "10ml glass tube", "polymorphic_discriminator": "container_template", - "category": "BCT", + "category": "container", "type": "tube", "subtype": "tube-10ml-glass", "version": "1.0", @@ -5161,10 +5161,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5197,7 +5197,7 @@ "name": "10ml glass tube" }, "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [], "semantic_category": "container" @@ -5206,7 +5206,7 @@ { "name": "1.5ml eppi tube", "polymorphic_discriminator": "container_template", - "category": "BCT", + "category": "container", "type": "tube", "subtype": "tube-eppi-1.5ml", "version": "1.0", @@ -5217,10 +5217,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5253,7 +5253,7 @@ "name": "1.5ml eppi tube" }, "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [], "semantic_category": "container" @@ -5262,7 +5262,7 @@ { "name": "Generic 10ml tube", "polymorphic_discriminator": "container_template", - "category": "BCT", + "category": "container", "type": "tube", "subtype": "tube-generic-10ml", "version": "1.0", @@ -5273,10 +5273,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5309,7 +5309,7 @@ "name": "10ml tube generic" }, "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [], "semantic_category": "container" @@ -5318,7 +5318,7 @@ { "name": "strek 10ml", "polymorphic_discriminator": "container_template", - "category": "BCT", + "category": "container", "type": "tube", "subtype": "tube-strek-10ml", "version": "1.0", @@ -5329,10 +5329,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5366,7 +5366,7 @@ }, "singleton": "0", "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [], "semantic_category": "container" @@ -5375,7 +5375,7 @@ { "name": "Mawi DNA Technologies - ISD-T-250 - iSWAB DNA Collection Tube - Buccal", "polymorphic_discriminator": "container_template", - "category": "BCT", + "category": "container", "type": "tube", "subtype": "mawi-isd-t-250-iswab-dna-collection-tube-buccal", "version": "1.0", @@ -5386,10 +5386,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5426,7 +5426,7 @@ }, "singleton": "0", "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [], "semantic_category": "container" @@ -5435,7 +5435,7 @@ { "name": "plate well - fixed", "polymorphic_discriminator": "container_template", - "category": "BCW", + "category": "container", "type": "well", "subtype": "fixed-plate-well", "version": "1.0", @@ -5446,7 +5446,7 @@ "json_addl": { "action_groups": {}, "action_imports": { - "set_object_status": "BAC/core/set_object_status/1.0" + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5479,7 +5479,7 @@ }, "singleton": "0", "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [ "container/plate/fixed-plate.*?/.*?" @@ -5490,7 +5490,7 @@ { "name": "giab NA12878", "polymorphic_discriminator": "content_template", - "category": "BNK", + "category": "material", "type": "control", "subtype": "giab-HG001", "version": "1.0", @@ -5501,10 +5501,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5534,7 +5534,7 @@ { "name": "giab NA24385", "polymorphic_discriminator": "content_template", - "category": "BNK", + "category": "material", "type": "control", "subtype": "giab-HG002", "version": "1.0", @@ -5545,10 +5545,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5578,7 +5578,7 @@ { "name": "synthetic-cfdna", "polymorphic_discriminator": "content_template", - "category": "BNK", + "category": "material", "type": "control", "subtype": "synthetic-cfdna", "version": "1.0", @@ -5589,10 +5589,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5622,7 +5622,7 @@ { "name": "NTC - water", "polymorphic_discriminator": "content_template", - "category": "BNK", + "category": "material", "type": "control", "subtype": "water-ntc", "version": "1.0", @@ -5633,10 +5633,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -5666,7 +5666,7 @@ { "name": "Pool of Contents", "polymorphic_discriminator": "content_template", - "category": "BNP", + "category": "material", "type": "pool", "subtype": "generic", "version": "1.0", @@ -5702,7 +5702,7 @@ { "name": "Novaseq sequencing kit", "polymorphic_discriminator": "content_template", - "category": "BNR", + "category": "material", "type": "reagent", "subtype": "ILMN-nova-seq-kit", "version": "1.0", @@ -5745,7 +5745,7 @@ { "name": "idt probes for rare mendelian disease, v1.0", "polymorphic_discriminator": "content_template", - "category": "BNR", + "category": "material", "type": "reagent", "subtype": "idt-probes-rare-mendelian", "version": "1.0", @@ -5788,7 +5788,7 @@ { "name": "NaOH", "polymorphic_discriminator": "content_template", - "category": "BNR", + "category": "material", "type": "reagent", "subtype": "naoh", "version": "1.0", @@ -5832,7 +5832,7 @@ { "name": "plasma (whole blood)", "polymorphic_discriminator": "content_template", - "category": "BNB", + "category": "material", "type": "sample", "subtype": "blood-plasma", "version": "1.0", @@ -5870,7 +5870,7 @@ { "name": "cell free DNA ", "polymorphic_discriminator": "content_template", - "category": "BNC", + "category": "material", "type": "sample", "subtype": "cfdna", "version": "1.0", @@ -5908,7 +5908,7 @@ { "name": "genomic DNA", "polymorphic_discriminator": "content_template", - "category": "BNG", + "category": "material", "type": "sample", "subtype": "gdna", "version": "1.0", @@ -5946,7 +5946,7 @@ { "name": "Whole Blood", "polymorphic_discriminator": "content_template", - "category": "BNB", + "category": "material", "type": "specimen", "subtype": "blood-whole", "version": "1.0", @@ -5984,7 +5984,7 @@ { "name": "Buccal Swab", "polymorphic_discriminator": "content_template", - "category": "BNS", + "category": "material", "type": "specimen", "subtype": "buccal-swab", "version": "1.0", @@ -6022,7 +6022,7 @@ { "name": "FFPE Block", "polymorphic_discriminator": "content_template", - "category": "BNF", + "category": "material", "type": "specimen", "subtype": "ffpe-block", "version": "1.0", @@ -6060,7 +6060,7 @@ { "name": "FFPE Slides", "polymorphic_discriminator": "content_template", - "category": "BNF", + "category": "material", "type": "specimen", "subtype": "ffpe-slice", "version": "1.0", @@ -6098,7 +6098,7 @@ { "name": "Execution Dead Letter Record", "polymorphic_discriminator": "data_template", - "category": "BDT", + "category": "data", "type": "execution", "subtype": "dead_letter", "version": "1.0", @@ -6109,9 +6109,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Execution Dead Letter Record", "expected_inputs": [], @@ -6137,7 +6137,7 @@ { "name": "Execution Attempt Record", "polymorphic_discriminator": "data_template", - "category": "BDT", + "category": "data", "type": "execution", "subtype": "execution_record", "version": "1.0", @@ -6148,9 +6148,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Execution Attempt Record", "expected_inputs": [], @@ -6190,7 +6190,7 @@ { "name": "Execution Hold", "polymorphic_discriminator": "data_template", - "category": "BDT", + "category": "data", "type": "execution", "subtype": "hold", "version": "1.0", @@ -6201,9 +6201,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Execution Hold", "expected_inputs": [], @@ -6227,7 +6227,7 @@ { "name": "Execution Queue Definition", "polymorphic_discriminator": "data_template", - "category": "BDT", + "category": "data", "type": "execution", "subtype": "queue", "version": "1.0", @@ -6238,10 +6238,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Execution Queue Definition", "expected_inputs": [], @@ -6291,7 +6291,7 @@ { "name": "Execution Queue Lease", "polymorphic_discriminator": "data_template", - "category": "BDT", + "category": "data", "type": "execution", "subtype": "queue_lease", "version": "1.0", @@ -6302,9 +6302,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Execution Queue Lease", "expected_inputs": [], @@ -6335,7 +6335,7 @@ { "name": "Generic Data Record", "polymorphic_discriminator": "data_template", - "category": "BD", + "category": "data", "type": "generic", "subtype": "generic", "version": "1.0", @@ -6346,9 +6346,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Generic data record", "expected_inputs": [], @@ -6365,7 +6365,7 @@ { "name": "DNA Quant Data", "polymorphic_discriminator": "data_template", - "category": "BDQ", + "category": "data", "type": "generic", "subtype": "gdna-quantification", "version": "1.0", @@ -6376,11 +6376,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "record_cfdna_quant_outcome": "BAC/data/record_cfdna_quant_outcome/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "record_cfdna_quant_outcome": "action/data/record_cfdna_quant_outcome/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -6408,7 +6408,7 @@ { "name": "Temperature Log Data", "polymorphic_discriminator": "data_template", - "category": "BDC", + "category": "data", "type": "generic", "subtype": "temperature-log", "version": "1.0", @@ -6445,7 +6445,7 @@ { "name": "Wet-Lab Library Prep Output", "polymorphic_discriminator": "data_template", - "category": "BDP", + "category": "data", "type": "wetlab", "subtype": "library_prep_output", "version": "1.0", @@ -6456,10 +6456,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Wet-Lab Library Prep Output", "expected_inputs": [], @@ -6478,7 +6478,7 @@ { "name": "Illumina Sequencing Run", "polymorphic_discriminator": "data_template", - "category": "BRM", + "category": "data", "type": "sequencing_run", "subtype": "illumina", "version": "1.0", @@ -6489,9 +6489,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Sequencing run executed on an Illumina instrument.", "expected_inputs": [], @@ -6516,7 +6516,7 @@ { "name": "ONT Sequencing Run", "polymorphic_discriminator": "data_template", - "category": "BRN", + "category": "data", "type": "sequencing_run", "subtype": "ont", "version": "1.0", @@ -6527,9 +6527,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Sequencing run executed on an Oxford Nanopore instrument.", "expected_inputs": [], @@ -6554,7 +6554,7 @@ { "name": "Ultima Sequencing Run", "polymorphic_discriminator": "data_template", - "category": "BRT", + "category": "data", "type": "sequencing_run", "subtype": "ultima", "version": "1.0", @@ -6565,9 +6565,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Sequencing run executed on an Ultima instrument.", "expected_inputs": [], @@ -6592,7 +6592,7 @@ { "name": "CompleteGenomics Sequencing Run", "polymorphic_discriminator": "data_template", - "category": "BRC", + "category": "data", "type": "sequencing_run", "subtype": "completegenomics", "version": "1.0", @@ -6603,9 +6603,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Sequencing run executed on a Complete Genomics instrument.", "expected_inputs": [], @@ -6630,7 +6630,7 @@ { "name": "PacBio Sequencing Run", "polymorphic_discriminator": "data_template", - "category": "BRP", + "category": "data", "type": "sequencing_run", "subtype": "pacbio", "version": "1.0", @@ -6641,9 +6641,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Sequencing run executed on a PacBio instrument.", "expected_inputs": [], @@ -6668,7 +6668,7 @@ { "name": "-20C Freezer", "polymorphic_discriminator": "equipment_template", - "category": "BEQ", + "category": "equipment", "type": "freezer", "subtype": "freezer-m20c", "version": "1.0", @@ -6679,14 +6679,14 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_container_to": "BAC/equipment/add-container-to/1.0", - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "download_inventory_tsv": "BAC/equipment/download-inventory-tsv/1.0", - "log_temperature": "BAC/equipment/log-temperature/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "remove_container_from": "BAC/equipment/remove-container-from/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_container_to": "action/equipment/add-container-to/1.0", + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "download_inventory_tsv": "action/equipment/download-inventory-tsv/1.0", + "log_temperature": "action/equipment/log-temperature/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "remove_container_from": "action/equipment/remove-container-from/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -6706,7 +6706,7 @@ "relationship_type": "contains", "child_templates": [ { - "template_code": "BCR/shelf/generic/1.0", + "template_code": "container/shelf/generic/1.0", "json_addl": { "cont_address": { "capacity": "10", @@ -6719,7 +6719,7 @@ } }, { - "template_code": "BCR/shelf/generic/1.0", + "template_code": "container/shelf/generic/1.0", "json_addl": { "cont_address": { "capacity": "10", @@ -6750,7 +6750,7 @@ { "name": "-80C Freezer.", "polymorphic_discriminator": "equipment_template", - "category": "BEQ", + "category": "equipment", "type": "freezer", "subtype": "freezer-m80c", "version": "1.0", @@ -6761,14 +6761,14 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_container_to": "BAC/equipment/add-container-to/1.0", - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "download_inventory_tsv": "BAC/equipment/download-inventory-tsv/1.0", - "log_temperature": "BAC/equipment/log-temperature/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "remove_container_from": "BAC/equipment/remove-container-from/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_container_to": "action/equipment/add-container-to/1.0", + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "download_inventory_tsv": "action/equipment/download-inventory-tsv/1.0", + "log_temperature": "action/equipment/log-temperature/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "remove_container_from": "action/equipment/remove-container-from/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -6788,7 +6788,7 @@ "relationship_type": "contains", "child_templates": [ { - "template_code": "BCR/shelf/generic/1.0", + "template_code": "container/shelf/generic/1.0", "json_addl": { "cont_address": { "capacity": "10", @@ -6801,7 +6801,7 @@ } }, { - "template_code": "BCR/shelf/generic/1.0", + "template_code": "container/shelf/generic/1.0", "json_addl": { "cont_address": { "capacity": "10", @@ -6814,7 +6814,7 @@ } }, { - "template_code": "BCR/shelf/generic/1.0", + "template_code": "container/shelf/generic/1.0", "json_addl": { "cont_address": { "capacity": "10", @@ -6827,7 +6827,7 @@ } }, { - "template_code": "BCR/shelf/generic/1.0", + "template_code": "container/shelf/generic/1.0", "json_addl": { "cont_address": { "capacity": "10", @@ -6858,7 +6858,7 @@ { "name": "Bravo Liquid Handler", "polymorphic_discriminator": "equipment_template", - "category": "BEQ", + "category": "equipment", "type": "liquid_handler", "subtype": "bravo", "version": "1.0", @@ -6869,10 +6869,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -6904,7 +6904,7 @@ { "name": "Illumnina MiSeq y", "polymorphic_discriminator": "equipment_template", - "category": "BEQ", + "category": "equipment", "type": "sequencers", "subtype": "miseq-y", "version": "1.0", @@ -6915,10 +6915,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -6950,7 +6950,7 @@ { "name": "Illumnina Novaseq 6000", "polymorphic_discriminator": "equipment_template", - "category": "BEQ", + "category": "equipment", "type": "sequencers", "subtype": "novaseq-6000", "version": "1.0", @@ -6961,10 +6961,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -6996,7 +6996,7 @@ { "name": "ONT MinION", "polymorphic_discriminator": "equipment_template", - "category": "BEQ", + "category": "equipment", "type": "sequencers", "subtype": "ont-minion", "version": "1.0", @@ -7007,10 +7007,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -7042,7 +7042,7 @@ { "name": "Generic File", "polymorphic_discriminator": "file_template", - "category": "BFX", + "category": "file", "type": "file", "subtype": "generic", "version": "1.0", @@ -7053,11 +7053,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "download_file": "BAC/file/download_file/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "download_file": "action/file/download_file/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "arbitrary_association_strings": [], "cogs": { @@ -7375,7 +7375,7 @@ { "name": "Generic File Set", "polymorphic_discriminator": "file_template", - "category": "BFX", + "category": "file", "type": "file_set", "subtype": "generic", "version": "1.0", @@ -7386,10 +7386,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -7466,10 +7466,111 @@ "semantic_category": "file" } }, + { + "name": "Generic Run Set", + "polymorphic_discriminator": "generic_template", + "category": "set", + "type": "run-set", + "subtype": "generic", + "version": "1.01", + "instance_prefix": "BGS", + "instance_polymorphic_identity": "generic_instance", + "bstatus": "active", + "is_singleton": false, + "json_addl": { + "action_groups": {}, + "action_imports": { + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" + }, + "controlled_properties": {}, + "description": "Generic run set for temporary Bloom lab batch and sequencing-run grouping.", + "expected_inputs": [], + "expected_outputs": [], + "instantiation_layouts": [], + "properties": { + "name": "", + "description": "", + "members": [], + "external_members": [], + "operator": "", + "instrument": "", + "reagents": [], + "machine": "", + "flowcell_barcode": "", + "status": "created", + "metadata": {} + }, + "singleton": "0", + "ui_form_properties": [ + { + "form_label": "Set Name", + "property_key": "name", + "required": false, + "value_type": "string" + }, + { + "form_label": "Description", + "property_key": "description", + "required": false, + "value_type": "string" + }, + { + "form_label": "Members", + "property_key": "members", + "required": false, + "value_type": "list" + }, + { + "form_label": "External Members", + "property_key": "external_members", + "required": false, + "value_type": "list" + }, + { + "form_label": "Operator", + "property_key": "operator", + "required": false, + "value_type": "string" + }, + { + "form_label": "Instrument", + "property_key": "instrument", + "required": false, + "value_type": "uid-interactive" + }, + { + "form_label": "Reagents", + "property_key": "reagents", + "required": false, + "value_type": "list" + }, + { + "form_label": "Machine", + "property_key": "machine", + "required": false, + "value_type": "string" + }, + { + "form_label": "Flowcell Barcode", + "property_key": "flowcell_barcode", + "required": false, + "value_type": "string" + }, + { + "form_label": "Status", + "property_key": "status", + "required": false, + "value_type": "controlled" + } + ], + "semantic_category": "set" + } + }, { "name": "Generic File Reference", "polymorphic_discriminator": "file_template", - "category": "BFX", + "category": "file", "type": "shared_ref", "subtype": "generic", "version": "1.0", @@ -7480,10 +7581,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "arbitrary_association_strings": [], "cogs": { @@ -7555,7 +7656,7 @@ { "name": "Generic Health Event", "polymorphic_discriminator": "health_event_template", - "category": "BHE", + "category": "health", "type": "generic", "subtype": "health-event", "version": "1.0", @@ -7566,10 +7667,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -7597,7 +7698,7 @@ { "name": "Decision scope for an accession (ACC / TRX / intake bundle).", "polymorphic_discriminator": "subject_template", - "category": "BSJ", + "category": "subject", "type": "generic", "subtype": "accession-subject", "version": "1.0", @@ -7608,10 +7709,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -7696,7 +7797,7 @@ { "name": "Decision scope for analysis result bundles.", "polymorphic_discriminator": "subject_template", - "category": "BSJ", + "category": "subject", "type": "generic", "subtype": "analysis-bundle-subject", "version": "1.0", @@ -7707,10 +7808,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -7795,7 +7896,7 @@ { "name": "Fallback decision scope for custom use cases.", "polymorphic_discriminator": "subject_template", - "category": "BSJ", + "category": "subject", "type": "generic", "subtype": "generic-subject", "version": "1.0", @@ -7806,10 +7907,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -7897,7 +7998,7 @@ { "name": "Decision scope for clinical reportable units.", "polymorphic_discriminator": "subject_template", - "category": "BSJ", + "category": "subject", "type": "generic", "subtype": "report-subject", "version": "1.0", @@ -7908,10 +8009,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -7996,7 +8097,7 @@ { "name": "Workset grouping workflow steps and objects created during an accessioning transaction.", "polymorphic_discriminator": "subject_template", - "category": "BSJ", + "category": "subject", "type": "workset", "subtype": "accession", "version": "1.0", @@ -8007,10 +8108,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -8125,7 +8226,7 @@ { "name": "Workset grouping workflow steps and objects created during an extraction batch.", "polymorphic_discriminator": "subject_template", - "category": "BSJ", + "category": "subject", "type": "workset", "subtype": "extraction", "version": "1.0", @@ -8136,10 +8237,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -8254,7 +8355,7 @@ { "name": "Workset grouping workflow steps and objects created during a sequencing run.", "polymorphic_discriminator": "subject_template", - "category": "BSJ", + "category": "subject", "type": "workset", "subtype": "sequencing", "version": "1.0", @@ -8265,10 +8366,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -8383,7 +8484,7 @@ { "name": "Workset for carrier-screen 3.9", "polymorphic_discriminator": "workflow_template", - "category": "BWF", + "category": "workflow", "type": "assay-workset", "subtype": "carrier-screen", "version": "3.9", @@ -8394,11 +8495,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "move_among_ay_top_queues": "BAC/move-queues/move-among-ay-top-queues/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "move_among_ay_top_queues": "action/move-queues/move-among-ay-top-queues/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -8418,7 +8519,7 @@ "relationship_type": "contains", "child_templates": [ { - "template_code": "BWS/queue/plasma-isolation-queue-available/1.0", + "template_code": "workflow_step/queue/plasma-isolation-queue-available/1.0", "json_addl": { "properties": { "comments": "", @@ -8427,7 +8528,7 @@ } }, { - "template_code": "BWS/queue/plasma-isolation-queue-removed/1.0", + "template_code": "workflow_step/queue/plasma-isolation-queue-removed/1.0", "json_addl": { "properties": { "comments": "", @@ -8436,7 +8537,7 @@ } }, { - "template_code": "BWS/queue/plasma-isolation-queue-exception/1.0", + "template_code": "workflow_step/queue/plasma-isolation-queue-exception/1.0", "json_addl": { "properties": { "comments": "", @@ -8464,7 +8565,7 @@ { "name": "Workset for hla-typing 1.2", "polymorphic_discriminator": "workflow_template", - "category": "BWF", + "category": "workflow", "type": "assay-workset", "subtype": "hla-typing", "version": "1.2", @@ -8475,11 +8576,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "move_among_ay_top_queues": "BAC/move-queues/move-among-ay-top-queues/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "move_among_ay_top_queues": "action/move-queues/move-among-ay-top-queues/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -8517,7 +8618,7 @@ { "name": "Carrier Screen 3.9", "polymorphic_discriminator": "workflow_template", - "category": "BWF", + "category": "workflow", "type": "assay", "subtype": "carrier-screen", "version": "3.9", @@ -8528,10 +8629,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "assay_type": "assay", "cogs": { @@ -8552,7 +8653,7 @@ "relationship_type": "contains", "child_templates": [ { - "template_code": "BWS/queue/extraction-batch-eligible/1.0", + "template_code": "workflow_step/queue/extraction-batch-eligible/1.0", "json_addl": { "description": "Extraction Batch Eligible", "properties": { @@ -8565,7 +8666,7 @@ "name_pattern": "Extraction Batch Eligible" }, { - "template_code": "BWS/queue/blood-to-gdna-extraction-eligible/1.0", + "template_code": "workflow_step/queue/blood-to-gdna-extraction-eligible/1.0", "json_addl": { "description": "Blood To gDNA Extraction Eligible", "properties": { @@ -8578,7 +8679,7 @@ "name_pattern": "Blood To gDNA Extraction Eligible" }, { - "template_code": "BWS/queue/buccal-to-gdna-extraction-eligible/1.0", + "template_code": "workflow_step/queue/buccal-to-gdna-extraction-eligible/1.0", "json_addl": { "description": "Buccal To gDNA Extraction Eligible", "properties": { @@ -8591,7 +8692,7 @@ "name_pattern": "Buccal To gDNA Extraction Eligible" }, { - "template_code": "BWS/queue/input-gdna-normalization-eligible/1.0", + "template_code": "workflow_step/queue/input-gdna-normalization-eligible/1.0", "json_addl": { "description": "Input gDNA Normalization Eligible", "properties": { @@ -8604,7 +8705,7 @@ "name_pattern": "Input gDNA Normalization Eligible" }, { - "template_code": "BWS/queue/illumina-novaseq-libprep-eligible/1.0", + "template_code": "workflow_step/queue/illumina-novaseq-libprep-eligible/1.0", "json_addl": { "description": "Illumina Novaseq LibPrep Eligible", "properties": { @@ -8617,7 +8718,7 @@ "name_pattern": "Illumina Novaseq LibPrep Eligible" }, { - "template_code": "BWS/queue/ont-libprep-eligible/1.0", + "template_code": "workflow_step/queue/ont-libprep-eligible/1.0", "json_addl": { "description": "ONT LibPrep Eligible", "properties": { @@ -8648,7 +8749,7 @@ { "name": "HLA Typing v1.2", "polymorphic_discriminator": "workflow_template", - "category": "BWF", + "category": "workflow", "type": "assay", "subtype": "hla-typing", "version": "1.2", @@ -8659,10 +8760,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "assay_type": "assay", "cogs": { @@ -8683,7 +8784,7 @@ "relationship_type": "contains", "child_templates": [ { - "template_code": "BWS/queue/extraction-batch-eligible/1.0", + "template_code": "workflow_step/queue/extraction-batch-eligible/1.0", "json_addl": { "description": "Extraction Batch Eligible", "properties": { @@ -8696,7 +8797,7 @@ "name_pattern": "Extraction Batch Eligible" }, { - "template_code": "BWS/queue/blood-to-gdna-extraction-eligible/1.0", + "template_code": "workflow_step/queue/blood-to-gdna-extraction-eligible/1.0", "json_addl": { "description": "Blood To gDNA Extraction Eligible", "properties": { @@ -8709,7 +8810,7 @@ "name_pattern": "Blood To gDNA Extraction Eligible" }, { - "template_code": "BWS/queue/buccal-to-gdna-extraction-eligible/1.0", + "template_code": "workflow_step/queue/buccal-to-gdna-extraction-eligible/1.0", "json_addl": { "description": "Buccal To gDNA Extraction Eligible", "properties": { @@ -8722,7 +8823,7 @@ "name_pattern": "Buccal To gDNA Extraction Eligible" }, { - "template_code": "BWS/queue/input-gdna-normalization-eligible/1.0", + "template_code": "workflow_step/queue/input-gdna-normalization-eligible/1.0", "json_addl": { "description": "Input gDNA Normalization Eligible", "properties": { @@ -8735,7 +8836,7 @@ "name_pattern": "Input gDNA Normalization Eligible" }, { - "template_code": "BWS/queue/illumina-novaseq-libprep-eligible/1.0", + "template_code": "workflow_step/queue/illumina-novaseq-libprep-eligible/1.0", "json_addl": { "description": "Illumina Novaseq LibPrep Eligible", "properties": { @@ -8748,7 +8849,7 @@ "name_pattern": "Illumina Novaseq LibPrep Eligible" }, { - "template_code": "BWS/queue/ont-libprep-eligible/1.0", + "template_code": "workflow_step/queue/ont-libprep-eligible/1.0", "json_addl": { "description": "ONT LibPrep Eligible", "properties": { @@ -8779,7 +8880,7 @@ { "name": "Destroy Specimen Containers", "polymorphic_discriminator": "workflow_template", - "category": "BWF", + "category": "workflow", "type": "destroy", "subtype": "specimen-containers", "version": "1.0", @@ -8790,11 +8891,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "destroy_specimen_containers": "BAC/workflow/destroy_specimen_containers/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "destroy_specimen_containers": "action/workflow/destroy_specimen_containers/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -8821,7 +8922,7 @@ { "name": "DNA extraction from blood-whole in tube-strek-10ml tubes to a fixed-plate-96 with gdna in each well.", "polymorphic_discriminator": "workflow_template", - "category": "BWF", + "category": "workflow", "type": "extraction", "subtype": "blood-whole-to-gdna", "version": "1.0", @@ -8832,10 +8933,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -8867,7 +8968,7 @@ { "name": "DNA extraction from blood-whole in tube-strek-10ml tubes to a fixed-plate-96 with gdna in each well.", "polymorphic_discriminator": "workflow_template", - "category": "BWF", + "category": "workflow", "type": "extraction", "subtype": "blood-whole-to-plasma-to-cfdna", "version": "1.0", @@ -8878,10 +8979,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -8913,7 +9014,7 @@ { "name": "DNA QUANTIFIED", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "analysis", "subtype": "gdna-quantified", "version": "1.0", @@ -8924,10 +9025,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -8943,44 +9044,44 @@ "expected_inputs": [ { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } } ], "expected_outputs": [ { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } } ], @@ -9006,7 +9107,7 @@ { "name": "Plasma QUANT", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "analysis", "subtype": "plasma-quantification", "version": "1.0", @@ -9017,11 +9118,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "download_quant_data": "BAC/workflow_step_analysis/download_quant_data/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "download_quant_data": "action/workflow_step_analysis/download_quant_data/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9058,7 +9159,7 @@ { "name": "Specimens Destroyed", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "destroy-steps", "subtype": "specimen-containers-destroyed", "version": "1.0", @@ -9069,11 +9170,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "complete_specimen_destroy": "BAC/workflow_step_destroy/complete_specimen_destroy/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "complete_specimen_destroy": "action/workflow_step_destroy/complete_specimen_destroy/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9110,7 +9211,7 @@ { "name": "Destroyed Containers Exceptions", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "exception", "subtype": "specimen-containers-destroyed", "version": "1.0", @@ -9121,10 +9222,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9161,7 +9262,7 @@ { "name": "cfDNA Extraction Plate ", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "extraction-steps", "subtype": "cfdna-extracted", "version": "1.0", @@ -9172,11 +9273,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "cfdna_post_extraction_quant": "BAC/workflow_step_extraction/cfdna_post_extraction_quant/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "cfdna_post_extraction_quant": "action/workflow_step_extraction/cfdna_post_extraction_quant/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9193,22 +9294,22 @@ [ { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } } ] @@ -9220,7 +9321,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/cfdna/*" + "holds_content": "material/sample/cfdna/*" } } ] @@ -9231,7 +9332,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/cfdna/*" + "holds_content": "material/sample/cfdna/*" } } ] @@ -9242,7 +9343,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/cfdna/*" + "holds_content": "material/sample/cfdna/*" } } ] @@ -9253,7 +9354,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/cfdna/*" + "holds_content": "material/sample/cfdna/*" } } ] @@ -9283,7 +9384,7 @@ { "name": "Extracting gdna from blood-whole in tubes to wells in a fixed-plate-96.", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "extraction-steps", "subtype": "gdna-extraction", "version": "1.0", @@ -9294,10 +9395,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9313,22 +9414,22 @@ "expected_inputs": [ { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } } ], @@ -9339,7 +9440,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/gdna/*" + "holds_content": "material/sample/gdna/*" } } ] @@ -9350,7 +9451,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/gdna/*" + "holds_content": "material/sample/gdna/*" } } ] @@ -9361,7 +9462,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/gdna/*" + "holds_content": "material/sample/gdna/*" } } ] @@ -9372,7 +9473,7 @@ "child_of_lineages": [ { "container/well/fixed-plate-well/*/": { - "holds_content": "content/sample/gdna/*" + "holds_content": "material/sample/gdna/*" } } ] @@ -9402,7 +9503,7 @@ { "name": "Loading blood-whole in tube-strek-10ml tubes.", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "extraction-steps", "subtype": "loading", "version": "1.0", @@ -9413,10 +9514,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9432,22 +9533,22 @@ "expected_inputs": [ { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } } ], @@ -9474,7 +9575,7 @@ { "name": "plasma isolation", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "extraction-steps", "subtype": "plasma-isolated", "version": "1.0", @@ -9485,10 +9586,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9504,44 +9605,44 @@ "expected_inputs": [ { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } }, { "container/tube/tube-strek-10ml/*/": { - "holds_content": "content/specimen/blood-whole/*" + "holds_content": "material/specimen/blood-whole/*" } } ], "expected_outputs": [ { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } }, { "container/tube/tube-generic-10ml/*/": { - "holds_content": "content/sample/blood-plasma/*" + "holds_content": "material/sample/blood-plasma/*" } } ], @@ -9567,7 +9668,7 @@ { "name": "Filled Extraction Plate", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "plate-operations", "subtype": "extraction-plate-filled", "version": "1.0", @@ -9578,15 +9679,15 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "cfdna_quant": "BAC/workflow_step_plate_operations/cfdna_quant/1.0", - "create_pool_from_containers": "BAC/pool/create_pool_from_containers__extraction_plate_filled/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_child_object": "BAC/object/set-child-object/1.0", - "set_object_status": "BAC/core/set_object_status/1.0", - "stamp_copy_plate": "BAC/workflow_step_plate_operations/stamp_copy_plate/1.0", - "stamp_plates_into_plate": "BAC/plate/stamp_plates_into_plate__extraction_plate_filled/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "cfdna_quant": "action/workflow_step_plate_operations/cfdna_quant/1.0", + "create_pool_from_containers": "action/pool/create_pool_from_containers__extraction_plate_filled/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_child_object": "action/object/set-child-object/1.0", + "set_object_status": "action/core/set_object_status/1.0", + "stamp_copy_plate": "action/workflow_step_plate_operations/stamp_copy_plate/1.0", + "stamp_plates_into_plate": "action/plate/stamp_plates_into_plate__extraction_plate_filled/1.0" }, "cogs": { "allocation_type": "", @@ -9630,7 +9731,7 @@ { "name": "Heatseal Plate", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "plate-operations", "subtype": "heat-seal", "version": "1.0", @@ -9641,10 +9742,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9689,7 +9790,7 @@ { "name": "Existing plate, had 1+ plates merged into it.", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "plate-operations", "subtype": "plate-added-to", "version": "1.0", @@ -9700,12 +9801,12 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "cfdna_quant": "BAC/workflow_step_plate_operations/cfdna_quant/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0", - "stamp_copy_plate": "BAC/workflow_step_plate_operations/stamp_copy_plate/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "cfdna_quant": "action/workflow_step_plate_operations/cfdna_quant/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0", + "stamp_copy_plate": "action/workflow_step_plate_operations/stamp_copy_plate/1.0" }, "cogs": { "allocation_type": "", @@ -9742,7 +9843,7 @@ { "name": "Stamp Copy Plate", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "plate-operations", "subtype": "stamp-copy-plate", "version": "1.0", @@ -9753,12 +9854,12 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "cfdna_quant": "BAC/workflow_step_plate_operations/cfdna_quant/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0", - "stamp_copy_plate": "BAC/workflow_step_plate_operations/stamp_copy_plate/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "cfdna_quant": "action/workflow_step_plate_operations/cfdna_quant/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0", + "stamp_copy_plate": "action/workflow_step_plate_operations/stamp_copy_plate/1.0" }, "cogs": { "allocation_type": "", @@ -9795,7 +9896,7 @@ { "name": "pool", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "pool", "subtype": "generic", "version": "1.0", @@ -9806,10 +9907,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9846,7 +9947,7 @@ { "name": "OVERRIDEME", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "all-purpose", "version": "1.0", @@ -9857,10 +9958,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9897,7 +9998,7 @@ { "name": "Blood To gDNA Extraction Eligible", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "blood-to-gdna-extraction-eligible", "version": "1.0", @@ -9908,15 +10009,15 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_gdna_to_gdna_normalization_queue": "BAC/workflow_step_queue/add-gdna-to-gdna-normalization-queue/1.0", - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "existing_plate_fill_auto": "BAC/workflow_step_queue/existing-plate-fill-auto/1.0", - "existing_plate_fill_directed": "BAC/workflow_step_queue/existing-plate-fill-directed/1.0", - "plate_create_fill_auto": "BAC/workflow_step_queue/plate-create-fill-auto/1.0", - "plate_create_fill_directed": "BAC/workflow_step_queue/plate-create-fill-directed/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_gdna_to_gdna_normalization_queue": "action/workflow_step_queue/add-gdna-to-gdna-normalization-queue/1.0", + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "existing_plate_fill_auto": "action/workflow_step_queue/existing-plate-fill-auto/1.0", + "existing_plate_fill_directed": "action/workflow_step_queue/existing-plate-fill-directed/1.0", + "plate_create_fill_auto": "action/workflow_step_queue/plate-create-fill-auto/1.0", + "plate_create_fill_directed": "action/workflow_step_queue/plate-create-fill-directed/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -9953,7 +10054,7 @@ { "name": "Buccal To gDNA Extraction Eligible", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "buccal-to-gdna-extraction-eligible", "version": "1.0", @@ -9964,15 +10065,15 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_gdna_to_gdna_normalization_queue": "BAC/workflow_step_queue/add-gdna-to-gdna-normalization-queue/1.0", - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "existing_plate_fill_auto": "BAC/workflow_step_queue/existing-plate-fill-auto/1.0", - "existing_plate_fill_directed": "BAC/workflow_step_queue/existing-plate-fill-directed/1.0", - "plate_create_fill_auto": "BAC/workflow_step_queue/plate-create-fill-auto/1.0", - "plate_create_fill_directed": "BAC/workflow_step_queue/plate-create-fill-directed/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_gdna_to_gdna_normalization_queue": "action/workflow_step_queue/add-gdna-to-gdna-normalization-queue/1.0", + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "existing_plate_fill_auto": "action/workflow_step_queue/existing-plate-fill-auto/1.0", + "existing_plate_fill_directed": "action/workflow_step_queue/existing-plate-fill-directed/1.0", + "plate_create_fill_auto": "action/workflow_step_queue/plate-create-fill-auto/1.0", + "plate_create_fill_directed": "action/workflow_step_queue/plate-create-fill-directed/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -10009,7 +10110,7 @@ { "name": "Extraction Batch Eligible", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "extraction-batch-eligible", "version": "1.0", @@ -10020,12 +10121,12 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "add_to_blood_gdna_extraction_queue": "BAC/workflow_step_queue/add-to-blood-gdna-extraction-queue/1.0", - "add_to_buccal_gdna_extraction_queue": "BAC/workflow_step_queue/add-to-buccal-gdna-extraction-queue/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "add_to_blood_gdna_extraction_queue": "action/workflow_step_queue/add-to-blood-gdna-extraction-queue/1.0", + "add_to_buccal_gdna_extraction_queue": "action/workflow_step_queue/add-to-buccal-gdna-extraction-queue/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -10062,7 +10163,7 @@ { "name": "Plasma To Plates For Extraction", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "extraction-queue-available", "version": "1.0", @@ -10073,12 +10174,12 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "fill_plate_directed": "BAC/workflow_step_queue/fill_plate_directed/1.0", - "fill_plate_undirected": "BAC/workflow_step_queue/fill_plate_undirected/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "fill_plate_directed": "action/workflow_step_queue/fill_plate_directed/1.0", + "fill_plate_undirected": "action/workflow_step_queue/fill_plate_undirected/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -10115,7 +10216,7 @@ { "name": "Illumina Novaseq LibPrep Eligible", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "illumina-novaseq-libprep-eligible", "version": "1.0", @@ -10126,14 +10227,14 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "existing_plate_fill_auto": "BAC/workflow_step_queue/existing-plate-fill-auto/1.0", - "existing_plate_fill_directed": "BAC/workflow_step_queue/existing-plate-fill-directed/1.0", - "plate_create_fill_auto": "BAC/workflow_step_queue/plate-create-fill-auto/1.0", - "plate_create_fill_directed": "BAC/workflow_step_queue/plate-create-fill-directed/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "existing_plate_fill_auto": "action/workflow_step_queue/existing-plate-fill-auto/1.0", + "existing_plate_fill_directed": "action/workflow_step_queue/existing-plate-fill-directed/1.0", + "plate_create_fill_auto": "action/workflow_step_queue/plate-create-fill-auto/1.0", + "plate_create_fill_directed": "action/workflow_step_queue/plate-create-fill-directed/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -10170,7 +10271,7 @@ { "name": "Input gDNA Normalization Eligible", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "input-gdna-normalization-eligible", "version": "1.0", @@ -10181,15 +10282,15 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "existing_plate_fill_auto": "BAC/workflow_step_queue/existing-plate-fill-auto/1.0", - "existing_plate_fill_directed": "BAC/workflow_step_queue/existing-plate-fill-directed/1.0", - "plate_create_fill_auto": "BAC/workflow_step_queue/plate-create-fill-auto/1.0", - "plate_create_fill_directed": "BAC/workflow_step_queue/plate-create-fill-directed/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "save_quant_data": "BAC/workflow_step_queue/save-quant-data/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "existing_plate_fill_auto": "action/workflow_step_queue/existing-plate-fill-auto/1.0", + "existing_plate_fill_directed": "action/workflow_step_queue/existing-plate-fill-directed/1.0", + "plate_create_fill_auto": "action/workflow_step_queue/plate-create-fill-auto/1.0", + "plate_create_fill_directed": "action/workflow_step_queue/plate-create-fill-directed/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "save_quant_data": "action/workflow_step_queue/save-quant-data/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -10226,7 +10327,7 @@ { "name": "ONT LibPrep Eligible", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "ont-libprep-eligible", "version": "1.0", @@ -10237,14 +10338,14 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "existing_plate_fill_auto": "BAC/workflow_step_queue/existing-plate-fill-auto/1.0", - "existing_plate_fill_directed": "BAC/workflow_step_queue/existing-plate-fill-directed/1.0", - "plate_create_fill_auto": "BAC/workflow_step_queue/plate-create-fill-auto/1.0", - "plate_create_fill_directed": "BAC/workflow_step_queue/plate-create-fill-directed/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "existing_plate_fill_auto": "action/workflow_step_queue/existing-plate-fill-auto/1.0", + "existing_plate_fill_directed": "action/workflow_step_queue/existing-plate-fill-directed/1.0", + "plate_create_fill_auto": "action/workflow_step_queue/plate-create-fill-auto/1.0", + "plate_create_fill_directed": "action/workflow_step_queue/plate-create-fill-directed/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -10281,7 +10382,7 @@ { "name": "Containers available for plasma isolation in this queue.", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "plasma-isolation-queue-available", "version": "1.0", @@ -10292,11 +10393,11 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "link_tubes_auto": "BAC/workflow_step_queue/link_tubes_auto/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "link_tubes_auto": "action/workflow_step_queue/link_tubes_auto/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -10333,7 +10434,7 @@ { "name": "Plasma Isolation Exception Queue", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "plasma-isolation-queue-exception", "version": "1.0", @@ -10344,12 +10445,12 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "link_tubes_auto": "BAC/workflow_step_queue/link_tubes_auto/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0", - "ycreate_test_req_and_link_child_workflow_step": "BAC/workflow_step_queue/ycreate_test_req_and_link_child_workflow_step/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "link_tubes_auto": "action/workflow_step_queue/link_tubes_auto/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0", + "ycreate_test_req_and_link_child_workflow_step": "action/workflow_step_queue/ycreate_test_req_and_link_child_workflow_step/1.0" }, "cogs": { "allocation_type": "", @@ -10400,7 +10501,7 @@ { "name": "Containers Removed From Queue", "polymorphic_discriminator": "workflow_step_template", - "category": "BWS", + "category": "workflow_step", "type": "queue", "subtype": "plasma-isolation-queue-removed", "version": "1.0", @@ -10411,10 +10512,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -10462,7 +10563,7 @@ { "name": "Sequencing Library Plate 96", "polymorphic_discriminator": "container_template", - "category": "BCP", + "category": "container", "type": "plate", "subtype": "sequencing-library-plate-96", "version": "1.0", @@ -10473,12 +10574,12 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "download_plate_map": "BAC/plate/download_plate_map/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0", - "stamp_plates_into_plate": "BAC/plate/stamp_plates_into_plate/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "download_plate_map": "action/plate/download_plate_map/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0", + "stamp_plates_into_plate": "action/plate/stamp_plates_into_plate/1.0" }, "cogs": { "allocation_type": "", @@ -12097,7 +12198,7 @@ { "name": "Sequencing Index Plate 96", "polymorphic_discriminator": "container_template", - "category": "BCP", + "category": "container", "type": "plate", "subtype": "index-plate-96", "version": "1.0", @@ -12108,12 +12209,12 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "download_plate_map": "BAC/plate/download_plate_map/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0", - "stamp_plates_into_plate": "BAC/plate/stamp_plates_into_plate/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "download_plate_map": "action/plate/download_plate_map/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0", + "stamp_plates_into_plate": "action/plate/stamp_plates_into_plate/1.0" }, "cogs": { "allocation_type": "", @@ -13732,7 +13833,7 @@ { "name": "Generic Reagent Bottle", "polymorphic_discriminator": "container_template", - "category": "BCB", + "category": "container", "type": "bottle", "subtype": "generic", "version": "1.0", @@ -13743,10 +13844,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -13780,7 +13881,7 @@ "container_role": "bottle" }, "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [], "semantic_category": "container" @@ -13789,7 +13890,7 @@ { "name": "Flowcell", "polymorphic_discriminator": "container_template", - "category": "BCF", + "category": "container", "type": "flowcell", "subtype": "generic", "version": "1.0", @@ -13800,10 +13901,10 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "print_barcode_label": "BAC/core/print_barcode_label/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "print_barcode_label": "action/core/print_barcode_label/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -13836,7 +13937,7 @@ "lab_code": "" }, "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [], "semantic_category": "container" @@ -13845,7 +13946,7 @@ { "name": "Flowcell Lane", "polymorphic_discriminator": "container_template", - "category": "BCE", + "category": "container", "type": "flowcell_lane", "subtype": "generic", "version": "1.0", @@ -13856,7 +13957,7 @@ "json_addl": { "action_groups": {}, "action_imports": { - "set_object_status": "BAC/core/set_object_status/1.0" + "set_object_status": "action/core/set_object_status/1.0" }, "cogs": { "allocation_type": "", @@ -13888,7 +13989,7 @@ }, "singleton": "0", "valid_content": [ - "content/.*" + "material/.*" ], "valid_parents": [ "container/plate/fixed-plate.*?/.*?" @@ -13899,7 +14000,7 @@ { "name": "Saliva Specimen", "polymorphic_discriminator": "content_template", - "category": "BNA", + "category": "material", "type": "specimen", "subtype": "saliva", "version": "1.0", @@ -13937,7 +14038,7 @@ { "name": "Sequencing Library Content", "polymorphic_discriminator": "content_template", - "category": "BNQ", + "category": "material", "type": "sample", "subtype": "sequencing-library", "version": "1.0", @@ -13977,7 +14078,7 @@ { "name": "Sequencing Library Pool Content", "polymorphic_discriminator": "content_template", - "category": "BNP", + "category": "material", "type": "pool", "subtype": "sequencing-library", "version": "1.0", @@ -14016,7 +14117,7 @@ { "name": "Sequencing Index Reagent Content", "polymorphic_discriminator": "content_template", - "category": "BNX", + "category": "material", "type": "reagent", "subtype": "sequencing-index", "version": "1.0", @@ -14065,7 +14166,7 @@ { "name": "gDNA Quantification Data", "polymorphic_discriminator": "data_template", - "category": "BDQ", + "category": "data", "type": "quantification", "subtype": "gdna", "version": "1.0", @@ -14076,9 +14177,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Generic data record", "expected_inputs": [], @@ -14106,7 +14207,7 @@ { "name": "Extraction Run Evidence", "polymorphic_discriminator": "data_template", - "category": "BDX", + "category": "data", "type": "operation", "subtype": "extraction", "version": "1.0", @@ -14117,9 +14218,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Generic data record", "expected_inputs": [], @@ -14144,7 +14245,7 @@ { "name": "Extraction QC Run Evidence", "polymorphic_discriminator": "data_template", - "category": "BDY", + "category": "data", "type": "operation", "subtype": "extraction-qc", "version": "1.0", @@ -14155,9 +14256,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Generic data record", "expected_inputs": [], @@ -14180,7 +14281,7 @@ { "name": "Library Prep Run Evidence", "polymorphic_discriminator": "data_template", - "category": "BDP", + "category": "data", "type": "operation", "subtype": "library-prep", "version": "1.0", @@ -14191,9 +14292,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Generic data record", "expected_inputs": [], @@ -14216,7 +14317,7 @@ { "name": "Pooling Run Evidence", "polymorphic_discriminator": "data_template", - "category": "BDN", + "category": "data", "type": "operation", "subtype": "pooling", "version": "1.0", @@ -14227,9 +14328,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Generic data record", "expected_inputs": [], @@ -14252,7 +14353,7 @@ { "name": "Library Index Assignment Data", "polymorphic_discriminator": "data_template", - "category": "BDA", + "category": "data", "type": "library-index-assignment", "subtype": "sequencing-library", "version": "1.0", @@ -14263,9 +14364,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Generic data record", "expected_inputs": [], @@ -14290,7 +14391,7 @@ { "name": "Transfer Execution Data", "polymorphic_discriminator": "data_template", - "category": "BDT", + "category": "data", "type": "execution", "subtype": "transfer", "version": "1.0", @@ -14301,9 +14402,9 @@ "json_addl": { "action_groups": {}, "action_imports": { - "add_relationships": "BAC/core/add-relationships/1.0", - "create_subject_and_anchor": "BAC/core/create-subject-and-anchor/1.0", - "set_object_status": "BAC/core/set_object_status/1.0" + "add_relationships": "action/core/add-relationships/1.0", + "create_subject_and_anchor": "action/core/create-subject-and-anchor/1.0", + "set_object_status": "action/core/set_object_status/1.0" }, "description": "Generic data record", "expected_inputs": [], @@ -14322,6 +14423,39 @@ "singleton": "0", "semantic_category": "data" } + }, + { + "name": "Bloom Anomaly Record", + "polymorphic_discriminator": "generic_template", + "category": "ops", + "type": "anomaly-record", + "subtype": "generic", + "version": "1.0", + "instance_prefix": "BAN", + "instance_polymorphic_identity": "generic_instance", + "bstatus": "active", + "is_singleton": false, + "json_addl": { + "managed_by": "bloom", + "domain": "observability", + "description": "Sanitized Bloom operational anomaly record for Kahlo observability.", + "semantic_category": "observability_anomaly", + "required_properties": [ + "service", + "environment", + "category", + "severity", + "fingerprint", + "summary", + "first_seen_at", + "last_seen_at", + "occurrence_count", + "redacted_context" + ], + "sensitive_fields": [ + "redacted_context" + ] + } } ] } diff --git a/container_tests/test_container_runtime.py b/container_tests/test_container_runtime.py index d8f6a6f..6d8617d 100644 --- a/container_tests/test_container_runtime.py +++ b/container_tests/test_container_runtime.py @@ -17,7 +17,7 @@ def test_docker_runtime_files_use_foreground_uv_and_no_legacy_runtime() -> None: assert "uv sync --frozen --no-dev --no-install-project" in dockerfile assert "uv sync --frozen --no-dev" in dockerfile assert "USER lsmc" in dockerfile - assert "python\", \"-m\", \"bloom_lims.container_entry" in dockerfile + assert 'python", "-m", "bloom_lims.container_entry' in dockerfile assert ":latest" not in dockerfile assert "conda" not in dockerfile.lower() assert "tmux" not in entrypoint @@ -25,7 +25,9 @@ def test_docker_runtime_files_use_foreground_uv_and_no_legacy_runtime() -> None: assert "${BLOOM_CONFIG_PATH:?BLOOM_CONFIG_PATH is required}" in entrypoint -def test_container_entry_requires_absolute_config_path(monkeypatch: pytest.MonkeyPatch) -> None: +def test_container_entry_requires_absolute_config_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: monkeypatch.setenv("BLOOM_CONFIG_PATH", "relative.yaml") with pytest.raises(RuntimeError, match="must be an absolute path"): @@ -41,13 +43,9 @@ def test_container_entry_runs_foreground_http_server( monkeypatch.setenv("HOST", "127.0.0.1") monkeypatch.setenv("PORT", "8912") - with ( - patch("bloom_lims.container_entry._initialize_cli_runtime") as init_runtime, - patch("bloom_lims.container_entry._start_server") as start, - ): + with patch("bloom_lims.container_entry._start_server") as start: container_entry.main() - init_runtime.assert_called_once_with(config_path) assert start.call_args.kwargs == { "port": 8912, "host": "127.0.0.1", diff --git a/docs/apis.md b/docs/apis.md index 50cfcb0..1f942d3 100644 --- a/docs/apis.md +++ b/docs/apis.md @@ -287,7 +287,7 @@ curl -k https://localhost:8912/api/v1/external/specimens \ -H "Idempotency-Key: atlas-specimen-001" \ -H "Content-Type: application/json" \ -d '{ - "specimen_template_code": "content/specimen/blood-whole/1.0", + "specimen_template_code": "material/specimen/blood-whole/1.0", "specimen_name": "specimen-demo", "status": "active", "container_template_code": "container/tube/tube-generic-10ml/1.0", @@ -361,6 +361,30 @@ instrument EUID. This is a real mounted surface with substantial tests, but its route naming and placement clearly mark it as beta/integration-specific rather than the most conservative public contract. +### Lab Actions Surface + +`/api/v1/lab-actions` is the current Bloom-owned temporary wet-lab action surface. It uses TapDB generic instances and lineage records and does not drive queue execution. + +The detailed operator/API runbook is [`lab_actions.md`](lab_actions.md). It is the source to update when production rollout steps, template requirements, or action-flow semantics change. + +Current routes: + +- `POST /api/v1/lab-actions/extraction-plates`: create or fill extraction plates from incoming filled tube EUIDs, create gDNA contents, and link tube/content/well/material provenance. +- `POST /api/v1/lab-actions/extraction-qc-plates`: create or fill extraction QC plates from extraction wells and attach QC data records. +- `POST /api/v1/lab-actions/seq-library-plates`: create sequencing-library plates from filled extraction wells or directed mappings. +- `POST /api/v1/lab-actions/seq-library-pools`: create or fill sequencing-library pool tubes from tube, well, content, or existing pool inputs. +- `POST /api/v1/lab-actions/seq-runs`: create sequencing run sets with pool, instrument/operator/reagent metadata, flowcell barcode, platform, and status. +- `POST /api/v1/lab-actions/sets`: create generic lab/run sets for batches, reagents, instruments, operators, or external members. +- `GET /api/v1/lab-actions/sets/{set_euid}`: read the stored set metadata and members. +- `POST /api/v1/lab-actions/sets/{set_euid}/members`: append internal Bloom EUID members or external member identifiers. +- `POST /api/v1/lab-actions/plate-well-data`: attach structured data records to plate wells or well contents. +- `POST /api/v1/lab-actions/spreadsheet-import`: dry-run or execute `.csv`/`.xlsx` uploads for explicit lab-action sheet shapes. +- `GET /api/v1/lab-actions/seq-runs/{set_euid}/samplesheet`: download ILMN sample sheets. Non-ILMN platforms fail explicitly until file formats are specified. +- `GET /api/v1/lab-actions/plates/{plate_euid}/mapping.csv`: export one-degree parent/child plate mapping CSV. +- `POST /api/v1/lab-actions/print-euids`: dispatch EUID barcode print requests through Zebra Day. + +The generic set template for these flows is `set/run-set/generic/1.01`. + ## Tokens And Admin APIs ### User Token Self-Service diff --git a/docs/architecture.md b/docs/architecture.md index 2b6eb9e..cba5dc4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,6 +131,7 @@ The domain layer is where Bloom's narrow ownership becomes concrete: | Module group | Current responsibility | | --- | --- | | `containers.py`, `content.py`, `equipment.py`, `external_specimens.py` | material/object state management | +| `lab_actions.py` | temporary operator actions that create and link extraction plates, sequencing-library plates, pool tubes, sequencing run sets, CSV exports, and barcode print jobs without driving workflow queues | | `execution_queue.py`, `execution_actions.py` | queue-centric runtime behavior | | `beta_lab*.py`, `beta_actions.py` | beta queue/lab flows and staged material operations | | `workflows.py`, `object_sets.py` | older concepts that still exist in code but are not current mounted product surfaces | @@ -168,6 +169,8 @@ Bloom persists state through TapDB-managed PostgreSQL structures and namespaced - lineage edges and containment relationships connect those instances - operator/runtime surfaces project queue state on top of the same core identity graph +The `/lab-actions` surface follows the same persistence model. It composes existing templates and lineage records into operator-friendly extraction, library, pooling, and sequencing-run setup actions. It does not add tables or queue execution semantics. See [`lab_actions.md`](lab_actions.md) for the concrete template matrix and rollout checklist. + That is why Bloom feels like a foundation service rather than a vertical application. It stores the durable material graph that other services and operator workflows can interrogate. ## Health, Observability, And Readiness diff --git a/docs/gui.md b/docs/gui.md index becaaec..e4be089 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -202,6 +202,26 @@ Bloom currently exposes two creation entrypoints: Older workflow/workset-centric creation surfaces are not the current product direction. +### Lab Actions + +`/lab-actions` exposes the temporary wet-lab action wizard for: + +- incoming tube to extraction plate mapping +- extraction plate to extraction QC plate mapping +- extraction plate to sequencing-library plate mapping +- sequencing-library pool tube creation +- sequencing run set setup +- generic lab/run set creation and member management +- plate-well associated data attachment +- spreadsheet upload for `.csv` and `.xlsx` lab-action sheets +- plate CSV export +- ILMN sample-sheet download +- generic EUID barcode print dispatch + +The GUI is backed by `/api/v1/lab-actions/*`. The GUI should remain an operator-friendly wrapper over the API rather than a separate behavior surface. + +The detailed runbook is [`lab_actions.md`](lab_actions.md). It includes the exact template matrix, lineage vocabulary, API examples, and production rollout checklist. + ## Playwright Coverage Current committed browser E2E coverage is deliberately narrow. diff --git a/docs/lab_actions.md b/docs/lab_actions.md new file mode 100644 index 0000000..f3d0031 --- /dev/null +++ b/docs/lab_actions.md @@ -0,0 +1,374 @@ +# Bloom Lab Actions + +## Overview + +Bloom lab actions are a temporary, Bloom-owned operator layer for moving real lab materials through a small number of common wet-lab setup flows: + +1. Incoming biospecimen tubes to an extraction plate. +2. Extraction plate wells to a sequencing-library plate. +3. Sequencing libraries to a sequencing-library pool tube. +4. Pool tube plus instrument/run metadata to a sequencing run set. + +The implementation is intentionally narrow. It uses existing TapDB generic instances, Bloom templates, and lineage records. It does not revive queue or beta-lab workflow-driving behavior, does not add database tables, and does not attempt to schedule or execute work. + +The GUI route is `/lab-actions`. The API route family is `/api/v1/lab-actions/*`. + +## Current Template Matrix + +The current flow uses the existing Bloom template pack plus one new generic run-set template. + +| Purpose | Semantic template path | Instance prefix | Notes | +|---|---|---:|---| +| Generic run/set object | `set/run-set/generic/1.01` | `BGS` | New template. Seeded as `category=set`, `type=run-set`, `subtype=generic`, `version=1.01`. Holds named sets, run metadata, members, external members, status, and arbitrary metadata. | +| Extraction plate | `container/plate/fixed-plate-96/1.0` | `BCP` | Existing recursive 96-well plate template. Creates linked well containers. | +| Sequencing-library plate | `container/plate/sequencing-library-plate-96/1.0` | `BCP` | Existing recursive 96-well plate template. Creates linked well containers. | +| Plate well | `container/well/fixed-plate-well/1.0` | `BCW` | Created by recursive plate templates. | +| Pool tube | `container/tube/tube-generic-10ml/1.0` | `BCT` | Existing generic tube template. More specific pool-tube templates can be added later if needed. | +| Incoming specimen contents | `material/specimen/{blood-whole,buccal-swab,saliva}/1.0` | `BNB`, `BNS`, `BNA` | Existing whole-blood, buccal-swab, and saliva specimen templates. Incoming tubes must already hold one material object unless the caller creates/fills the tube first. | +| Extraction well gDNA | `material/sample/gdna/1.0` | `BNG` | Created during extraction mapping. | +| Sequencing-library content | `material/sample/sequencing-library/1.0` | `BNQ` | Created during library plate mapping. | +| Sequencing-library pool content | `material/pool/sequencing-library/1.0` | `BNP` | Created when filling the pool tube. | +| Sequencing index reagent | `material/reagent/sequencing-index/1.0` | `BNX` | Optional; callers may also store an index barcode string directly. | +| gDNA quant data | `data/quantification/gdna/1.0` | `BDQ` | Optional per-well extraction output data. | + +No additional templates are required for the current implementation. The semantic paths above are the operator-facing shorthand used by the API and docs; the seed file stores Bloom's actual prefix-backed template identity in `category`, `type`, `subtype`, `version`, and `instance_prefix`. If production operators need separate EUID prefixes or stricter metadata for pool tubes, flowcells, reagent sets, or run kits, add those templates as a separate versioned template-pack change and update this matrix before production rollout. + +## Lineage Contract + +Lab actions write explicit Bloom lineage records. The key relationship names are: + +| Relationship | Source | Target | Meaning | +|---|---|---|---| +| `contains` | Plate | Well container | Recursive plate template child containment. | +| `HOLDS_MATERIAL` | Container | Content/material | Container currently holds the material. | +| `DERIVED_FROM` | Parent content/material | Child content/material | New material was derived from the source material. | +| `extraction_source_container` | Incoming tube | Extraction well | Tube was assigned to this extraction well. | +| `library_source_well` | Extraction well | Library well | Source well mapped to destination library well. | +| `uses_index` | Library content | Index reagent | Library uses this sequencing index reagent. | +| `pooled_from_container` | Source container | Pool tube | Pool tube was filled from this source container. | +| `run_set_member` | Run set | Member EUID | Member belongs to the run set. | +| `associated_set` | Member EUID | Run set | Reverse navigability for set membership. | +| `run_uses_pool` | Sequencing run set | Pool tube/content | Run set uses this sequencing pool. | +| `run_uses_instrument` | Sequencing run set | Instrument EUID | Run set uses this sequencer/instrument. | +| `run_uses_reagent` | Sequencing run set | Reagent EUID | Run set uses this reagent. | + +`HOLDS_MATERIAL` and `DERIVED_FROM` also carry Bloom v0 edge metadata through the existing lineage metadata mechanism. + +## API Workflows + +All GUI actions are API-backed. The GUI should remain a convenience wrapper, not a separate behavior surface. + +### 1. Extraction Plate + +Auto mode assigns 1-95 tube EUIDs in row-major well order, starting at `A1`. + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/extraction-plates" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "mode": "auto", + "tube_euids": ["Z-BCT-EXAMPLE1", "Z-BCT-EXAMPLE2"], + "plate_name": "extraction plate", + "create_run_set": true + }' +``` + +Directed mode lets the caller specify destination wells. It can create a new plate or fill an existing plate: + +```json +{ + "mode": "directed", + "plate_euid": "Z-BCP-EXISTING", + "assignments": [ + {"tube_euid": "Z-BCT-EXAMPLE1", "row": "A", "col": 1}, + {"tube_euid": "Z-BCT-EXAMPLE2", "row": "B", "col": 1} + ] +} +``` + +The action creates gDNA content in each destination well, links the tube to the well, and links the incoming tube content to the new gDNA content. + +### 2. Extraction QC Plate + +Extraction QC fills a new or existing QC plate from an extraction plate. If assignments are omitted, Bloom uses every filled source well from the source extraction plate in row-major order. Explicit assignments can name source wells, destination QC wells, result/status, and structured QC data. + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/extraction-qc-plates" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "source_plate_euid": "Z-BCP-EXTRACTION", + "qc_plate_name": "extraction QC plate", + "assignments": [ + { + "source_well_euid": "Z-BCW-SOURCEA1", + "qc_row": "A", + "qc_col": 1, + "result": "pass", + "status": "recorded", + "data": {"concentration_ng_ul": 43.2, "a260_280": 1.83} + } + ], + "create_run_set": true + }' +``` + +The action creates `data/operation/extraction-qc/1.0/` records, links source well/content to QC well/data, and rejects requests above the 96-well plate capacity. + +### 3. Sequencing-Library Plate + +`plate_1_to_1` mode maps every filled extraction well to the same position on a new sequencing-library plate. + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/seq-library-plates" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "mode": "plate_1_to_1", + "source_plate_euid": "Z-BCP-EXTRACTION", + "plate_name": "seq library plate" + }' +``` + +Directed mode accepts explicit input wells and output wells. Optional fields include `index_barcode`, `index_euid`, and arbitrary per-well `data`. + +### 4. Sequencing-Library Pool Tube + +Pools accept input tube EUIDs, well EUIDs, content EUIDs, or existing pool EUIDs. The action creates or fills a pool tube and creates sequencing-library pool content. + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/seq-library-pools" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "input_euids": ["Z-BNQ-LIB1", "Z-BNQ-LIB2"], + "platform": "ILMN", + "pool_name": "seq library" + }' +``` + +Supported platform metadata values are `ILMN`, `ONT`, `Ultima`, `PacBio`, and `CompleteGenomics`. + +### 5. Generic Lab Sets + +Sets use the `set/run-set/generic/1.01` template for lightweight batch/run grouping. They can hold Bloom EUID members, external EUID/string members, operator/instrument/reagent metadata, status, and arbitrary metadata. Set membership is modeled with lineage and stored properties; it does not drive queues or execution. + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/sets" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "extraction reagent set", + "description": "Reagents, machine, and operator for extraction batch", + "members": ["Z-BEQ-INSTRUMENT"], + "external_members": ["lot:REAGENT-2026-06-18"], + "operator": "operator@example.com", + "status": "created", + "metadata": {"bench": "SSF"} + }' +``` + +Members can be appended later: + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/sets/Z-BGS-SET/members" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"members": ["Z-BCT-TUBE"], "external_members": ["vendor-kit:123"]}' +``` + +### 6. Plate-Well Associated Data + +Per-well data can be attached to a well or the well content. This is intentionally generic so operators can capture QC, quantification, or other measurements without introducing a new workflow engine. + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/plate-well-data" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "plate_euid": "Z-BCP-EXTRACTION", + "records": [ + { + "row": "A", + "col": 1, + "data_template_code": "data/quantification/gdna/1.0/", + "name": "A1 gDNA quant", + "target": "content", + "data": {"concentration_ng_ul": 43.2} + } + ] + }' +``` + +### 7. Sequencing Run Set + +Sequencing run setup creates a `set/run-set/generic/1.01` object with pool, operator, instrument, reagent, flowcell, status, and platform metadata. + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/seq-runs" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "pool_tube_euid": "Z-BCT-POOL", + "pool_content_euid": "Z-BNP-POOLCONTENT", + "platform": "ILMN", + "operator": "operator@example.com", + "instrument_euid": "Z-BEQ-SEQUENCER", + "flowcell_barcode": "FLOWCELL123", + "status": "created" + }' +``` + +Status values are `created`, `running`, `abandoned`, `complete`, and `error`. + +### Sample Sheet Download + +ILMN sample-sheet download is implemented: + +```bash +curl -sS "$BLOOM_URL/api/v1/lab-actions/seq-runs/Z-BGS-RUNSET/samplesheet" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -o SampleSheet.csv +``` + +The sample sheet includes the run-set EUID and library mappings so OWY can walk back through: + +`run set -> pool -> library content -> gDNA content -> incoming specimen content` + +Non-ILMN sample-sheet downloads fail explicitly until concrete file formats are specified. There is no fallback manifest. + +### Spreadsheet Uploads + +Spreadsheet uploads are available at: + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/spreadsheet-import?dry_run=true" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -F "file=@lab-actions.xlsx" +``` + +Use `dry_run=false` to execute supported sheets after previewing the parsed operations. The parser accepts `.xlsx` and `.csv`; other extensions fail explicitly. The parser normalizes common operator header typos such as `Continer`, `Conteiner`, and `Templae`. + +Executable sheet names: + +| Sheet | Purpose | Key columns | +|---|---|---| +| `Extraction Plate` | Create or fill extraction plate from incoming tubes. | `tube_euid`, `row`, `col`, `plate_euid`, `plate_name`, `run_set_name`, `print_labels` | +| `Extraction QC` | Fill extraction QC plate from extraction wells. | `source_plate_euid`, `source_well_euid`, `source_row`, `source_col`, `qc_plate_euid`, `qc_plate_name`, `qc_row`, `qc_col`, `result`, `status`, `data` | +| `Seq Library Plate` | Create library plate from extraction wells. | `source_plate_euid`, `source_well_euid`, `source_row`, `source_col`, `output_row`, `output_col`, `index_barcode`, `index_euid`, `data` | +| `Seq Pool` | Create/fill pool tube from wells, tubes, contents, or pools. | `input_euids`, `pool_tube_euid`, `pool_name`, `pool_content_name`, `platform`, `run_set_name`, `print_labels` | +| `Seq Run Set` | Create sequencing run set from a pool and run metadata. | `pool_tube_euid`, `pool_content_euid`, `platform`, `operator`, `instrument_euid`, `flowcell_barcode`, `reagent_euids`, `status`, `metadata` | +| `Sets` | Create generic sets. | `name`, `description`, `members`, `external_members`, `operator`, `instrument`, `reagents`, `machine`, `flowcell_barcode`, `status`, `metadata` | +| `Plate Well Data` | Attach data records to wells or contents. | `plate_euid`, `well_euid`, `row`, `col`, `target`, `data_template_code`, `name`, `data` | + +The example workbook family with `Bulk Create`, `Transfer`, and `Annotation` sheets is parsed and previewed as container-interaction evidence. Those sheets are not blindly executed because they can describe broad generic operations; executable lab-action sheets use the specific names above. + +### Plate Mapping CSV + +Any plate-like container can export a one-degree mapping CSV: + +```bash +curl -sS "$BLOOM_URL/api/v1/lab-actions/plates/Z-BCP-PLATE/mapping.csv" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -o plate_mapping.csv +``` + +Columns: + +- `tube_euid` +- `plate_euid` +- `well_euid` +- `well_row` +- `well_col` +- `well_content_euid` +- `parent_content_euid` +- `one_degree_parent_euids` +- `one_degree_child_euids` + +### EUID Barcode Printing + +Printing goes through Zebra Day. Tests mock this integration; real printer output requires explicit operator approval. + +```bash +curl -sS -X POST "$BLOOM_URL/api/v1/lab-actions/print-euids" \ + -H "Authorization: Bearer $BLOOM_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "euids": ["Z-BCP-PLATE", "Z-BCT-POOL"], + "lab": "SSF", + "printer_id": "printer-id", + "label_zpl_style": "euid", + "copies": 1 + }' +``` + +## Object Create Count + +Both object-creation APIs support `count` for creating multiple sibling objects from the same non-recursive, non-singleton template. + +Recursive templates, such as 96-well plates, reject `count > 1` because a single plate already expands into many child objects. Create recursive containers one at a time. + +## GUI Flow + +Open `/lab-actions`. + +The wizard has these panels: + +1. Extraction Plate. +2. Extraction QC Plate. +3. Sequencing-Library Plate. +4. Sequencing Pool Tube. +5. Sequencing Run Set. +6. EUID Barcode Printing. +7. Generic Set Management. +8. Plate-Well Data. +9. Spreadsheet Upload. + +Successful actions populate downstream fields where possible, for example extraction plate EUID into the QC and library plate steps and pool tube/content EUIDs into the run-set step. + +## Production Rollout Checklist + +Use this checklist when updating production later. + +1. Confirm Bloom source is committed on the intended release branch. +2. Confirm the template pack includes `set/run-set/generic/1.01`. +3. Run focused local validation: + + ```bash + source ./activate + ruff check bloom_lims/api/v1/__init__.py bloom_lims/api/v1/lab_actions.py bloom_lims/api/v1/object_creation.py bloom_lims/api/v1/objects.py bloom_lims/domain/lab_actions.py bloom_lims/schemas/lab_actions.py bloom_lims/schemas/objects.py bloom_lims/gui/routes/modern.py tests/test_lab_actions_api.py tests/test_bloom_prefix_taxonomy.py + python -m pytest tests/test_lab_actions_api.py tests/test_bloom_prefix_taxonomy.py::test_wet_lab_templates_use_bloom_prefix_taxonomy tests/test_route_coverage_gaps_api.py::test_object_creation_plate_creates_96_linked_wells tests/test_template_instantiation_recursion.py -q --no-cov + python -m pytest tests/test_lab_actions_api.py tests/test_bloom_prefix_taxonomy.py::test_wet_lab_templates_use_bloom_prefix_taxonomy tests/test_route_coverage_gaps_api.py::test_object_creation_plate_creates_96_linked_wells tests/test_template_instantiation_recursion.py tests/test_api_v1.py::TestObjectCreationAPI tests/test_api_v1.py::TestObjectCreationPathTraversal -q --cov-reset --cov=bloom_lims.api.v1.lab_actions --cov=bloom_lims.domain.lab_actions --cov=bloom_lims.domain.lab_action_spreadsheets --cov=bloom_lims.schemas.lab_actions --cov=bloom_lims.api.v1.object_creation --cov=bloom_lims.schemas.objects --cov-report=term-missing --cov-report=json:coverage-lab-actions.json --cov-fail-under=81 + ``` + +4. Release Bloom with an annotated semver tag. +5. Update Dayhoff `services/pins.toml` to the new Bloom tag. +6. Double-release Dayhoff if the normal Dayhoff self-pin release process is in scope. +7. Refresh templates on the target deployment through the supported Bloom/Dayhoff path; do not manually insert templates in PostgreSQL. +8. Restart only the Bloom container first for WIP proof. +9. Verify: + - `/healthz` + - `/readyz` + - `/lab-actions` + - `/api/v1/lab-actions/extraction-plates` validation behavior + - 96-well plate recursive creation + - ILMN sample-sheet download + - non-ILMN explicit rejection + - plate mapping CSV + - mocked or approved printer path +10. Only after Bloom WIP proof passes, decide whether to refresh the broader Dayhoff deployment. + +Do not run real print jobs or create production lab objects during deployment validation unless a human operator explicitly approves the exact test objects and printer. + +## Current Evidence + +The controlling ledger is: + +`docs/plans/20260618T000000Z_bloom_lab_action_flow_ledger.md` + +Local screenshot evidence from the implementation pass: + +`output/playwright/bloom_lab_actions/lab_actions_wizard_local.png` + +The screenshot is local proof only. A production or `jemdev5` deployment still needs live browser evidence after the service is refreshed. diff --git a/docs/old_docs/ATLAS_BLOOM_CONTRACT_TESTS.md b/docs/old_docs/ATLAS_BLOOM_CONTRACT_TESTS.md index 790482e..c77dc64 100644 --- a/docs/old_docs/ATLAS_BLOOM_CONTRACT_TESTS.md +++ b/docs/old_docs/ATLAS_BLOOM_CONTRACT_TESTS.md @@ -36,7 +36,7 @@ def test_create_container_contract(client): ```python def test_specimen_crud_contract(client, container_euid): create_payload = { - "specimen_template_code": "content/specimen/blood-whole/1.0", + "specimen_template_code": "material/specimen/blood-whole/1.0", "container_euid": container_euid, "atlas_refs": {"trf_euid": "TRF-1", "patient_id": "PAT-1", "kit_barcode": "KIT-1"}, "properties": {"source": "atlas"}, diff --git a/docs/old_docs/TAPDB_SPECIFICATION.md b/docs/old_docs/TAPDB_SPECIFICATION.md index b796a6d..4901180 100644 --- a/docs/old_docs/TAPDB_SPECIFICATION.md +++ b/docs/old_docs/TAPDB_SPECIFICATION.md @@ -998,7 +998,7 @@ Templates are identified by a **template code string** with the format: **Examples:** - `container/plate/fixed-plate-96/1.0/` - `workflow/assay/ngs-library-prep/2.1/` -- `content/sample/blood-specimen/1.0/` +- `material/sample/blood-specimen/1.0/` - `equipment/instrument/sequencer/1.0/` ### 4.2 Template JSON Structure diff --git a/docs/plans/20260520T064952Z_bloom_sequencing_run_ledger.md b/docs/plans/20260520T064952Z_bloom_sequencing_run_ledger.md index e7be661..9cb8007 100644 --- a/docs/plans/20260520T064952Z_bloom_sequencing_run_ledger.md +++ b/docs/plans/20260520T064952Z_bloom_sequencing_run_ledger.md @@ -15,7 +15,7 @@ Add a first-class Bloom sequencing run concept for the Dayhoff-deployed Bloom se | Repo state | `git status --short --branch -> ## main...origin/main` | | Instructions read | `/Users/jmajor/projects/mega_dayhoff/repos_work/bloom/AGENTS.md`, `/Users/jmajor/projects/mega_dayhoff/dayhoff/AGENTS.md`, `/Users/jmajor/.agents/AGENTS.md`, `/Users/jmajor/.agents/AGENTS.md~`, `/Users/jmajor/.codex/docs/plan-ledger-workflow.md`, `/Users/jmajor/projects/mega_dayhoff/repos_work/bloom/.augment/RULES.md` | | Existing pool/run surface | `bloom_lims/api/v1/beta_lab.py` already exposes `POST /api/v1/external/atlas/beta/pools`, `POST /api/v1/external/atlas/beta/runs`, and `GET /api/v1/external/atlas/beta/runs/{run_euid}/resolve` | -| Existing pool implementation | `bloom_lims/domain/beta_lab_stages.py::create_pool` already creates `content/pool/generic/1.0`, a pool tube, `contains` lineage, and `beta_pool_member` edges | +| Existing pool implementation | `bloom_lims/domain/beta_lab_stages.py::create_pool` already creates `material/pool/generic/1.0`, a pool tube, `contains` lineage, and `beta_pool_member` edges | | Existing run gap | `create_run` stores runs as `data/generic/generic/1.0` with `beta_kind=sequencing_run`; `BetaRunCreateRequest.platform` is `Literal["ILMN"]`; run properties do not have explicit `operator_start_datetime`, `sequencing_end_datetime`, `instrument_euid`, or run subtype | | Template gap | `config/tapdb_templates/bloom/templates.json` has `data/wetlab/library_prep_output/1.0` and equipment sequencer templates, but no `data/sequencing_run/*/1.0` templates | | Atlas boundary | Bloom `.augment/RULES.md` requires Bloom and Atlas to interact only through published APIs; this change will preserve existing external-reference lineage and not read/write Atlas DBs | @@ -29,7 +29,7 @@ Add a first-class Bloom sequencing run concept for the Dayhoff-deployed Bloom se | BSR-001 | Template pack | Add first-class sequencing run templates with subtypes for Illumina, ONT, and NovaSeq. | SUCCESS | feature_implementation | Gate 3 | orchestrator | `config/tapdb_templates/bloom/templates.json`; live seed verified `BDT/sequencing_run/{illumina,novaseq,ont}/1.0` with EUIDs `Z-TPX-50P`, `Z-TPX-52J`, `Z-TPX-51M` | Missing template-backed sequencing-run data records; runs were generic data rows. | Added and seeded three Bloom-owned sequencing run templates. | | BSR-002 | API schema | Extend beta run create/response schema with run subtype, operator start datetime, sequencing end datetime, and instrument linkage while preserving explicit validation. | SUCCESS | feature_implementation | Gate 3 | orchestrator | `bloom_lims/schemas/beta_lab.py`; public `https://bloom.dev.lsmc.life/openapi.json` exposes `platform` enum `ILMN,ONT`, `run_subtype`, `operator_start_datetime`, `sequencing_end_datetime`, and `instrument_euid` | Existing beta run schema only modeled ILMN flowcell runs. | Schema accepts Illumina, ONT, NovaSeq subtype contract and rejects mismatched platform/subtype and naive/out-of-order datetimes. | | BSR-003 | Domain persistence | Store sequencing runs using the subtype-specific sequencing run template, link optional instrument via existing `beta_used_instrument`, and preserve pool/library/assignment lineage. | SUCCESS | feature_implementation | Gate 3 | orchestrator | `bloom_lims/domain/beta_lab.py`, `bloom_lims/domain/beta_lab_store.py`, `bloom_lims/domain/beta_lab_stages.py`; local DB-backed `tests/test_queue_flow.py::test_beta_queue_flow_end_to_end` verifies run instance `type=sequencing_run`, `subtype=novaseq`, persisted timing fields, and `beta_used_instrument` lineage | Persistence always created `data/generic/generic/1.0` rows for sequencing runs. | Run creation now chooses `data/sequencing_run/{illumina,ont,novaseq}/1.0` and still keeps pool, library assignment, artifact, and Atlas fulfillment lineage. | -| BSR-004 | Pool assessment | Verify whether pooled sequencing library already exists and avoid adding a duplicate concept if current `content/pool/generic/1.0` plus pool tube/member lineage satisfies the request. | SUCCESS | feature_implementation | Gate 3 | orchestrator | Existing `create_pool` creates `content/pool/generic/1.0`, pool tube, `contains`, and `beta_pool_member` edges; `tests/test_queue_flow.py::test_beta_queue_flow_end_to_end` exercises that pool before creating the run | Pool concept was uncertain, but not absent. | No duplicate pool model added; existing pool content plus container/member lineage satisfies the request. | +| BSR-004 | Pool assessment | Verify whether pooled sequencing library already exists and avoid adding a duplicate concept if current `material/pool/generic/1.0` plus pool tube/member lineage satisfies the request. | SUCCESS | feature_implementation | Gate 3 | orchestrator | Existing `create_pool` creates `material/pool/generic/1.0`, pool tube, `contains`, and `beta_pool_member` edges; `tests/test_queue_flow.py::test_beta_queue_flow_end_to_end` exercises that pool before creating the run | Pool concept was uncertain, but not absent. | No duplicate pool model added; existing pool content plus container/member lineage satisfies the request. | | BSR-005 | Tests | Add focused tests for sequencing run templates, schema validation, persisted run fields, instrument linkage, and existing pool lineage. | SUCCESS | contract_test | Gate 5 | orchestrator | `pytest tests/test_sequencing_run_contract.py tests/test_queue_flow.py::test_beta_queue_flow_end_to_end tests/test_run_resolver.py::test_run_resolver_returns_404_for_unknown_index -q --no-cov` -> `4 passed`; `jq empty config/tapdb_templates/bloom/templates.json`; `git diff --check` | Contract needed coverage for template loadability and first-class run persistence. | Focused local tests pass. | | BSR-006 | Live deploy | Patch AWS-deployed Bloom, seed any new Bloom-owned templates, restart only Bloom, and verify public/auth/readiness behavior. | SUCCESS | contract_test | Gate 5 | orchestrator | Live target `i-09126000eb19643b0`, repo `/home/ubuntu/.cache/dayhoff/local/lsmcok1/repos/bloom`, config `/home/ubuntu/.config/bloom-lsmcok1/bloom-config-lsmcok1.yaml`; `bloom --config ... db seed` -> `seed=ok`; old Bloom PID `238229`, new Bloom PID `264810`; `https://bloom.dev.lsmc.life/readyz` -> `ready=true`, DB `status=ok` | AWS Bloom needed the same template/schema/domain patch and live TapDB seed. | AWS Bloom patched, templates seeded, and only Bloom restarted. | diff --git a/docs/plans/20260604T110700Z_access_logging_ai_agent_gap.md b/docs/plans/20260604T110700Z_access_logging_ai_agent_gap.md new file mode 100644 index 0000000..90bf6b4 --- /dev/null +++ b/docs/plans/20260604T110700Z_access_logging_ai_agent_gap.md @@ -0,0 +1,34 @@ +# Bloom Access Logging And AI-Agent Read Access Gap + +Created: 2026-06-04T11:07:00Z + +## Status + +SUPERSEDED for local `jem-dev` source by commit `2e53e0c` and release tag +`6.0.2`. Bloom now has the local source pieces for Kahlo-issued read-only +AI-agent validation, broker-backed theme preferences, and common access logging. + +Remaining acceptance moved to the Dayhoff final beta ledger: +`/Users/jmajor/projects/mega_dayhoff/dayhoff/docs/plans/20260606T080000Z_final_beta_release_consolidation_ledger.md`. +That acceptance requires the future `jemdev` deployment and must not use +production `day` services. + +## Required Contract + +- Validate Kahlo-issued AI-agent bearer tokens against an explicit Dayhoff-generated allowlist. +- Accept only read-only endpoint IDs approved for Bloom search/detail APIs. +- Record every endpoint access with request ID, correlation ID, route template, status, duration, client IP, auth mode, human user, service ID, AI-agent ID, authorizing human, token ID prefix/hash, scopes, and denial reason. +- Never log bearer tokens, cookies, raw presigned URLs, PHI-bearing query strings, object keys, or raw request/response bodies. + +## Historical Gap + +At creation time, source did not prove uniform all-endpoint structured access +logs with actor/IP/AI-agent provenance, and it did not validate Kahlo-issued +AI-agent tokens on Bloom search/read APIs. That is no longer the current local +`jem-dev` source state. + +## Acceptance + +- Focused tests prove allowed AI-agent tokens can read only approved Bloom search/detail endpoints. +- Mutating endpoints and non-allowlisted reads reject AI-agent tokens. +- Access-log tests prove actor/IP/request/token provenance is present and sensitive values are redacted. diff --git a/docs/plans/20260618T000000Z_bloom_lab_action_flow_ledger.md b/docs/plans/20260618T000000Z_bloom_lab_action_flow_ledger.md new file mode 100644 index 0000000..b9d5ade --- /dev/null +++ b/docs/plans/20260618T000000Z_bloom_lab_action_flow_ledger.md @@ -0,0 +1,116 @@ +# Bloom Lab Action Flow Ledger + +Date: 2026-06-18 + +## Summary + +Build a Bloom-owned lab actions layer for extraction plates, sequencing library plates, sequencing library pool tubes, sequencing run sets, plate mapping CSV export, object-count create, and EUID barcode print dispatch. The work stays in Bloom on `jem-dev` and uses existing TapDB generic instances plus lineage records. It does not revive queue or workflow-driving behavior. + +## Gate 0 Inventory + +- Repo: `/Users/jmajor/projects/mega_dayhoff/repos_work/bloom` +- Branch: `jem-dev` +- Boundaries: no production `day`, no `us-west-2`, no destructive DB repair, no real printer calls in tests. +- Existing dirty files before this ledger included Bloom auth/UI/theme files plus recursive template-instantiation changes already present in the worktree. +- Existing primitives found: + - recursive template create in `BloomObj.create_instances` + - `container/plate/fixed-plate-96/1.0`, `container/plate/sequencing-library-plate-96/1.0`, `container/well/fixed-plate-well/1.0` + - `material/sample/gdna/1.0`, `material/sample/sequencing-library/1.0`, `material/pool/sequencing-library/1.0`, `material/reagent/sequencing-index/1.0` + - `data/quantification/gdna/1.0`, `data/sequencing_run/*/1.0` + - Zebra Day service wrapper for print dispatch + +## Ledger Rows + +| ID | Agent | Requirement | Status | Evidence | +|---|---|---|---|---| +| LEDGER-001 | Agent 1 Orchestrator | Create ledger and record baseline state. | COMPLETE | This file records Gate 0 boundaries and evidence. | +| TEMPLATE-001 | Agent 2 Templates | Add `set/run-set/generic/1.01`; update taxonomy tests/docs. | COMPLETE | Added template in `config/tapdb_templates/bloom/templates.json`; updated `README.md`, `docs/apis.md`, `docs/gui.md`, `docs/architecture.md`, and `docs/lab_actions.md`; `test_wet_lab_templates_use_bloom_prefix_taxonomy` passes. Template inventory found no additional templates required for this pass. | +| CORE-001 | Agent 3 Domain Core | Add lab-actions domain service over TapDB objects/lineage, no queue driving. | COMPLETE | Added `bloom_lims/domain/lab_actions.py`; no queue/betalab runtime calls. | +| LINEAGE-001 | Agent 3 Domain Core | Standardize requested relationship types. | COMPLETE | Domain service writes `contains`, `HOLDS_MATERIAL`, `DERIVED_FROM`, `run_set_member`, `associated_set`, `uses_index`, `run_uses_pool`, `run_uses_instrument`, and `run_uses_reagent`. | +| EXTRACT-001 | Agent 4 Extraction | Auto/directed extraction plate creation/fill with gDNA and optional quant/set. | COMPLETE | API tests cover auto extraction, directed extraction into an existing plate, quant data creation, run-set creation, duplicate tube rejection, duplicate destination-well rejection, and filled-well reuse rejection. | +| LIB-001 | Agent 5 Library Plate | 1:1/directed sequencing-library plate creation with index linkage. | COMPLETE | API tests cover 1:1 library plate creation plus directed library mapping with index barcode, index EUID, run-set creation, and duplicate destination-well rejection. | +| POOL-001 | Agent 6 Pooling | Sequencing-library pool tube/content creation from containers/wells/contents/pools. | COMPLETE | API tests cover pool creation from library content, existing pool-tube fill, pool-from-pool via a filled pool tube, duplicate input rejection, and sequencing-run links to instrument/reagent EUIDs. | +| RUN-001 | Agent 7 Seq Run | Sequencing run set plus ILMN sample sheet and explicit non-ILMN rejection. | COMPLETE | API test creates seq run set, downloads ILMN sample sheet, and verifies ONT sample-sheet request returns 400. | +| CSV-001 | Agent 8 Export | Plate mapping CSV with one-degree parent/child mappings. | COMPLETE | API test downloads `plates/{plate_euid}/mapping.csv` and verifies requested columns and source tube EUID. | +| PRINT-001 | Agent 8 Print | Generic EUID print API/UI using Zebra Day; tests mock printing. | COMPLETE | Added `/api/v1/lab-actions/print-euids`; API test mocks `ZebraDayService.submit_print_job` and verifies one call per EUID. | +| GUI-001 | Agent 9 GUI | `/lab-actions` GUI wizard for the complete flow. | COMPLETE | Added `/lab-actions` route and `templates/modern/lab_actions.html`; TestClient GUI smoke verifies all four flow sections render; local browser screenshot exists. | +| QA-001 | Agent 10 QA | API/unit tests for action modes, safeguards, CSV, sample sheet, print mock. | COMPLETE | `python -m pytest tests/test_lab_actions_api.py -q --no-cov` -> 9 passed; focused coverage gate over changed lab-action/object-create surface -> 83.81%, above the requested 81% gate. | +| PW-001 | Agent 11 Playwright | GUI Playwright/screenshots. | COMPLETE | Local Playwright proof captured at `output/playwright/bloom_lab_actions/lab_actions_wizard_local.png`; page reached `/lab-actions` with status 200. Live `jemdev5` proof is tracked as a release/deploy concern, not a source/test blocker. | +| REL-001 | Agent 12 Release | Commit/tag/release later after WIP proof. | COMPLETE | User approved the release train. Bloom release target is `7.0.16`; downstream Dayhoff release evidence is tracked in `dayhoff/docs/plans/20260618T131619Z_bloom_lab_actions_release_train_ledger.md`. No live `jemdev5` refresh is included in this row. | + +## Acceptance Notes + +- All rows are terminal. User approved the explicit release train after local implementation evidence passed; `REL-001` records the branch/tag/pin train and remains separate from any live `jemdev5` refresh. +- ILMN sample sheet is implemented first. +- Other platform sample-sheet downloads must fail explicitly without fallback. +- Real printer output requires separate explicit approval. +- Template inventory found the current flow is covered by `set/run-set/generic/1.01`, the existing recursive 96-well plate templates, fixed-plate well template, generic tube template, gDNA sample, sequencing-library sample, sequencing-library pool, sequencing-index reagent, and gDNA quant templates. No extra templates are needed for this implementation pass. +- Production rollout details are now documented in `docs/lab_actions.md`. + +## Current Validation + +- `git diff --check` -> passed. +- `source ./activate dev && ruff check bloom_lims/api/v1/__init__.py bloom_lims/api/v1/lab_actions.py bloom_lims/api/v1/object_creation.py bloom_lims/api/v1/objects.py bloom_lims/domain/lab_actions.py bloom_lims/schemas/lab_actions.py bloom_lims/schemas/objects.py bloom_lims/gui/routes/modern.py tests/test_lab_actions_api.py tests/test_bloom_prefix_taxonomy.py` -> passed. +- `source ./activate dev && ruff check bloom_lims/api/v1/lab_actions.py bloom_lims/domain/lab_actions.py bloom_lims/schemas/lab_actions.py tests/test_lab_actions_api.py` -> passed after the docs pass. +- `source ./activate dev && python -m pytest tests/test_lab_actions_api.py -q --no-cov` -> 9 passed. +- `source ./activate dev && python -m pytest tests/test_lab_actions_api.py tests/test_bloom_prefix_taxonomy.py::test_wet_lab_templates_use_bloom_prefix_taxonomy tests/test_route_coverage_gaps_api.py::test_object_creation_plate_creates_96_linked_wells tests/test_template_instantiation_recursion.py -q --no-cov` -> 14 passed. +- `source ./activate dev && python -m pytest tests/test_lab_actions_api.py tests/test_bloom_prefix_taxonomy.py::test_wet_lab_templates_use_bloom_prefix_taxonomy tests/test_route_coverage_gaps_api.py::test_object_creation_plate_creates_96_linked_wells tests/test_template_instantiation_recursion.py tests/test_api_v1.py::TestObjectCreationAPI tests/test_api_v1.py::TestObjectCreationPathTraversal -q --cov-reset --cov=bloom_lims.api.v1.lab_actions --cov=bloom_lims.domain.lab_actions --cov=bloom_lims.schemas.lab_actions --cov=bloom_lims.api.v1.object_creation --cov=bloom_lims.schemas.objects --cov-report=term-missing --cov-report=json:coverage-lab-actions.json --cov-fail-under=81` -> 30 passed; total changed-surface coverage 83.81%. +- Full repo test collection is not clean in the local Bloom env: `tests/test_beta_cross_repo_smoke.py` imports Ursa and requires `daylily-ephemeral-cluster`; broad non-E2E collection also stalled before progress in local subprocess handling. These are not lab-action source failures. +- Template inventory from `config/tapdb_templates/bloom/templates.json` confirmed these seeded records for the lab-action flow: + - `set/run-set/generic/1.01` + - `container/plate/fixed-plate-96/1.0` + - `container/plate/sequencing-library-plate-96/1.0` + - `container/well/fixed-plate-well/1.0` + - `container/tube/tube-generic-10ml/1.0` + - `material/specimen/blood-whole/1.0` + - `material/specimen/buccal-swab/1.0` + - `material/specimen/saliva/1.0` + - `material/sample/gdna/1.0` + - `material/sample/sequencing-library/1.0` + - `material/pool/sequencing-library/1.0` + - `material/reagent/sequencing-index/1.0` + - `data/quantification/gdna/1.0` +- Local browser proof used repo test-runtime config plus `BLOOM_OAUTH=no`; Playwright captured `/lab-actions` at `output/playwright/bloom_lab_actions/lab_actions_wizard_local.png`. +- Local browser console still shows the pre-existing `/api/v1/me/preferences` 503 under test-runtime config; `/lab-actions` itself returned 200 and static assets loaded. +- Docs pass added `docs/lab_actions.md` and linked it from `README.md`, `docs/apis.md`, `docs/gui.md`, and `docs/architecture.md`. + +## Remaining Boundaries + +- Local Playwright screenshot captured: `output/playwright/bloom_lab_actions/lab_actions_wizard_local.png`. +- No live `jemdev5` Playwright screenshot was captured in this pass; live proof belongs to the deferred release/deploy train. +- No real Zebra Day print job was sent. +- Bloom release, Dayhoff pin update, and Dayhoff double-release are tracked in `dayhoff/docs/plans/20260618T131619Z_bloom_lab_actions_release_train_ledger.md`. +- No live `jemdev5` refresh was performed in this pass. + +## 2026-06-19 Live `jemdev5` Proof + +The lab-action flow was released as Bloom `7.0.19`, pinned by Dayhoff `7.0.74`, +self-pinned by Dayhoff `7.0.75`, and exercised in the live `jemdev5` +greenfield hybrid GUI E2E run. + +| Surface | Live evidence | +|---|---| +| Deploy verification | `/Users/jmajor/projects/mega_dayhoff/dayhoff/output/jemdev5_verify_7_0_75_final/deploy-verify.md` | +| GUI E2E screenshot pack | `/Users/jmajor/projects/mega_dayhoff/dayhoff/output/playwright/hybrid_order_to_delivery/hybrid_order_to_delivery/codex-hybrid-e2e-20260619T002814Z/` | +| Tracked evidence guide | `/Users/jmajor/projects/mega_dayhoff/dayhoff/docs/evidence/hybrid_order_to_delivery_gui_e2e.md` | +| Pytest result | `1 passed in 149.11s` | + +Live Bloom lab-action EUIDs from the passing run: + +| Role | EUID | +|---|---| +| Patient biospecimen tube | `Z-BCT-2AM` | +| Extraction plate | `Z-BCP-19Z` | +| Extraction well | `Z-BCW-3EN3` | +| Extraction gDNA content | `Z-BNG-FX` | +| ILMN prep set | `Z-BGS-15X` | +| ONT prep set | `Z-BGS-16V` | +| ILMN library plate | `Z-BCP-1AX` | +| ONT library plate | `Z-BCP-1BV` | +| ILMN pool tube | `Z-BCT-2BJ` | +| ONT pool tube | `Z-BCT-2CG` | +| ILMN seq-run set | `Z-BGS-17S` | +| ONT seq-run set | `Z-BGS-18Q` | + +All Bloom lab-action rows are source-tested, release-pinned, deployed to +`jemdev5`, and live-proven. Final status: `100%`. diff --git a/docs/plans/20260618T190000Z_bloom_lab_action_spreadsheet_upload_ledger.md b/docs/plans/20260618T190000Z_bloom_lab_action_spreadsheet_upload_ledger.md new file mode 100644 index 0000000..a3dd194 --- /dev/null +++ b/docs/plans/20260618T190000Z_bloom_lab_action_spreadsheet_upload_ledger.md @@ -0,0 +1,75 @@ +# Bloom Lab Actions Spreadsheet Upload Ledger + +Date: 2026-06-18 + +## Summary + +Extend the existing Bloom lab-action layer so extraction QC plate fill, sequencing-library plate generation, plate-well associated data, pool creation from wells/tubes/contents/pools, generic set management, and sequencing run-set creation can be driven through API, GUI, and spreadsheet uploads. + +The attached workbook `Container to Container Interactions (1).xlsx` is a schema example. It has `Bulk Create`, `Transfer`, and `Annotation` sheets with source/target container, child container, content-template, and annotation/data-template columns. This pass supports that family of spreadsheet layout as a parsed/previewed container-interaction workbook and adds executable lab-action sheet shapes for the Bloom wet-lab flow. + +## Boundaries + +- Work stays in Bloom on `jem-dev`. +- No production `day`, no `us-west-2`, no live deploy, no destructive DB repair. +- Use existing TapDB generic instances and lineage records. +- Do not revive queue or betalab workflow-driving behavior. Existing queue/betalab code may remain until replaced. +- No fallback config discovery. Runtime config remains explicit. + +## Gate 0 Inventory + +| Surface | State | +|---|---| +| Branch | `jem-dev` | +| Pre-existing dirty files | Untracked `coverage-lab-actions.json`, untracked `output/` | +| Existing API | `/api/v1/lab-actions/extraction-plates`, `/seq-library-plates`, `/seq-library-pools`, `/seq-runs`, `/plates/{plate_euid}/mapping.csv`, `/print-euids` | +| Existing GUI | `/lab-actions` wizard | +| Existing templates | `set/run-set/generic/1.01`, recursive 96-well plate templates, gDNA, sequencing-library content, sequencing-library pool, sequencing-index, gDNA quant | +| Spreadsheet example | Sheets: `Bulk Create`, `Transfer`, `Annotation`; headers on row 4 | + +## Workstreams + +| ID | Requirement | Status | Evidence | +|---|---|---|---| +| LEDGER-001 | Create ledger and record attached spreadsheet/current source baseline. | COMPLETE | This ledger records the workbook sheets, Bloom branch, pre-existing dirt, current lab-action API, GUI, and template baseline. | +| XLSX-001 | Add dependency-free CSV/XLSX workbook parser for lab-action uploads. | COMPLETE | `bloom_lims/domain/lab_action_spreadsheets.py`; parser accepts `.csv` and `.xlsx`, normalizes operator header typos, and previews the attached workbook family. | +| QC-001 | Add extraction QC plate fill API/domain support. | COMPLETE | `POST /api/v1/lab-actions/extraction-qc-plates`; `LabActionsService.fill_extraction_qc_plate`; tests cover explicit QC well fill and 96-assignment capacity guard. | +| DATA-001 | Add associated data-to-plate-well API/domain support. | COMPLETE | `POST /api/v1/lab-actions/plate-well-data`; supports well or well-content targets with structured data payloads. | +| SET-001 | Add generic set create/read/member-management API/domain support. | COMPLETE | `POST /sets`, `GET /sets/{set_euid}`, and `POST /sets/{set_euid}/members`; uses existing `set/run-set/generic/1.01` template and lineage. | +| IMPORT-001 | Add spreadsheet upload endpoint that can dry-run/execute explicit lab-action sheets and preview container-interaction sheets. | COMPLETE | `POST /api/v1/lab-actions/spreadsheet-import`; tests execute extraction QC, seq-library plate, seq-pool, seq-run set, set, and plate-well-data CSV imports, and preview XLSX `Transfer`. | +| GUI-001 | Expose QC, data, set, and spreadsheet-upload controls on `/lab-actions`. | COMPLETE | `templates/modern/lab_actions.html`; GUI smoke test confirms Extraction QC, Spreadsheet Upload, Manage Sets, and upload action controls render. | +| DOC-001 | Update docs with sheet schemas, API routes, and production rollout guidance. | COMPLETE | `README.md`, `docs/apis.md`, `docs/gui.md`, and `docs/lab_actions.md` updated with endpoints, sheet schemas, GUI panels, and rollout tests. | +| TEST-001 | Add focused parser/API/GUI tests and run validation. | COMPLETE | `ruff check ...` passed; `source ./activate dev && python -m pytest tests/test_lab_actions_api.py -q --no-cov` -> 13 passed; changed-surface coverage command -> 34 passed, total coverage 83.16% >= 81%. | +| REPORT-001 | Final report with test output, changed files, and commit status. | COMPLETE | This ledger plus final chat report. Generated coverage/output artifacts are not committed unless promoted separately. | + +## Spreadsheet Sheet Shapes + +Executable lab-action uploads use these sheet names: + +- `Extraction Plate` +- `Extraction QC` +- `Seq Library Plate` +- `Seq Pool` +- `Seq Run Set` +- `Sets` +- `Plate Well Data` + +Container-interaction workbook sheets from the attached example are parsed for preview/evidence: + +- `Bulk Create` +- `Transfer` +- `Annotation` + +## Acceptance + +- API tests prove QC plate fill, plate-well data, set management, pool from plate well/tube, and spreadsheet import. +- GUI smoke test proves the upload/QC/set controls render on `/lab-actions`. +- Docs explain the spreadsheet columns and current production rollout boundary. +- No queue-driving logic is added. + +## Validation + +- `python -m py_compile bloom_lims/domain/lab_action_spreadsheets.py bloom_lims/domain/lab_actions.py bloom_lims/api/v1/lab_actions.py bloom_lims/schemas/lab_actions.py` -> passed. +- `ruff check bloom_lims/domain/lab_action_spreadsheets.py bloom_lims/domain/lab_actions.py bloom_lims/api/v1/lab_actions.py bloom_lims/schemas/lab_actions.py tests/test_lab_actions_api.py` -> passed. +- `source ./activate dev && python -m pytest tests/test_lab_actions_api.py -q --no-cov` -> 13 passed. +- `source ./activate dev && python -m pytest tests/test_lab_actions_api.py tests/test_bloom_prefix_taxonomy.py::test_wet_lab_templates_use_bloom_prefix_taxonomy tests/test_route_coverage_gaps_api.py::test_object_creation_plate_creates_96_linked_wells tests/test_template_instantiation_recursion.py tests/test_api_v1.py::TestObjectCreationAPI tests/test_api_v1.py::TestObjectCreationPathTraversal -q --cov-reset --cov=bloom_lims.api.v1.lab_actions --cov=bloom_lims.domain.lab_actions --cov=bloom_lims.domain.lab_action_spreadsheets --cov=bloom_lims.schemas.lab_actions --cov=bloom_lims.api.v1.object_creation --cov=bloom_lims.schemas.objects --cov-report=term-missing --cov-report=json:coverage-lab-actions.json --cov-fail-under=81` -> 34 passed, total coverage 83.16%. diff --git a/pyproject.toml b/pyproject.toml index 70d2f1a..cb87f2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,9 @@ license = {text = "MIT"} requires-python = ">=3.12" dependencies = [ "cli-core-yo==2.1.1", - "daylily-tapdb==7.0.9", + "daylily-tapdb @ git+https://github.com/Daylily-Informatics/daylily-tapdb.git@9.0.10", "daylily-auth-cognito==2.1.5", + "defusedxml>=0.7.1", "IPython>=8.18.1", "bandit[toml]>=1.8.0", "black>=24.0.0", @@ -91,7 +92,7 @@ version_file = "bloom_lims/_scm_version.py" [tool.setuptools.packages.find] where = ["."] -include = ["bloom_lims*"] +include = ["auth*", "bloom_lims*"] [tool.setuptools.package-data] bloom_lims = ["etc/*.yaml", "etc/*.json"] diff --git a/static/js/graph.js b/static/js/graph.js index 9ebbb86..b6eae15 100644 --- a/static/js/graph.js +++ b/static/js/graph.js @@ -27,6 +27,7 @@ const keyState = { c: false, n: false, i: false, + z: false, }; const DEFAULT_CONTROL_STATE = { @@ -160,13 +161,13 @@ const cytoscapeStyle = [ "width": 2, "line-color": "#666", "curve-style": "bezier", - "source-arrow-shape": "none", - "target-arrow-shape": "triangle", - "target-arrow-fill": "filled", - "target-arrow-color": "#666", + "source-arrow-shape": "triangle", + "source-arrow-fill": "filled", + "source-arrow-color": "#666", + "target-arrow-shape": "none", "source-endpoint": "outside-to-node", "target-endpoint": "outside-to-node", - "target-distance-from-node": 6, + "source-distance-from-node": 6, "arrow-scale": 1.6, "opacity": 1, }, @@ -176,7 +177,7 @@ const cytoscapeStyle = [ style: { "line-style": "dashed", "line-color": "#c1a967", - "target-arrow-color": "#c1a967", + "source-arrow-color": "#c1a967", }, }, { @@ -184,7 +185,7 @@ const cytoscapeStyle = [ style: { "line-style": "dotted", "line-color": "#f7c948", - "target-arrow-color": "#f7c948", + "source-arrow-color": "#f7c948", "width": 3, }, }, @@ -192,7 +193,7 @@ const cytoscapeStyle = [ selector: "edge:selected", style: { "line-color": "#e74c3c", - "target-arrow-color": "#e74c3c", + "source-arrow-color": "#e74c3c", "width": 3, }, }, @@ -385,6 +386,9 @@ function installKeyboardHandlers() { if (key === "i") { keyState.i = true; } + if (key === "z") { + keyState.z = true; + } if (key === "escape") { clearPendingLineageSelection(); setStatus("Cleared selection.", "warn"); @@ -411,9 +415,30 @@ function installKeyboardHandlers() { if (key === "i") { keyState.i = false; } + if (key === "z") { + keyState.z = false; + } }); } +function configureCytoscapeInteractions(container) { + if (container.dataset.cytoscapeWheelGate === "installed") { + return; + } + container.dataset.cytoscapeWheelGate = "installed"; + container.addEventListener( + "wheel", + (evt) => { + const isPinchZoom = evt.ctrlKey || evt.metaKey; + if (isPinchZoom || keyState.z) { + return; + } + evt.stopImmediatePropagation(); + }, + { capture: true, passive: true }, + ); +} + function registerTapSequence(nodeId, button) { const key = `${nodeId}|${button}`; const now = Date.now(); @@ -1081,6 +1106,7 @@ function initCytoscape(container, elements) { evt.preventDefault(); }); + configureCytoscapeInteractions(container); cy = cytoscape({ container: container, elements: elements, diff --git a/static/modern/css/bloom_modern.css b/static/modern/css/bloom_modern.css index 3b9e612..79ba112 100644 --- a/static/modern/css/bloom_modern.css +++ b/static/modern/css/bloom_modern.css @@ -1121,3 +1121,1254 @@ pre { white-space: nowrap; border-width: 0; } +.lsmc-theme-control, +.lsmc-action-help-button, +.lsmc-action-help-panel { + font-family: var(--font-sans, Inter, system-ui, sans-serif); + z-index: 2147483000; +} + +.lsmc-theme-control { + position: fixed; + right: 1rem; + bottom: 5rem; + padding: 0.4rem 0.55rem; + border: 1px solid color-mix(in srgb, currentColor 20%, transparent); + border-radius: 8px; + background: color-mix(in srgb, Canvas 94%, transparent); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.16); + font-size: 0.78rem; +} + +.lsmc-theme-control label { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.lsmc-theme-control select { + min-height: 1.7rem; + border-radius: 6px; +} + +.lsmc-action-help-button { + position: fixed; + right: 1rem; + bottom: 1rem; + width: 2.65rem; + height: 2.65rem; + border: 0; + border-radius: 999px; + background: #1f6feb; + color: white; + font-weight: 800; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.22); +} + +.lsmc-action-help-panel { + position: fixed; + right: 1rem; + bottom: 4.1rem; + width: min(26rem, calc(100vw - 2rem)); + padding: 0.8rem; + border: 1px solid color-mix(in srgb, currentColor 20%, transparent); + border-radius: 8px; + background: Canvas; + color: CanvasText; + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.24); +} + +.lsmc-action-help-panel pre { + overflow: auto; + margin: 0.55rem 0; + padding: 0.65rem; + border-radius: 6px; + background: color-mix(in srgb, CanvasText 8%, Canvas); + white-space: pre-wrap; +} + +html[data-theme="lsmc"] body { + background: #f4f8fb; + color: #172033; +} + +html[data-theme="dark"] { + color-scheme: dark; +} + +html[data-theme="dark"] body { + background: #10141d; + color: #e5edf7; +} + +html[data-theme="light"] body { + background: #ffffff; + color: #111827; +} + +html[data-theme="tacky"] body { + background: #fff700; + color: #1f1147; +} + +html[data-theme="tacky"] .lsmc-action-help-button { + background: #ff00b8; +} + +/* LSMC skin-system primitives. CBF is color-blind-friendly, not privacy masking. */ +html[data-theme="cbf"] { + color-scheme: light; + --ui-bg: #f7f7f2; + --ui-surface: #ffffff; + --ui-surface-2: #f1f5f9; + --ui-surface-3: #e2e8f0; + --ui-text: #111827; + --ui-muted: #334155; + --ui-border: #111827; + --ui-accent: #1f4e79; + --ui-accent-2: #6b4c9a; + --ui-info: #1f4e79; + --ui-ok: #2f6f4e; + --ui-warn: #946200; + --ui-danger: #8f2d28; + --ui-decision: #5c3d99; + --ui-radius-xs: 3px; + --ui-radius-sm: 4px; + --ui-radius-md: 6px; + --ui-radius-lg: 8px; + --ui-density-card-pad: 0.85rem; + --ui-density-table-y: 0.45rem; + --ui-density-table-x: 0.65rem; + --ui-font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --ui-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +html[data-theme="cbf"] body { + background: var(--ui-bg); + color: var(--ui-text); +} + +html[data-theme="cbf"] a { + color: var(--ui-accent); + text-decoration: underline; + text-underline-offset: 0.16em; +} + +.lsmc-status, +.authority-chip, +.euid-pill, +.xid-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border: 1.5px solid var(--ui-border, currentColor); + border-radius: var(--ui-radius-sm, 4px); + padding: 0.12rem 0.45rem; + font-weight: 700; + line-height: 1.25; +} + +.lsmc-status::before { + content: "i"; + font-weight: 900; +} + +.lsmc-status[data-criticality="warning"] { + border-style: dashed; +} + +.lsmc-status[data-criticality="warning"]::before { + content: "▲"; +} + +.lsmc-status[data-criticality="stop"] { + border-width: 3px; +} + +.lsmc-status[data-criticality="stop"]::before { + content: "■"; +} + +.lsmc-status[data-criticality="decision"] { + text-decoration: underline; +} + +.lsmc-status[data-criticality="decision"]::before, +.lsmc-status[data-authority="r2"]::before { + content: "◆"; +} + +.relationship-row, +.evidence-card, +.raw-payload-drawer { + border: 1px solid var(--ui-border, currentColor); + border-radius: var(--ui-radius-md, 6px); +} + +html[data-theme="cbf"] [class*="status-"], +html[data-theme="cbf"] [class*="badge"] { + border: 2px solid currentColor; + font-weight: 750; +} + +html[data-theme="cbf"] [class*="status-"]::before, +html[data-theme="cbf"] [class*="badge"]::before { + content: "● "; +} + +html[data-theme="cbf"] [class*="warn"]::before, +html[data-theme="cbf"] [class*="hold"]::before { + content: "▲ "; +} + +html[data-theme="cbf"] [class*="danger"]::before, +html[data-theme="cbf"] [class*="error"]::before, +html[data-theme="cbf"] [class*="fail"]::before, +html[data-theme="cbf"] [class*="stop"]::before { + content: "■ "; +} + +/* Four-theme dashboard legibility repair. Color-only overrides. */ +html[data-theme="light"] { + color-scheme: light; + --lsmc-dashboard-bg: #f6f8fb; + --lsmc-dashboard-surface: #ffffff; + --lsmc-dashboard-surface-2: #eef3f8; + --lsmc-dashboard-text: #111827; + --lsmc-dashboard-muted: #374151; + --lsmc-dashboard-border: #8fa1b5; + --lsmc-dashboard-link: #0f4c81; + --lsmc-dashboard-link-hover: #073a63; + --lsmc-dashboard-button-bg: #174ea6; + --lsmc-dashboard-button-text: #ffffff; + --lsmc-dashboard-badge-bg: #e8f1ff; + --lsmc-dashboard-badge-text: #0f2544; + --lsmc-dashboard-ok-bg: #dff5e8; + --lsmc-dashboard-ok-text: #064b2f; + --lsmc-dashboard-warn-bg: #fff2cc; + --lsmc-dashboard-warn-text: #5c3b00; + --lsmc-dashboard-danger-bg: #ffe1df; + --lsmc-dashboard-danger-text: #7f1d1d; + --lsmc-dashboard-info-bg: #e0f2fe; + --lsmc-dashboard-info-text: #0b4168; + --color-primary: #f8fafc; + --color-secondary: #ffffff; + --color-accent: #dbeafe; + --color-highlight: #174ea6; + --color-success: #087a4b; + --color-warning: #8a5800; + --color-error: #b42318; + --color-info: #0f4c81; + --color-gray-900: #f6f8fb; + --color-gray-800: #ffffff; + --color-gray-700: #eef3f8; + --color-gray-600: #8fa1b5; + --color-gray-500: #64748b; + --color-gray-400: #374151; + --color-gray-300: #1f2937; + --color-gray-200: #111827; + --color-white: #111827; + --bg-primary: #f6f8fb; + --bg-secondary: #ffffff; + --bg-tertiary: #eef3f8; + --bg-elevated: #ffffff; + --bg-hover: #e6edf5; + --text-primary: #111827; + --text-secondary: #374151; + --text-muted: #4b5563; + --border-color: #8fa1b5; + --border-light: #b7c3d0; + --tapdb-bg: #f6f8fb; + --tapdb-panel: #ffffff; + --tapdb-text: #111827; + --tapdb-muted: #374151; + --tapdb-border: #8fa1b5; +} + +html[data-theme="dark"] { + color-scheme: dark; + --lsmc-dashboard-bg: #0b1220; + --lsmc-dashboard-surface: #111827; + --lsmc-dashboard-surface-2: #1f2937; + --lsmc-dashboard-text: #f8fafc; + --lsmc-dashboard-muted: #cbd5e1; + --lsmc-dashboard-border: #64748b; + --lsmc-dashboard-link: #93c5fd; + --lsmc-dashboard-link-hover: #bfdbfe; + --lsmc-dashboard-button-bg: #1d4ed8; + --lsmc-dashboard-button-text: #ffffff; + --lsmc-dashboard-badge-bg: #1e3a8a; + --lsmc-dashboard-badge-text: #f8fafc; + --lsmc-dashboard-ok-bg: #064e3b; + --lsmc-dashboard-ok-text: #d1fae5; + --lsmc-dashboard-warn-bg: #713f12; + --lsmc-dashboard-warn-text: #fef3c7; + --lsmc-dashboard-danger-bg: #7f1d1d; + --lsmc-dashboard-danger-text: #fee2e2; + --lsmc-dashboard-info-bg: #075985; + --lsmc-dashboard-info-text: #e0f2fe; + --color-primary: #0b1220; + --color-secondary: #111827; + --color-accent: #1f2937; + --color-highlight: #93c5fd; + --color-success: #34d399; + --color-warning: #fbbf24; + --color-error: #f87171; + --color-info: #38bdf8; + --color-gray-900: #0b1220; + --color-gray-800: #111827; + --color-gray-700: #1f2937; + --color-gray-600: #64748b; + --color-gray-500: #94a3b8; + --color-gray-400: #cbd5e1; + --color-gray-300: #e2e8f0; + --color-gray-200: #f8fafc; + --color-white: #f8fafc; + --bg-primary: #0b1220; + --bg-secondary: #111827; + --bg-tertiary: #1f2937; + --bg-elevated: #1e293b; + --bg-hover: #273449; + --text-primary: #f8fafc; + --text-secondary: #cbd5e1; + --text-muted: #b6c2d2; + --border-color: #64748b; + --border-light: #94a3b8; + --tapdb-bg: #0b1220; + --tapdb-panel: #111827; + --tapdb-text: #f8fafc; + --tapdb-muted: #cbd5e1; + --tapdb-border: #64748b; +} + +html[data-theme="cbf"] { + color-scheme: light; + --lsmc-dashboard-bg: #f8f3df; + --lsmc-dashboard-surface: #fffdf2; + --lsmc-dashboard-surface-2: #f0e5bc; + --lsmc-dashboard-text: #111111; + --lsmc-dashboard-muted: #2f3137; + --lsmc-dashboard-border: #111111; + --lsmc-dashboard-link: #004b8d; + --lsmc-dashboard-link-hover: #002f5f; + --lsmc-dashboard-button-bg: #004b8d; + --lsmc-dashboard-button-text: #ffffff; + --lsmc-dashboard-badge-bg: #fffdf2; + --lsmc-dashboard-badge-text: #111111; + --lsmc-dashboard-ok-bg: #d7f0ff; + --lsmc-dashboard-ok-text: #00365f; + --lsmc-dashboard-warn-bg: #ffe7a3; + --lsmc-dashboard-warn-text: #422800; + --lsmc-dashboard-danger-bg: #f7d4f4; + --lsmc-dashboard-danger-text: #4b1248; + --lsmc-dashboard-info-bg: #d8e7ff; + --lsmc-dashboard-info-text: #002f5f; + --color-primary: #f8f3df; + --color-secondary: #fffdf2; + --color-accent: #f0e5bc; + --color-highlight: #004b8d; + --color-success: #00365f; + --color-warning: #6b4e00; + --color-error: #8f2d56; + --color-info: #004b8d; + --color-gray-900: #f8f3df; + --color-gray-800: #fffdf2; + --color-gray-700: #f0e5bc; + --color-gray-600: #111111; + --color-gray-500: #2f3137; + --color-gray-400: #2f3137; + --color-gray-300: #111111; + --color-gray-200: #111111; + --color-white: #111111; + --bg-primary: #f8f3df; + --bg-secondary: #fffdf2; + --bg-tertiary: #f0e5bc; + --bg-elevated: #fffdf2; + --bg-hover: #eadca6; + --text-primary: #111111; + --text-secondary: #2f3137; + --text-muted: #2f3137; + --border-color: #111111; + --border-light: #111111; + --tapdb-bg: #f8f3df; + --tapdb-panel: #fffdf2; + --tapdb-text: #111111; + --tapdb-muted: #2f3137; + --tapdb-border: #111111; +} + +html[data-theme="light"] body, +html[data-theme="dark"] body, +html[data-theme="cbf"] body { + background: var(--lsmc-dashboard-bg); + color: var(--lsmc-dashboard-text); +} + +html[data-theme="light"] .header, +html[data-theme="light"] .topbar, +html[data-theme="light"] .top-bar, +html[data-theme="light"] .secondary-nav, +html[data-theme="light"] .navbar, +html[data-theme="light"] header, +html[data-theme="light"] nav, +html[data-theme="dark"] .header, +html[data-theme="dark"] .topbar, +html[data-theme="dark"] .top-bar, +html[data-theme="dark"] .secondary-nav, +html[data-theme="dark"] .navbar, +html[data-theme="dark"] header, +html[data-theme="dark"] nav, +html[data-theme="cbf"] .header, +html[data-theme="cbf"] .topbar, +html[data-theme="cbf"] .top-bar, +html[data-theme="cbf"] .secondary-nav, +html[data-theme="cbf"] .navbar, +html[data-theme="cbf"] header, +html[data-theme="cbf"] nav { + background: var(--lsmc-dashboard-surface); + color: var(--lsmc-dashboard-text); + border-color: var(--lsmc-dashboard-border); + box-shadow: 0 1px 0 var(--lsmc-dashboard-border); +} + +html[data-theme="light"] .card, +html[data-theme="light"] .stat-card, +html[data-theme="light"] .metric-card, +html[data-theme="light"] .panel, +html[data-theme="light"] .table-container, +html[data-theme="light"] .details-panel, +html[data-theme="light"] .action-card, +html[data-theme="light"] .dashboard-service-card, +html[data-theme="light"] .directory-card, +html[data-theme="light"] .environment-card, +html[data-theme="light"] .binding-card, +html[data-theme="light"] .summary-chip, +html[data-theme="light"] .seqnas-card, +html[data-theme="light"] .seqnas-board-card, +html[data-theme="light"] .seqnas-run-card, +html[data-theme="light"] .tapdb-card, +html[data-theme="light"] .tapdb-panel, +html[data-theme="light"] .auth-card, +html[data-theme="dark"] .card, +html[data-theme="dark"] .stat-card, +html[data-theme="dark"] .metric-card, +html[data-theme="dark"] .panel, +html[data-theme="dark"] .table-container, +html[data-theme="dark"] .details-panel, +html[data-theme="dark"] .action-card, +html[data-theme="dark"] .dashboard-service-card, +html[data-theme="dark"] .directory-card, +html[data-theme="dark"] .environment-card, +html[data-theme="dark"] .binding-card, +html[data-theme="dark"] .summary-chip, +html[data-theme="dark"] .seqnas-card, +html[data-theme="dark"] .seqnas-board-card, +html[data-theme="dark"] .seqnas-run-card, +html[data-theme="dark"] .tapdb-card, +html[data-theme="dark"] .tapdb-panel, +html[data-theme="dark"] .auth-card, +html[data-theme="cbf"] .card, +html[data-theme="cbf"] .stat-card, +html[data-theme="cbf"] .metric-card, +html[data-theme="cbf"] .panel, +html[data-theme="cbf"] .table-container, +html[data-theme="cbf"] .details-panel, +html[data-theme="cbf"] .action-card, +html[data-theme="cbf"] .dashboard-service-card, +html[data-theme="cbf"] .directory-card, +html[data-theme="cbf"] .environment-card, +html[data-theme="cbf"] .binding-card, +html[data-theme="cbf"] .summary-chip, +html[data-theme="cbf"] .seqnas-card, +html[data-theme="cbf"] .seqnas-board-card, +html[data-theme="cbf"] .seqnas-run-card, +html[data-theme="cbf"] .tapdb-card, +html[data-theme="cbf"] .tapdb-panel, +html[data-theme="cbf"] .auth-card { + background: var(--lsmc-dashboard-surface); + color: var(--lsmc-dashboard-text); + border-color: var(--lsmc-dashboard-border); + box-shadow: 0 10px 28px color-mix(in srgb, var(--lsmc-dashboard-text) 10%, transparent); +} + +html[data-theme="light"] h1, +html[data-theme="light"] h2, +html[data-theme="light"] h3, +html[data-theme="light"] h4, +html[data-theme="light"] .page-title, +html[data-theme="light"] .card-title, +html[data-theme="light"] .stat-value, +html[data-theme="light"] .metric-value, +html[data-theme="dark"] h1, +html[data-theme="dark"] h2, +html[data-theme="dark"] h3, +html[data-theme="dark"] h4, +html[data-theme="dark"] .page-title, +html[data-theme="dark"] .card-title, +html[data-theme="dark"] .stat-value, +html[data-theme="dark"] .metric-value, +html[data-theme="cbf"] h1, +html[data-theme="cbf"] h2, +html[data-theme="cbf"] h3, +html[data-theme="cbf"] h4, +html[data-theme="cbf"] .page-title, +html[data-theme="cbf"] .card-title, +html[data-theme="cbf"] .stat-value, +html[data-theme="cbf"] .metric-value { + color: var(--lsmc-dashboard-text); +} + +html[data-theme="light"] .text-muted, +html[data-theme="light"] .text-secondary, +html[data-theme="light"] .stat-label, +html[data-theme="light"] .stat-subtext, +html[data-theme="light"] .metric-label, +html[data-theme="light"] .metric-caption, +html[data-theme="light"] .page-subtitle, +html[data-theme="light"] .subtitle, +html[data-theme="light"] .eyebrow, +html[data-theme="light"] .help-text, +html[data-theme="light"] .small, +html[data-theme="light"] th, +html[data-theme="dark"] .text-muted, +html[data-theme="dark"] .text-secondary, +html[data-theme="dark"] .stat-label, +html[data-theme="dark"] .stat-subtext, +html[data-theme="dark"] .metric-label, +html[data-theme="dark"] .metric-caption, +html[data-theme="dark"] .page-subtitle, +html[data-theme="dark"] .subtitle, +html[data-theme="dark"] .eyebrow, +html[data-theme="dark"] .help-text, +html[data-theme="dark"] .small, +html[data-theme="dark"] th, +html[data-theme="cbf"] .text-muted, +html[data-theme="cbf"] .text-secondary, +html[data-theme="cbf"] .stat-label, +html[data-theme="cbf"] .stat-subtext, +html[data-theme="cbf"] .metric-label, +html[data-theme="cbf"] .metric-caption, +html[data-theme="cbf"] .page-subtitle, +html[data-theme="cbf"] .subtitle, +html[data-theme="cbf"] .eyebrow, +html[data-theme="cbf"] .help-text, +html[data-theme="cbf"] .small, +html[data-theme="cbf"] th { + color: var(--lsmc-dashboard-muted); +} + +html[data-theme="light"] a, +html[data-theme="dark"] a, +html[data-theme="cbf"] a { + color: var(--lsmc-dashboard-link); +} + +html[data-theme="light"] a:hover, +html[data-theme="dark"] a:hover, +html[data-theme="cbf"] a:hover { + color: var(--lsmc-dashboard-link-hover); +} + +html[data-theme="light"] input, +html[data-theme="light"] select, +html[data-theme="light"] textarea, +html[data-theme="light"] table, +html[data-theme="light"] pre, +html[data-theme="light"] code, +html[data-theme="dark"] input, +html[data-theme="dark"] select, +html[data-theme="dark"] textarea, +html[data-theme="dark"] table, +html[data-theme="dark"] pre, +html[data-theme="dark"] code, +html[data-theme="cbf"] input, +html[data-theme="cbf"] select, +html[data-theme="cbf"] textarea, +html[data-theme="cbf"] table, +html[data-theme="cbf"] pre, +html[data-theme="cbf"] code { + background: var(--lsmc-dashboard-surface-2); + color: var(--lsmc-dashboard-text); + border-color: var(--lsmc-dashboard-border); +} + +html[data-theme="light"] .button, +html[data-theme="light"] .btn, +html[data-theme="light"] .btn-primary, +html[data-theme="light"] button, +html[data-theme="dark"] .button, +html[data-theme="dark"] .btn, +html[data-theme="dark"] .btn-primary, +html[data-theme="dark"] button, +html[data-theme="cbf"] .button, +html[data-theme="cbf"] .btn, +html[data-theme="cbf"] .btn-primary, +html[data-theme="cbf"] button { + background: var(--lsmc-dashboard-button-bg); + color: var(--lsmc-dashboard-button-text); + border-color: var(--lsmc-dashboard-button-bg); +} + +html[data-theme="light"] [class*="badge"], +html[data-theme="light"] [class*="status-"], +html[data-theme="light"] .pill, +html[data-theme="light"] .status-pill, +html[data-theme="dark"] [class*="badge"], +html[data-theme="dark"] [class*="status-"], +html[data-theme="dark"] .pill, +html[data-theme="dark"] .status-pill, +html[data-theme="cbf"] [class*="badge"], +html[data-theme="cbf"] [class*="status-"], +html[data-theme="cbf"] .pill, +html[data-theme="cbf"] .status-pill { + background: var(--lsmc-dashboard-badge-bg); + color: var(--lsmc-dashboard-badge-text); + border-color: var(--lsmc-dashboard-border); +} + +html[data-theme="light"] [class*="success"], +html[data-theme="light"] [class*="ok"], +html[data-theme="light"] [class*="active"], +html[data-theme="light"] [class*="completed"], +html[data-theme="dark"] [class*="success"], +html[data-theme="dark"] [class*="ok"], +html[data-theme="dark"] [class*="active"], +html[data-theme="dark"] [class*="completed"], +html[data-theme="cbf"] [class*="success"], +html[data-theme="cbf"] [class*="ok"], +html[data-theme="cbf"] [class*="active"], +html[data-theme="cbf"] [class*="completed"] { + background: var(--lsmc-dashboard-ok-bg); + color: var(--lsmc-dashboard-ok-text); +} + +html[data-theme="light"] [class*="warn"], +html[data-theme="light"] [class*="hold"], +html[data-theme="light"] [class*="pending"], +html[data-theme="dark"] [class*="warn"], +html[data-theme="dark"] [class*="hold"], +html[data-theme="dark"] [class*="pending"], +html[data-theme="cbf"] [class*="warn"], +html[data-theme="cbf"] [class*="hold"], +html[data-theme="cbf"] [class*="pending"] { + background: var(--lsmc-dashboard-warn-bg); + color: var(--lsmc-dashboard-warn-text); +} + +html[data-theme="light"] [class*="danger"], +html[data-theme="light"] [class*="error"], +html[data-theme="light"] [class*="fail"], +html[data-theme="light"] [class*="canceled"], +html[data-theme="light"] [class*="rejected"], +html[data-theme="dark"] [class*="danger"], +html[data-theme="dark"] [class*="error"], +html[data-theme="dark"] [class*="fail"], +html[data-theme="dark"] [class*="canceled"], +html[data-theme="dark"] [class*="rejected"], +html[data-theme="cbf"] [class*="danger"], +html[data-theme="cbf"] [class*="error"], +html[data-theme="cbf"] [class*="fail"], +html[data-theme="cbf"] [class*="canceled"], +html[data-theme="cbf"] [class*="rejected"] { + background: var(--lsmc-dashboard-danger-bg); + color: var(--lsmc-dashboard-danger-text); +} + +html[data-theme="light"] [class*="info"], +html[data-theme="dark"] [class*="info"], +html[data-theme="cbf"] [class*="info"] { + background: var(--lsmc-dashboard-info-bg); + color: var(--lsmc-dashboard-info-text); +} + +/* Four-theme dashboard legibility repair pass 2. Color-only targeted surfaces. */ +html[data-theme="light"] .lsmc-theme-control, +html[data-theme="light"] .lsmc-action-help-panel, +html[data-theme="dark"] .lsmc-theme-control, +html[data-theme="dark"] .lsmc-action-help-panel, +html[data-theme="cbf"] .lsmc-theme-control, +html[data-theme="cbf"] .lsmc-action-help-panel { + background: var(--lsmc-dashboard-surface); + color: var(--lsmc-dashboard-text); + border-color: var(--lsmc-dashboard-border); +} + +html[data-theme="light"] .navlink, +html[data-theme="light"] .nav-link, +html[data-theme="light"] .secondary-link, +html[data-theme="light"] .role-indicator, +html[data-theme="light"] .secondary-title, +html[data-theme="light"] .topbar-account-email, +html[data-theme="light"] .topbar-account-roles, +html[data-theme="light"] .user, +html[data-theme="light"] .session-strip, +html[data-theme="dark"] .navlink, +html[data-theme="dark"] .nav-link, +html[data-theme="dark"] .secondary-link, +html[data-theme="dark"] .role-indicator, +html[data-theme="dark"] .secondary-title, +html[data-theme="dark"] .topbar-account-email, +html[data-theme="dark"] .topbar-account-roles, +html[data-theme="dark"] .user, +html[data-theme="dark"] .session-strip, +html[data-theme="cbf"] .navlink, +html[data-theme="cbf"] .nav-link, +html[data-theme="cbf"] .secondary-link, +html[data-theme="cbf"] .role-indicator, +html[data-theme="cbf"] .secondary-title, +html[data-theme="cbf"] .topbar-account-email, +html[data-theme="cbf"] .topbar-account-roles, +html[data-theme="cbf"] .user, +html[data-theme="cbf"] .session-strip { + background: var(--lsmc-dashboard-surface-2); + color: var(--lsmc-dashboard-text); + border-color: var(--lsmc-dashboard-border); +} + +html[data-theme="light"] .user-avatar, +html[data-theme="dark"] .user-avatar, +html[data-theme="cbf"] .user-avatar { + background: var(--lsmc-dashboard-button-bg); + color: var(--lsmc-dashboard-button-text); + border-color: var(--lsmc-dashboard-border); +} + +html[data-theme="light"] .footer, +html[data-theme="light"] .page-footer, +html[data-theme="light"] .app-footer, +html[data-theme="light"] footer, +html[data-theme="dark"] .footer, +html[data-theme="dark"] .page-footer, +html[data-theme="dark"] .app-footer, +html[data-theme="dark"] footer, +html[data-theme="cbf"] .footer, +html[data-theme="cbf"] .page-footer, +html[data-theme="cbf"] .app-footer, +html[data-theme="cbf"] footer { + background: var(--lsmc-dashboard-surface); + color: var(--lsmc-dashboard-text); + border-color: var(--lsmc-dashboard-border); +} + +html[data-theme="light"] .footer-label, +html[data-theme="light"] .footer-meta, +html[data-theme="light"] .footer-brand p, +html[data-theme="light"] .app-footer span, +html[data-theme="light"] footer p, +html[data-theme="light"] footer span, +html[data-theme="dark"] .footer-label, +html[data-theme="dark"] .footer-meta, +html[data-theme="dark"] .footer-brand p, +html[data-theme="dark"] .app-footer span, +html[data-theme="dark"] footer p, +html[data-theme="dark"] footer span, +html[data-theme="cbf"] .footer-label, +html[data-theme="cbf"] .footer-meta, +html[data-theme="cbf"] .footer-brand p, +html[data-theme="cbf"] .app-footer span, +html[data-theme="cbf"] footer p, +html[data-theme="cbf"] footer span { + color: var(--lsmc-dashboard-muted); +} + +html[data-theme="light"] .glass, +html[data-theme="light"] .dashboard-hero, +html[data-theme="light"] .hero, +html[data-theme="dark"] .glass, +html[data-theme="dark"] .dashboard-hero, +html[data-theme="dark"] .hero, +html[data-theme="cbf"] .glass, +html[data-theme="cbf"] .dashboard-hero, +html[data-theme="cbf"] .hero { + background: var(--lsmc-dashboard-surface); + color: var(--lsmc-dashboard-text); + border-color: var(--lsmc-dashboard-border); +} + +html[data-theme="light"] .stat-card strong, +html[data-theme="light"] .panel strong, +html[data-theme="light"] .hero h1, +html[data-theme="dark"] .stat-card strong, +html[data-theme="dark"] .panel strong, +html[data-theme="dark"] .hero h1, +html[data-theme="cbf"] .stat-card strong, +html[data-theme="cbf"] .panel strong, +html[data-theme="cbf"] .hero h1 { + color: var(--lsmc-dashboard-text); +} + +html[data-theme="light"] .environment-banner, +html[data-theme="light"] .environment-bar, +html[data-theme="light"] .deployment-banner, +html[data-theme="light"] .bloom-deployment-bar, +html[data-theme="dark"] .environment-banner, +html[data-theme="dark"] .environment-bar, +html[data-theme="dark"] .deployment-banner, +html[data-theme="dark"] .bloom-deployment-bar, +html[data-theme="cbf"] .environment-banner, +html[data-theme="cbf"] .environment-bar, +html[data-theme="cbf"] .deployment-banner, +html[data-theme="cbf"] .bloom-deployment-bar { + color: var(--lsmc-dashboard-text); +} + +/* Four-theme dashboard legibility repair pass 3. Color-only muted text and deployment bars. */ +html[data-theme="light"] .environment-banner, +html[data-theme="light"] .environment-bar, +html[data-theme="light"] .environment-bar-deployment, +html[data-theme="light"] .deployment-banner, +html[data-theme="light"] .bloom-deployment-bar, +html[data-theme="dark"] .environment-banner, +html[data-theme="dark"] .environment-bar, +html[data-theme="dark"] .environment-bar-deployment, +html[data-theme="dark"] .deployment-banner, +html[data-theme="dark"] .bloom-deployment-bar, +html[data-theme="cbf"] .environment-banner, +html[data-theme="cbf"] .environment-bar, +html[data-theme="cbf"] .environment-bar-deployment, +html[data-theme="cbf"] .deployment-banner, +html[data-theme="cbf"] .bloom-deployment-bar { + background: var(--lsmc-dashboard-button-bg); + color: var(--lsmc-dashboard-button-text); +} + +html[data-theme="light"] .note, +html[data-theme="light"] .panel-copy, +html[data-theme="light"] .empty-state, +html[data-theme="light"] .card-note, +html[data-theme="light"] .panel-kicker, +html[data-theme="light"] .brand-kicker, +html[data-theme="light"] .lede, +html[data-theme="light"] .page-heading p, +html[data-theme="light"] .stat-card span, +html[data-theme="light"] .kv-list dt, +html[data-theme="light"] dt, +html[data-theme="light"] label, +html[data-theme="dark"] .note, +html[data-theme="dark"] .panel-copy, +html[data-theme="dark"] .empty-state, +html[data-theme="dark"] .card-note, +html[data-theme="dark"] .panel-kicker, +html[data-theme="dark"] .brand-kicker, +html[data-theme="dark"] .lede, +html[data-theme="dark"] .page-heading p, +html[data-theme="dark"] .stat-card span, +html[data-theme="dark"] .kv-list dt, +html[data-theme="dark"] dt, +html[data-theme="dark"] label, +html[data-theme="cbf"] .note, +html[data-theme="cbf"] .panel-copy, +html[data-theme="cbf"] .empty-state, +html[data-theme="cbf"] .card-note, +html[data-theme="cbf"] .panel-kicker, +html[data-theme="cbf"] .brand-kicker, +html[data-theme="cbf"] .lede, +html[data-theme="cbf"] .page-heading p, +html[data-theme="cbf"] .stat-card span, +html[data-theme="cbf"] .kv-list dt, +html[data-theme="cbf"] dt, +html[data-theme="cbf"] label { + color: var(--lsmc-dashboard-muted); +} + +/* Four-theme dashboard legibility repair pass 4. Color-only final dashboard labels. */ +html[data-theme="light"] .muted, +html[data-theme="light"] .cyto-instructions, +html[data-theme="light"] .label, +html[data-theme="light"] .dashboard-notes li, +html[data-theme="dark"] .muted, +html[data-theme="dark"] .cyto-instructions, +html[data-theme="dark"] .label, +html[data-theme="dark"] .dashboard-notes li, +html[data-theme="cbf"] .muted, +html[data-theme="cbf"] .cyto-instructions, +html[data-theme="cbf"] .label, +html[data-theme="cbf"] .dashboard-notes li { + color: var(--lsmc-dashboard-muted); +} + +html[data-theme="light"] body > div[style*="height: 18px"][style*="background"], +html[data-theme="dark"] body > div[style*="height: 18px"][style*="background"], +html[data-theme="cbf"] body > div[style*="height: 18px"][style*="background"] { + background: var(--lsmc-dashboard-button-bg) !important; + color: var(--lsmc-dashboard-button-text) !important; +} + +/* LSMC S.SF and Viridis skin additions. Color-only overrides; original remains unchanged. */ +html[data-theme="ssf"] { + color-scheme: light; + --lsmc-dashboard-bg: #f8ead2; + --lsmc-dashboard-surface: #fffaf0; + --lsmc-dashboard-surface-2: #ffe8bd; + --lsmc-dashboard-text: #172126; + --lsmc-dashboard-muted: #4a2c21; + --lsmc-dashboard-border: #1c2e35; + --lsmc-dashboard-link: #9f341f; + --lsmc-dashboard-link-hover: #6f2116; + --lsmc-dashboard-button-bg: #007f7a; + --lsmc-dashboard-button-text: #fffaf0; + --lsmc-status-success-bg: #dff2d6; + --lsmc-status-success-text: #123d28; + --lsmc-status-success-border: #2c7a54; + --lsmc-status-warning-bg: #fff1a8; + --lsmc-status-warning-text: #352500; + --lsmc-status-warning-border: #9a6d00; + --lsmc-status-danger-bg: #ffe0d6; + --lsmc-status-danger-text: #5b160f; + --lsmc-status-danger-border: #b83a2e; + --lsmc-status-info-bg: #d7f4ef; + --lsmc-status-info-text: #073d3a; + --lsmc-status-info-border: #007f7a; +} + +html[data-theme="viridis"] { + color-scheme: light; + --lsmc-dashboard-bg: #f7fbf0; + --lsmc-dashboard-surface: #ffffff; + --lsmc-dashboard-surface-2: #e9f5d4; + --lsmc-dashboard-text: #162016; + --lsmc-dashboard-muted: #35513d; + --lsmc-dashboard-border: #84a98c; + --lsmc-dashboard-link: #2a788e; + --lsmc-dashboard-link-hover: #1f5f74; + --lsmc-dashboard-button-bg: #31688e; + --lsmc-dashboard-button-text: #ffffff; + --lsmc-status-success-bg: #e3f8c9; + --lsmc-status-success-text: #173b1d; + --lsmc-status-success-border: #35b779; + --lsmc-status-warning-bg: #fff6a6; + --lsmc-status-warning-text: #302800; + --lsmc-status-warning-border: #b79f00; + --lsmc-status-danger-bg: #efe2f4; + --lsmc-status-danger-text: #2f063c; + --lsmc-status-danger-border: #440154; + --lsmc-status-info-bg: #dff2f0; + --lsmc-status-info-text: #123d3b; + --lsmc-status-info-border: #21918c; +} + +html[data-theme="viridis-dark"] { + color-scheme: dark; + --lsmc-dashboard-bg: #0d1128; + --lsmc-dashboard-surface: #171d3a; + --lsmc-dashboard-surface-2: #24315f; + --lsmc-dashboard-text: #f7fce5; + --lsmc-dashboard-muted: #c2d6b4; + --lsmc-dashboard-border: #3b528b; + --lsmc-dashboard-link: #7ad151; + --lsmc-dashboard-link-hover: #aadc32; + --lsmc-dashboard-button-bg: #21918c; + --lsmc-dashboard-button-text: #ffffff; + --lsmc-status-success-bg: #183820; + --lsmc-status-success-text: #dff7c2; + --lsmc-status-success-border: #7ad151; + --lsmc-status-warning-bg: #4a4200; + --lsmc-status-warning-text: #fff6a6; + --lsmc-status-warning-border: #fde725; + --lsmc-status-danger-bg: #32104a; + --lsmc-status-danger-text: #f2def7; + --lsmc-status-danger-border: #b765d1; + --lsmc-status-info-bg: #163d45; + --lsmc-status-info-text: #d7fff6; + --lsmc-status-info-border: #22a884; +} + +html[data-theme="ssf"] body, +html[data-theme="viridis"] body, +html[data-theme="viridis-dark"] body { + background: var(--lsmc-dashboard-bg) !important; + color: var(--lsmc-dashboard-text) !important; +} + +html[data-theme="ssf"] .header, +html[data-theme="ssf"] .topbar, +html[data-theme="ssf"] .top-bar, +html[data-theme="ssf"] .secondary-nav, +html[data-theme="ssf"] .navbar, +html[data-theme="ssf"] header, +html[data-theme="ssf"] nav, +html[data-theme="viridis"] .header, +html[data-theme="viridis"] .topbar, +html[data-theme="viridis"] .top-bar, +html[data-theme="viridis"] .secondary-nav, +html[data-theme="viridis"] .navbar, +html[data-theme="viridis"] header, +html[data-theme="viridis"] nav, +html[data-theme="viridis-dark"] .header, +html[data-theme="viridis-dark"] .topbar, +html[data-theme="viridis-dark"] .top-bar, +html[data-theme="viridis-dark"] .secondary-nav, +html[data-theme="viridis-dark"] .navbar, +html[data-theme="viridis-dark"] header, +html[data-theme="viridis-dark"] nav { + background: var(--lsmc-dashboard-surface) !important; + color: var(--lsmc-dashboard-text) !important; + border-color: var(--lsmc-dashboard-border) !important; +} + +html[data-theme="ssf"] .card, +html[data-theme="ssf"] .stat-card, +html[data-theme="ssf"] .metric-card, +html[data-theme="ssf"] .panel, +html[data-theme="ssf"] .table-container, +html[data-theme="ssf"] .details-panel, +html[data-theme="ssf"] .action-card, +html[data-theme="ssf"] .dashboard-service-card, +html[data-theme="ssf"] .directory-card, +html[data-theme="ssf"] .environment-card, +html[data-theme="ssf"] .binding-card, +html[data-theme="ssf"] .summary-chip, +html[data-theme="ssf"] .seqnas-card, +html[data-theme="ssf"] .seqnas-board-card, +html[data-theme="ssf"] .seqnas-run-card, +html[data-theme="ssf"] .tapdb-card, +html[data-theme="ssf"] .tapdb-panel, +html[data-theme="ssf"] .auth-card, +html[data-theme="viridis"] .card, +html[data-theme="viridis"] .stat-card, +html[data-theme="viridis"] .metric-card, +html[data-theme="viridis"] .panel, +html[data-theme="viridis"] .table-container, +html[data-theme="viridis"] .details-panel, +html[data-theme="viridis"] .action-card, +html[data-theme="viridis"] .dashboard-service-card, +html[data-theme="viridis"] .directory-card, +html[data-theme="viridis"] .environment-card, +html[data-theme="viridis"] .binding-card, +html[data-theme="viridis"] .summary-chip, +html[data-theme="viridis"] .seqnas-card, +html[data-theme="viridis"] .seqnas-board-card, +html[data-theme="viridis"] .seqnas-run-card, +html[data-theme="viridis"] .tapdb-card, +html[data-theme="viridis"] .tapdb-panel, +html[data-theme="viridis"] .auth-card, +html[data-theme="viridis-dark"] .card, +html[data-theme="viridis-dark"] .stat-card, +html[data-theme="viridis-dark"] .metric-card, +html[data-theme="viridis-dark"] .panel, +html[data-theme="viridis-dark"] .table-container, +html[data-theme="viridis-dark"] .details-panel, +html[data-theme="viridis-dark"] .action-card, +html[data-theme="viridis-dark"] .dashboard-service-card, +html[data-theme="viridis-dark"] .directory-card, +html[data-theme="viridis-dark"] .environment-card, +html[data-theme="viridis-dark"] .binding-card, +html[data-theme="viridis-dark"] .summary-chip, +html[data-theme="viridis-dark"] .seqnas-card, +html[data-theme="viridis-dark"] .seqnas-board-card, +html[data-theme="viridis-dark"] .seqnas-run-card, +html[data-theme="viridis-dark"] .tapdb-card, +html[data-theme="viridis-dark"] .tapdb-panel, +html[data-theme="viridis-dark"] .auth-card { + background: var(--lsmc-dashboard-surface) !important; + color: var(--lsmc-dashboard-text) !important; + border-color: var(--lsmc-dashboard-border) !important; +} + +html[data-theme="ssf"] h1, +html[data-theme="ssf"] h2, +html[data-theme="ssf"] h3, +html[data-theme="ssf"] h4, +html[data-theme="ssf"] .page-title, +html[data-theme="ssf"] .card-title, +html[data-theme="ssf"] .stat-value, +html[data-theme="ssf"] .metric-value, +html[data-theme="viridis"] h1, +html[data-theme="viridis"] h2, +html[data-theme="viridis"] h3, +html[data-theme="viridis"] h4, +html[data-theme="viridis"] .page-title, +html[data-theme="viridis"] .card-title, +html[data-theme="viridis"] .stat-value, +html[data-theme="viridis"] .metric-value, +html[data-theme="viridis-dark"] h1, +html[data-theme="viridis-dark"] h2, +html[data-theme="viridis-dark"] h3, +html[data-theme="viridis-dark"] h4, +html[data-theme="viridis-dark"] .page-title, +html[data-theme="viridis-dark"] .card-title, +html[data-theme="viridis-dark"] .stat-value, +html[data-theme="viridis-dark"] .metric-value { + color: var(--lsmc-dashboard-text) !important; +} + +html[data-theme="ssf"] .text-muted, +html[data-theme="ssf"] .text-secondary, +html[data-theme="ssf"] .stat-label, +html[data-theme="ssf"] .stat-subtext, +html[data-theme="ssf"] .metric-label, +html[data-theme="ssf"] .metric-caption, +html[data-theme="ssf"] .page-subtitle, +html[data-theme="ssf"] .subtitle, +html[data-theme="ssf"] .eyebrow, +html[data-theme="ssf"] .help-text, +html[data-theme="ssf"] .small, +html[data-theme="ssf"] th, +html[data-theme="viridis"] .text-muted, +html[data-theme="viridis"] .text-secondary, +html[data-theme="viridis"] .stat-label, +html[data-theme="viridis"] .stat-subtext, +html[data-theme="viridis"] .metric-label, +html[data-theme="viridis"] .metric-caption, +html[data-theme="viridis"] .page-subtitle, +html[data-theme="viridis"] .subtitle, +html[data-theme="viridis"] .eyebrow, +html[data-theme="viridis"] .help-text, +html[data-theme="viridis"] .small, +html[data-theme="viridis"] th, +html[data-theme="viridis-dark"] .text-muted, +html[data-theme="viridis-dark"] .text-secondary, +html[data-theme="viridis-dark"] .stat-label, +html[data-theme="viridis-dark"] .stat-subtext, +html[data-theme="viridis-dark"] .metric-label, +html[data-theme="viridis-dark"] .metric-caption, +html[data-theme="viridis-dark"] .page-subtitle, +html[data-theme="viridis-dark"] .subtitle, +html[data-theme="viridis-dark"] .eyebrow, +html[data-theme="viridis-dark"] .help-text, +html[data-theme="viridis-dark"] .small, +html[data-theme="viridis-dark"] th { + color: var(--lsmc-dashboard-muted) !important; +} + +html[data-theme="ssf"] a, +html[data-theme="viridis"] a, +html[data-theme="viridis-dark"] a { + color: var(--lsmc-dashboard-link) !important; + text-decoration: underline; + text-underline-offset: 0.16em; +} + +html[data-theme="ssf"] a:hover, +html[data-theme="viridis"] a:hover, +html[data-theme="viridis-dark"] a:hover { + color: var(--lsmc-dashboard-link-hover) !important; +} + +html[data-theme="ssf"] input, +html[data-theme="ssf"] select, +html[data-theme="ssf"] textarea, +html[data-theme="ssf"] table, +html[data-theme="ssf"] pre, +html[data-theme="ssf"] code, +html[data-theme="viridis"] input, +html[data-theme="viridis"] select, +html[data-theme="viridis"] textarea, +html[data-theme="viridis"] table, +html[data-theme="viridis"] pre, +html[data-theme="viridis"] code, +html[data-theme="viridis-dark"] input, +html[data-theme="viridis-dark"] select, +html[data-theme="viridis-dark"] textarea, +html[data-theme="viridis-dark"] table, +html[data-theme="viridis-dark"] pre, +html[data-theme="viridis-dark"] code { + background: var(--lsmc-dashboard-surface-2) !important; + color: var(--lsmc-dashboard-text) !important; + border-color: var(--lsmc-dashboard-border) !important; +} + +html[data-theme="ssf"] .button, +html[data-theme="ssf"] .btn, +html[data-theme="ssf"] .btn-primary, +html[data-theme="ssf"] button, +html[data-theme="viridis"] .button, +html[data-theme="viridis"] .btn, +html[data-theme="viridis"] .btn-primary, +html[data-theme="viridis"] button, +html[data-theme="viridis-dark"] .button, +html[data-theme="viridis-dark"] .btn, +html[data-theme="viridis-dark"] .btn-primary, +html[data-theme="viridis-dark"] button { + background: var(--lsmc-dashboard-button-bg) !important; + color: var(--lsmc-dashboard-button-text) !important; + border-color: var(--lsmc-dashboard-button-bg) !important; +} + +html[data-theme="ssf"] [class*="badge"], +html[data-theme="ssf"] [class*="status-"], +html[data-theme="ssf"] .pill, +html[data-theme="ssf"] .status-pill, +html[data-theme="viridis"] [class*="badge"], +html[data-theme="viridis"] [class*="status-"], +html[data-theme="viridis"] .pill, +html[data-theme="viridis"] .status-pill, +html[data-theme="viridis-dark"] [class*="badge"], +html[data-theme="viridis-dark"] [class*="status-"], +html[data-theme="viridis-dark"] .pill, +html[data-theme="viridis-dark"] .status-pill { + background: var(--lsmc-status-info-bg) !important; + color: var(--lsmc-status-info-text) !important; + border-color: var(--lsmc-status-info-border) !important; + border-style: solid; +} + +html[data-theme="ssf"] [class*="success"], +html[data-theme="ssf"] [class*="ok"], +html[data-theme="ssf"] [class*="active"], +html[data-theme="ssf"] [class*="completed"], +html[data-theme="viridis"] [class*="success"], +html[data-theme="viridis"] [class*="ok"], +html[data-theme="viridis"] [class*="active"], +html[data-theme="viridis"] [class*="completed"], +html[data-theme="viridis-dark"] [class*="success"], +html[data-theme="viridis-dark"] [class*="ok"], +html[data-theme="viridis-dark"] [class*="active"], +html[data-theme="viridis-dark"] [class*="completed"] { + background: var(--lsmc-status-success-bg) !important; + color: var(--lsmc-status-success-text) !important; + border-color: var(--lsmc-status-success-border) !important; +} + +html[data-theme="ssf"] [class*="warn"], +html[data-theme="ssf"] [class*="hold"], +html[data-theme="ssf"] [class*="pending"], +html[data-theme="viridis"] [class*="warn"], +html[data-theme="viridis"] [class*="hold"], +html[data-theme="viridis"] [class*="pending"], +html[data-theme="viridis-dark"] [class*="warn"], +html[data-theme="viridis-dark"] [class*="hold"], +html[data-theme="viridis-dark"] [class*="pending"] { + background: var(--lsmc-status-warning-bg) !important; + color: var(--lsmc-status-warning-text) !important; + border-color: var(--lsmc-status-warning-border) !important; +} + +html[data-theme="ssf"] [class*="danger"], +html[data-theme="ssf"] [class*="error"], +html[data-theme="ssf"] [class*="fail"], +html[data-theme="ssf"] [class*="canceled"], +html[data-theme="ssf"] [class*="rejected"], +html[data-theme="viridis"] [class*="danger"], +html[data-theme="viridis"] [class*="error"], +html[data-theme="viridis"] [class*="fail"], +html[data-theme="viridis"] [class*="canceled"], +html[data-theme="viridis"] [class*="rejected"], +html[data-theme="viridis-dark"] [class*="danger"], +html[data-theme="viridis-dark"] [class*="error"], +html[data-theme="viridis-dark"] [class*="fail"], +html[data-theme="viridis-dark"] [class*="canceled"], +html[data-theme="viridis-dark"] [class*="rejected"] { + background: var(--lsmc-status-danger-bg) !important; + color: var(--lsmc-status-danger-text) !important; + border-color: var(--lsmc-status-danger-border) !important; +} + +.lsmc-theme-global { + display: flex; + align-items: center; + gap: 0.3rem; + margin-top: 0.35rem; + font-size: 0.72rem; +} + +html[data-theme="ssf"] .lsmc-theme-control, +html[data-theme="ssf"] .lsmc-action-help-panel, +html[data-theme="viridis"] .lsmc-theme-control, +html[data-theme="viridis"] .lsmc-action-help-panel, +html[data-theme="viridis-dark"] .lsmc-theme-control, +html[data-theme="viridis-dark"] .lsmc-action-help-panel { + background: var(--lsmc-dashboard-surface) !important; + color: var(--lsmc-dashboard-text) !important; + border-color: var(--lsmc-dashboard-border) !important; +} diff --git a/static/modern/js/lsmc_ui.js b/static/modern/js/lsmc_ui.js new file mode 100644 index 0000000..8354a5c --- /dev/null +++ b/static/modern/js/lsmc_ui.js @@ -0,0 +1,155 @@ +(function () { + const themes = ["original", "light", "dark", "ssf", "viridis", "viridis-dark"]; + const themeLabels = { ssf: "S.SF", viridis: "Viridis", "viridis-dark": "Viridis Dark" }; + const globalStorageKey = "lsmc.ui.theme"; + const modeStoragePrefix = "lsmc.ui.theme.mode."; + const serviceStoragePrefix = "lsmc.ui.theme.service."; + const defaultTheme = "dark"; + + const service = document.documentElement.dataset.lsmcService || "bloom"; + + function validTheme(theme) { + return themes.includes(theme); + } + + function serviceModeKey() { + return `${modeStoragePrefix}${service}`; + } + + function serviceThemeKey() { + return `${serviceStoragePrefix}${service}`; + } + + function isGlobalThemeMode() { + return window.localStorage.getItem(serviceModeKey()) !== "service"; + } + + function currentTheme() { + if (!isGlobalThemeMode()) { + const serviceTheme = window.localStorage.getItem(serviceThemeKey()); + if (validTheme(serviceTheme)) return serviceTheme; + } + const globalTheme = window.localStorage.getItem(globalStorageKey); + return validTheme(globalTheme) ? globalTheme : defaultTheme; + } + + function applyTheme(theme, options = {}) { + const value = validTheme(theme) ? theme : defaultTheme; + document.documentElement.dataset.theme = value; + if (options.persist !== false) { + if (isGlobalThemeMode()) { + window.localStorage.setItem(globalStorageKey, value); + } else { + window.localStorage.setItem(serviceThemeKey(), value); + } + } + return value; + } + + function setThemeMode(globalMode) { + window.localStorage.setItem(serviceModeKey(), globalMode ? "global" : "service"); + } + + async function syncThemeFromBroker(select, globalCheckbox) { + const response = await fetch("/api/v1/me/preferences", { credentials: "same-origin" }); + if (!response.ok) return; + const payload = await response.json(); + const preferences = (payload && payload.preferences) || {}; + const serviceThemes = preferences.service_themes || preferences.theme_by_service || {}; + const theme = isGlobalThemeMode() ? preferences.theme : serviceThemes[service]; + if (!validTheme(theme)) return; + applyTheme(theme, { persist: false }); + if (isGlobalThemeMode()) { + window.localStorage.setItem(globalStorageKey, theme); + } else { + window.localStorage.setItem(serviceThemeKey(), theme); + } + if (select) select.value = theme; + if (globalCheckbox) globalCheckbox.checked = isGlobalThemeMode(); + if (globalCheckbox) globalCheckbox.checked = isGlobalThemeMode(); + } + + async function persistThemeToBroker(theme) { + const body = isGlobalThemeMode() ? { theme } : { service_themes: { [service]: theme } }; + await fetch("/api/v1/me/preferences", { + method: "PUT", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + function commandForPage() { + const explicit = document.body.dataset.actionHelpCommand || ""; + if (explicit) return explicit; + if (location.pathname.startsWith("/search")) return "bloom search --help"; + if (location.pathname.startsWith("/create_object")) return "bloom object create --help"; + if (location.pathname.startsWith("/admin")) return "bloom admin --help"; + if (location.pathname.startsWith("/dag")) return "bloom dag show --help"; + return `No CLI equivalent for bloom ${location.pathname}`; + } + + function initThemeControl() { + const wrap = document.createElement("div"); + wrap.className = "lsmc-theme-control"; + + const label = document.createElement("label"); + label.textContent = "Theme"; + const select = document.createElement("select"); + for (const theme of themes) select.appendChild(new Option(themeLabels[theme] || theme, theme)); + select.value = currentTheme(); + label.appendChild(select); + + const modeLabel = document.createElement("label"); + modeLabel.className = "lsmc-theme-global"; + const globalCheckbox = document.createElement("input"); + globalCheckbox.type = "checkbox"; + globalCheckbox.checked = isGlobalThemeMode(); + modeLabel.append(globalCheckbox, document.createTextNode("Global")); + + select.addEventListener("change", () => { + applyTheme(select.value); + persistThemeToBroker(select.value).catch(() => {}); + }); + globalCheckbox.addEventListener("change", () => { + setThemeMode(globalCheckbox.checked); + select.value = currentTheme(); + applyTheme(select.value); + persistThemeToBroker(select.value).catch(() => {}); + }); + + wrap.append(label, modeLabel); + document.body.appendChild(wrap); + syncThemeFromBroker(select, globalCheckbox).catch(() => {}); + } + + function initActionHelp() { + const button = document.createElement("button"); + button.type = "button"; + button.className = "lsmc-action-help-button"; + button.setAttribute("aria-expanded", "false"); + button.textContent = "?"; + const panel = document.createElement("aside"); + panel.className = "lsmc-action-help-panel"; + panel.hidden = true; + panel.innerHTML = 'Action Help
';
+    const output = panel.querySelector("pre");
+    const copy = panel.querySelector("button");
+    function refresh() {
+      output.textContent = commandForPage();
+    }
+    button.addEventListener("click", () => {
+      panel.hidden = !panel.hidden;
+      button.setAttribute("aria-expanded", String(!panel.hidden));
+      refresh();
+    });
+    copy.addEventListener("click", () => navigator.clipboard?.writeText(output.textContent || ""));
+    document.body.append(button, panel);
+  }
+
+  applyTheme(currentTheme());
+  document.addEventListener("DOMContentLoaded", () => {
+    initThemeControl();
+    initActionHelp();
+  });
+})();
diff --git a/templates/modern/base.html b/templates/modern/base.html
index f85249d..1afaa87 100644
--- a/templates/modern/base.html
+++ b/templates/modern/base.html
@@ -1,5 +1,5 @@
 
-
+
 
     
     
@@ -8,9 +8,6 @@
     
     
     
-    
-    
-    
     
     
     
                  Dashboard
             
-            
-                 Queue Runtime
-            
             
                  Equipment
             
@@ -75,6 +69,12 @@
             
                  Create
             
+            
+                 Lab Actions
+            
+            
+                 Container Actions
+            
             
         
     
+    

+ Pinch to zoom. Mouse wheel scrolls the page; hold z while wheeling to zoom. Arrowheads point to parent nodes. +

+

Details

diff --git a/templates/modern/dashboard.html b/templates/modern/dashboard.html index 27ee403..dd56dc1 100644 --- a/templates/modern/dashboard.html +++ b/templates/modern/dashboard.html @@ -17,16 +17,6 @@

Dashboard

- -
- -
-
-
{{ stats.queue_runtime_total | default(0) }}
-
Queue Runtime Objects
-
-
-
@@ -60,37 +50,6 @@

Dashboard

- -
- -
- {% if recent_queue_runtime %} - - {% else %} -
-
-

No recent queue runtime objects

- Open Queue Runtime -
- {% endif %} -
-
-
@@ -129,7 +88,6 @@

Quick Actions

- Queue Runtime Equipment Reagents Templates diff --git a/templates/modern/euid_details.html b/templates/modern/euid_details.html index 2bcbaa0..56ec80f 100644 --- a/templates/modern/euid_details.html +++ b/templates/modern/euid_details.html @@ -160,9 +160,6 @@

Actions

Related Objects -
@@ -779,23 +776,27 @@

Full JSON (json_addl)

var editorProperties = null; var editorTemplate = null; -// Collapsible section toggle -document.querySelectorAll('.collapsible-btn').forEach(function(btn) { - btn.addEventListener('click', function() { - var targetId = this.getAttribute('data-target'); - var targetEl = document.getElementById(targetId); - - // Toggle active class on button - this.classList.toggle('active'); - - // Toggle visibility of content - if (targetEl.style.display === 'none') { - targetEl.style.display = 'block'; - } else { - targetEl.style.display = 'none'; - } +function bindCollapsibleSections() { + document.querySelectorAll('.collapsible-btn').forEach(function(btn) { + if (btn.dataset.bound === '1') return; + btn.dataset.bound = '1'; + btn.addEventListener('click', function() { + var targetId = this.getAttribute('data-target'); + var targetEl = document.getElementById(targetId); + if (!targetEl) { + if (typeof window.BloomToast !== 'undefined' && typeof window.BloomToast.error === 'function') { + window.BloomToast.error('Section Missing', 'Unable to find section ' + targetId); + } + return; + } + this.classList.toggle('active'); + targetEl.style.display = targetEl.style.display === 'none' || targetEl.style.display === '' ? 'block' : 'none'; + }); }); -}); +} + +bindCollapsibleSections(); +document.addEventListener('DOMContentLoaded', bindCollapsibleSections); // Format JSON on page load document.addEventListener('DOMContentLoaded', function() { @@ -957,11 +958,6 @@

Full JSON (json_addl)

} } -// Print label -function printLabel(euid) { - BloomToast.info('Print Label', 'Printing label for ' + euid); -} - // Edit mode state var isEditMode = false; var editorFull = null; diff --git a/templates/modern/lab_actions.html b/templates/modern/lab_actions.html new file mode 100644 index 0000000..729f562 --- /dev/null +++ b/templates/modern/lab_actions.html @@ -0,0 +1,499 @@ +{% extends "modern/base.html" %} + +{% block title %}Lab Actions - BLOOM{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + +
+
+
+

1. Extraction Plate

+
+
+ + +
+ + +
+ + +
+
+ +
+
+

2. Extraction QC Plate

+
+
+ + + + + + + +
+
+ +
+
+

3. Sequencing-Library Plate

+
+
+ + + + + + + +
+
+ +
+
+

4. Sequencing Pool Tube

+
+
+ + +
+ + +
+ + + +
+
+ +
+
+

5. Sequencing Run Set

+
+
+ + + + + + + + + + + +
+
+
+ +
+
+
+

Manage Sets

+
+
+ + + + + + + +
+
+ +
+
+

Plate-Well Data

+
+
+ + + + + + + +
+
+
+ +
+
+

Spreadsheet Upload

+
+
+

+ Upload `.xlsx` or `.csv` sheets for Extraction Plate, Extraction QC, Seq Library Plate, Seq Pool, + Seq Run Set, Sets, or Plate Well Data. Bulk Create, Transfer, and Annotation sheets are parsed as previews. +

+ + + +
+
+ +
+
+

Print EUID Barcodes

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

Action Results

+
+
+ +
{}
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/modern/login.html b/templates/modern/login.html index 9c0487f..f412e74 100644 --- a/templates/modern/login.html +++ b/templates/modern/login.html @@ -7,9 +7,6 @@ - - - diff --git a/templates/modern/queue_retired.html b/templates/modern/queue_retired.html new file mode 100644 index 0000000..316fa67 --- /dev/null +++ b/templates/modern/queue_retired.html @@ -0,0 +1,28 @@ +{% extends "modern/base.html" %} + +{% block title %}Queue Runtime Retired - BLOOM LIMS{% endblock %} + +{% block content %} + + +
+
+

Queue GUI Retired

+
+
+

+ Existing queue records are unchanged, but the Bloom dashboard no longer exposes queue runtime navigation. + Use object search or EUID details for retained Bloom objects. +

+ +
+
+{% endblock %} diff --git a/tests/support/runtime.py b/tests/support/runtime.py index 1151e87..fbc274f 100644 --- a/tests/support/runtime.py +++ b/tests/support/runtime.py @@ -54,11 +54,14 @@ / "templates.json" ) _TAPDB_CORE_PREFIX_OWNERSHIP = { - "TPX", - "EDG", "ADT", - "SYS", + "EDG", + "GSE", + "GVR", "MSG", + "SYS", + "TPX", + "XRF", } _PREFIX_OWNERSHIP_OWNER_FIELD = "".join(("issuer", "_app_code")) PROJECT_ROOT = Path(__file__).resolve().parents[2] diff --git a/tests/test_action_button_template_safety.py b/tests/test_action_button_template_safety.py index be00720..5f3a9bf 100644 --- a/tests/test_action_button_template_safety.py +++ b/tests/test_action_button_template_safety.py @@ -2,7 +2,6 @@ from pathlib import Path - PROJECT_ROOT = Path(__file__).resolve().parent.parent @@ -13,18 +12,31 @@ def _read(path: str) -> str: def test_modern_euid_details_uses_data_attribute_action_handler(): template = _read("templates/modern/euid_details.html") - assert "onclick=\"showCapturedDataFormFromDataAttributes(this)\"" in template - assert "data-action-json=\"{{ action_value | tojson | e }}\"" in template - assert "onclick=\"showCapturedDataForm(this, {{ action_value | tojson }}" not in template + assert 'onclick="showCapturedDataFormFromDataAttributes(this)"' in template + assert 'data-action-json="{{ action_value | tojson | e }}"' in template + assert ( + 'onclick="showCapturedDataForm(this, {{ action_value | tojson }}' + not in template + ) + + +def test_modern_euid_details_has_no_toast_only_print_button(): + template = _read("templates/modern/euid_details.html") + + assert "onclick=\"printLabel('{{ obj.euid }}')\"" not in template + assert "Printing label for " not in template def test_modern_workflow_details_uses_data_attribute_action_handler(): template = _read("templates/modern/workflow_details.html") - assert "onclick=\"showCapturedDataFormFromDataAttributes(this)\"" in template - assert "data-action-json=\"{{ action_value | tojson | e }}\"" in template - assert "onclick=\"showCapturedDataForm(this, {{ action_value }}" not in template - assert "onclick=\"showCapturedDataForm(this, {{ action_value | tojson }}" not in template + assert 'onclick="showCapturedDataFormFromDataAttributes(this)"' in template + assert 'data-action-json="{{ action_value | tojson | e }}"' in template + assert 'onclick="showCapturedDataForm(this, {{ action_value }}' not in template + assert ( + 'onclick="showCapturedDataForm(this, {{ action_value | tojson }}' + not in template + ) def test_action_buttons_uses_ui_schema_fields_array_for_filtering(): diff --git a/tests/test_action_schema_hydration.py b/tests/test_action_schema_hydration.py index 2b65717..b84a45b 100644 --- a/tests/test_action_schema_hydration.py +++ b/tests/test_action_schema_hydration.py @@ -44,3 +44,58 @@ def test_hydrate_dynamic_action_groups_removes_retired_assay_markup(): assert original_action["captured_data"] == {} assert "___workflow/assay/" not in hydrated_action["captured_data"] assert hydrated_action["ui_schema"]["fields"] == [] + + +def test_hydrate_dynamic_action_groups_adds_relationship_type_dropdown(): + action_groups = { + "core": { + "group_name": "Core Actions", + "actions": { + "action/core/add-relationships/1.0": { + "method_name": "do_action_add_relationships", + "capture_data": "yes", + "captured_data": {}, + "ui_schema": {"title": "Add Relationships", "fields": []}, + } + }, + } + } + + hydrated = hydrate_dynamic_action_groups(action_groups, SimpleNamespace()) + fields = hydrated["core"]["actions"]["action/core/add-relationships/1.0"][ + "ui_schema" + ]["fields"] + relationship_field = next( + field for field in fields if field["name"] == "relationship_type" + ) + + assert relationship_field["type"] == "select" + assert relationship_field["required"] is True + assert {"value": "derived_from", "label": "derived_from"} in relationship_field[ + "options" + ] + assert { + "value": "beta_sequencing_run", + "label": "beta_sequencing_run", + } in relationship_field["options"] + + +def test_hydrate_dynamic_action_groups_hides_subject_decision_scope_action(): + action_groups = { + "core": { + "group_name": "Core Actions", + "actions": { + "action/core/create-subject-and-anchor/1.0": { + "method_name": "do_action_create_subject_and_anchor", + "action_visible": "0", + "capture_data": "yes", + "captured_data": {}, + "ui_schema": {"title": "Create Subject", "fields": []}, + } + }, + } + } + + hydrated = hydrate_dynamic_action_groups(action_groups, SimpleNamespace()) + + assert hydrated["core"]["actions"] == {} diff --git a/tests/test_anomalies.py b/tests/test_anomalies.py index cc6729e..cc48de9 100644 --- a/tests/test_anomalies.py +++ b/tests/test_anomalies.py @@ -33,7 +33,9 @@ def _build_fake_repository() -> TapdbAnomalyRepository: instances: list[SimpleNamespace] = [] counter = {"value": 0} - def create_instance(*, template_code: str, name: str, properties: dict, session) -> SimpleNamespace: + def create_instance( + *, template_code: str, name: str, properties: dict, session + ) -> SimpleNamespace: counter["value"] += 1 category, type_name, subtype, version = template_code.strip("/").split("/") instance = SimpleNamespace( @@ -174,7 +176,9 @@ def test_anomaly_api_lists_and_reads_records(monkeypatch) -> None: def test_admin_anomalies_views_render(monkeypatch) -> None: monkeypatch.setattr(operations, "BLOOMdb3", _StubBloomDB) - monkeypatch.setattr(operations, "TapdbAnomalyRepository", lambda _session: _StubRepository()) + monkeypatch.setattr( + operations, "TapdbAnomalyRepository", lambda _session: _StubRepository() + ) with TestClient(app, raise_server_exceptions=False) as client: listing = client.get("/admin/anomalies") @@ -213,7 +217,7 @@ def test_anomaly_api_tolerates_malformed_stored_properties(monkeypatch) -> None: } }, created_dt=datetime.now(UTC), - category="bloom", + category="BAN", type="ops", subtype="anomaly-record", version="1.0", diff --git a/tests/test_api_auth_rbac.py b/tests/test_api_auth_rbac.py index 902e06f..4a55871 100644 --- a/tests/test_api_auth_rbac.py +++ b/tests/test_api_auth_rbac.py @@ -121,6 +121,43 @@ def _unexpected_atlas_service(): assert service._atlas is None +def test_external_specimen_lookup_uses_order_test_reference_filter(monkeypatch): + def _unexpected_atlas_service(): + raise AssertionError( + "AtlasService should not be constructed for lookup-only queries" + ) + + monkeypatch.setattr( + "bloom_lims.domain.external_specimens.AtlasService", + _unexpected_atlas_service, + ) + + calls: list[tuple[str, str]] = [] + + def _find_parent_uids_for_reference(*, reference_type: str, reference_value: str): + calls.append((reference_type, reference_value)) + if reference_type == "order_euid": + return {123} + if reference_type == "order_test_euid": + return set() + raise AssertionError(f"unexpected reference filter: {reference_type}") + + service = object.__new__(ExternalSpecimenService) + service._atlas = None + service.bdb = SimpleNamespace() + service.bobj = SimpleNamespace() + service._find_parent_uids_for_reference = _find_parent_uids_for_reference + + result = ExternalSpecimenService.find_by_references( + service, + AtlasReferences(order_euid="ORDER-1", order_test_euid="ORDERTEST-1"), + ) + + assert result == [] + assert calls == [("order_euid", "ORDER-1"), ("order_test_euid", "ORDERTEST-1")] + assert service._atlas is None + + def test_user_tokens_endpoints_create_list_usage_revoke(client): token_id = _create_token(client) diff --git a/tests/test_atlas_workflow_contract.py b/tests/test_atlas_workflow_contract.py index 3d2eb6d..070cf54 100644 --- a/tests/test_atlas_workflow_contract.py +++ b/tests/test_atlas_workflow_contract.py @@ -142,7 +142,7 @@ def _create_specimen_payload( container_euid: str | None = None, ) -> dict: payload = { - "specimen_template_code": "content/specimen/blood-whole/1.0", + "specimen_template_code": "material/specimen/blood-whole/1.0", "specimen_name": f"specimen-{_suffix()}", "status": "active", "properties": {"source": "atlas-contract-test"}, @@ -260,7 +260,7 @@ def get_container_trf_context( "/api/v1/external/specimens", headers={"Idempotency-Key": _opaque("idem", 16)}, json={ - "specimen_template_code": "content/specimen/blood-whole/1.0", + "specimen_template_code": "material/specimen/blood-whole/1.0", "specimen_name": "context-mismatch", "container_euid": container["euid"], "status": "active", @@ -315,7 +315,7 @@ def get_container_trf_context( created = _create_specimen( client, payload={ - "specimen_template_code": "content/specimen/blood-whole/1.0", + "specimen_template_code": "material/specimen/blood-whole/1.0", "specimen_name": "context-match", "container_euid": container["euid"], "status": "active", @@ -408,7 +408,7 @@ def test_unsupported_template_returns_validation_error(monkeypatch): response = client.post( "/api/v1/external/specimens", json={ - "specimen_template_code": "content/specimen/saliva/1.0", + "specimen_template_code": "material/specimen/saliva/1.0", "specimen_name": "unsupported-template", "status": "active", "atlas_refs": {"patient_id": f"PAT-{_suffix()}"}, diff --git a/tests/test_beta_cross_repo_smoke.py b/tests/test_beta_cross_repo_smoke.py index be767df..a62db8a 100644 --- a/tests/test_beta_cross_repo_smoke.py +++ b/tests/test_beta_cross_repo_smoke.py @@ -19,6 +19,14 @@ os.environ["BLOOM_DEV_AUTH_BYPASS"] = "true" WORKSPACE_ROOT = Path(__file__).resolve().parents[2] +SMOKE_STATE_ROOT = WORKSPACE_ROOT / "_refactor" / "beta_cross_repo_smoke" +os.environ.setdefault("XDG_CONFIG_HOME", str(SMOKE_STATE_ROOT / "config")) +os.environ.setdefault("XDG_DATA_HOME", str(SMOKE_STATE_ROOT / "data")) +os.environ.setdefault("XDG_STATE_HOME", str(SMOKE_STATE_ROOT / "state")) +os.environ.setdefault("XDG_CACHE_HOME", str(SMOKE_STATE_ROOT / "cache")) +os.environ.setdefault("ATLAS_DEPLOYMENT_CODE", "jemdev5") +os.environ.setdefault("AWS_PROFILE", "beta-smoke-profile") +os.environ.setdefault("AWS_REGION", "us-west-2") def _resolve_repo_root(*candidates: Path) -> Path | None: @@ -45,6 +53,95 @@ def _resolve_repo_root(*candidates: Path) -> Path | None: allow_module_level=True, ) +atlas_registry_dir = ATLAS_ROOT / "app" / "etc" +tapdb_registry_dir = SMOKE_STATE_ROOT / "config" / "tapdb" / "registries" +tapdb_registry_dir.mkdir(parents=True, exist_ok=True) +for filename in ("domain_code_registry.json", "prefix_ownership_registry.json"): + (tapdb_registry_dir / filename).write_text( + (atlas_registry_dir / filename).read_text(encoding="utf-8"), + encoding="utf-8", + ) +tapdb_config_file = ( + SMOKE_STATE_ROOT + / "config" + / "tapdb" + / "atlas" + / "lsmc-atlas-jemdev5" + / "tapdb-config.yaml" +) +tapdb_config_file.parent.mkdir(parents=True, exist_ok=True) +tapdb_config_file.write_text( + f"""meta: + config_version: 4 + client_id: atlas + database_name: lsmc-atlas + owner_repo_name: lsmc-atlas + domain_registry_path: {tapdb_registry_dir / "domain_code_registry.json"} + prefix_ownership_registry_path: {tapdb_registry_dir / "prefix_ownership_registry.json"} +target: + engine_type: local + host: localhost + port: '5533' + ui_port: '8911' + user: postgres + password: '' + database: lsmc_atlas_beta_smoke + schema_name: tapdb_atlas_beta_smoke + domain_code: Z + support_email: support@lsmc.bio + cognito_user_pool_id: '' +safety: + safety_tier: local + destructive_operations: allowed +""", + encoding="utf-8", +) +atlas_config_file = ( + SMOKE_STATE_ROOT + / "config" + / "lsmc-atlas-jemdev5" + / "lsmc-atlas-config-jemdev5.yaml" +) +atlas_config_file.parent.mkdir(parents=True, exist_ok=True) +atlas_config_file.write_text( + f"""application: + debug: true + secret_key: atlas-beta-smoke-secret-key-0123456789abcd +database: + backend: tapdb + target: local + namespace: lsmc-atlas + meridian_domain_code: Z + owner_repo_name: lsmc-atlas + client_id: atlas + database_name: lsmc-atlas + config_path: {tapdb_config_file} + domain_registry_path: {tapdb_registry_dir / "domain_code_registry.json"} + prefix_ownership_registry_path: {tapdb_registry_dir / "prefix_ownership_registry.json"} +internal_api: + key: atlas-beta-smoke-internal-api-key +deployment: + name: jemdev5 + color: "#123456" + is_production: false +authentication: + mode: cognito + session_max_age: 28800 + session_cookie_name: lsmc_atlas_session + cognito: + region: us-west-2 + user_pool_id: atlas-beta-smoke-pool + domain: atlas.auth.us-west-2.amazoncognito.com + app_client_id: atlas-client-id + app_client_secret: "" + redirect_uri: https://localhost:8911/auth/callback + logout_url: https://localhost:8911/auth/logout +""", + encoding="utf-8", +) +os.environ.setdefault("LSMC_ATLAS_CONFIG_FILE", str(atlas_config_file)) +os.environ.setdefault("TAPDB_CONFIG_PATH", str(tapdb_config_file)) + os.environ.setdefault( "DATABASE_URL", f"sqlite:///{WORKSPACE_ROOT / '_refactor' / 'atlas_beta_smoke.sqlite'}", @@ -324,8 +421,8 @@ def override_ursa_integration_write_client(): def test_cross_repo_beta_smoke(monkeypatch): tenant_id = uuid.uuid4() - atlas_trf_euid = _opaque("trf") - atlas_test_accept = _opaque("test") + atlas_order_euid = _opaque("order") + atlas_order_test_accept = _opaque("order-test") patient_euid = _opaque("patient") shipment_euid = _opaque("shipment") captured_return: dict[str, object] = {} @@ -336,8 +433,8 @@ def __init__(self, db, provided_tenant_id): def register_accepted_material(self, data, actor_id=None): assert actor_id is not None - assert data.trf_euid == atlas_trf_euid - assert data.test_euids == [atlas_test_accept] + assert data.order_euid == atlas_order_euid + assert data.order_test_euids == [atlas_order_test_accept] assert data.patient_euid == patient_euid assert data.shipment_euid == shipment_euid assert data.starting_queue == "extraction_prod" @@ -350,7 +447,7 @@ def register_accepted_material(self, data, actor_id=None): current_queue=data.starting_queue, created=True, queue_idempotent_replay=False, - fulfillment_item_euids=[_opaque("tpc")], + fulfillment_slot_euids=[_opaque("slot")], ) class FakeUrsaResultReturnService: @@ -360,7 +457,7 @@ def __init__(self, db, provided_tenant_id): def apply(self, data): captured_return["request"] = data return SimpleNamespace( - fulfillment_run_euid="ASR-SMOKE", + fulfillment_instance_euid="ASR-SMOKE", fulfillment_output_euid="RES-SMOKE", artifact_euids=["ART-SMOKE-1"], results_set_euid="RSET-SMOKE", @@ -393,8 +490,8 @@ def apply(self, data): "/api/integrations/bloom/v1/materials/accepted", headers={"Idempotency-Key": _opaque("idem-atlas-accepted")}, json={ - "trf_euid": atlas_trf_euid, - "test_euids": [atlas_test_accept], + "order_euid": atlas_order_euid, + "order_test_euids": [atlas_order_test_accept], "patient_euid": patient_euid, "shipment_euid": shipment_euid, "starting_queue": "extraction_prod", @@ -405,16 +502,16 @@ def apply(self, data): _assert_no_uuid_keys(accepted_body) assert accepted_body["accepted"] is True assert accepted_body["current_queue"] == "extraction_prod" - assert accepted_body["fulfillment_item_euids"] - fulfillment_item_accept = accepted_body["fulfillment_item_euids"][0] + assert accepted_body["fulfillment_slot_euids"] + fulfillment_slot_accept = accepted_body["fulfillment_slot_euids"][0] atlas_context = { "atlas_tenant_id": str(tenant_id), - "atlas_trf_euid": atlas_trf_euid, + "atlas_trf_euid": atlas_order_euid, "fulfillment_items": [ { - "atlas_test_euid": atlas_test_accept, - "atlas_test_fulfillment_item_euid": fulfillment_item_accept, + "atlas_test_euid": atlas_order_test_accept, + "atlas_test_fulfillment_item_euid": fulfillment_slot_accept, } ], } @@ -447,7 +544,7 @@ def apply(self, data): "source_specimen_euid": specimen_euid, "well_name": "A1", "extraction_type": "gdna", - "atlas_test_fulfillment_item_euid": fulfillment_item_accept, + "atlas_test_fulfillment_item_euid": fulfillment_slot_accept, }, ) assert extraction.status_code == 200, extraction.text @@ -545,7 +642,7 @@ def apply(self, data): resolved_body = resolved.json() _assert_no_uuid_keys(resolved_body) assert ( - resolved_body["atlas_test_fulfillment_item_euid"] == fulfillment_item_accept + resolved_body["atlas_test_fulfillment_item_euid"] == fulfillment_slot_accept ) store = SmokeAnalysisStore() @@ -569,11 +666,16 @@ def apply(self, data): ), dewey_client=dewey_client, auth_provider=SmokeUrsaAuthProvider(tenant_id), + s3_client=object(), settings=Settings( aws_profile="", cors_origins="*", + deployment_name="jemdev5", + allowed_hosts="testserver,localhost,127.0.0.1", session_secret_key="test-session-secret", - ursa_internal_api_key="ursa-smoke-key", + ursa_observability_service_token="ursa-smoke-observability-token", + ursa_write_service_token="ursa-smoke-key", + ursa_tapdb_admin_service_token="ursa-smoke-tapdb-token", ursa_internal_output_bucket="beta-analysis-artifacts", bloom_base_url="https://testserver", bloom_api_token="bloom-smoke-token", @@ -614,7 +716,7 @@ def apply(self, data): analysis_euid = ingest_payload["analysis_euid"] assert ( ingest_payload["atlas_test_fulfillment_item_euid"] - == fulfillment_item_accept + == fulfillment_slot_accept ) assert ( ingest_payload["sequenced_library_assignment_euid"] @@ -669,9 +771,9 @@ def apply(self, data): recorded_request = captured_return["request"] assert recorded_request.atlas_tenant_id == str(tenant_id) - assert recorded_request.atlas_trf_euid == atlas_trf_euid - assert recorded_request.atlas_test_euid == atlas_test_accept - assert recorded_request.atlas_test_fulfillment_item_euid == fulfillment_item_accept + assert recorded_request.atlas_order_euid == atlas_order_euid + assert recorded_request.atlas_order_test_euid == atlas_order_test_accept + assert recorded_request.atlas_fulfillment_slot_euid == fulfillment_slot_accept assert recorded_request.flowcell_id == flowcell_id assert recorded_request.lane == lane assert recorded_request.library_barcode == library_barcode diff --git a/tests/test_beta_lab_schema.py b/tests/test_beta_lab_schema.py new file mode 100644 index 0000000..1921048 --- /dev/null +++ b/tests/test_beta_lab_schema.py @@ -0,0 +1,42 @@ +"""Schema regressions for Bloom beta lab payload contracts.""" + +from __future__ import annotations + +import pytest + +from bloom_lims.schemas.beta_lab import AtlasFulfillmentContext + + +def test_atlas_fulfillment_context_preserves_order_refs(): + context = AtlasFulfillmentContext( + atlas_tenant_id="tenant-1", + atlas_order_euid="Z-AGX-ORDER1", + atlas_order_test_euid="Z-AGX-TEST1", + atlas_order_test_euids=["Z-AGX-TEST1", "Z-AGX-TEST2"], + fulfillment_slots=[ + { + "atlas_order_test_euid": "Z-AGX-TEST2", + "atlas_fulfillment_slot_euid": "Z-AGX-SLOT1", + } + ], + ) + + payload = context.model_dump() + + assert payload["atlas_order_euid"] == "Z-AGX-ORDER1" + assert payload["atlas_order_test_euid"] == "Z-AGX-TEST1" + assert payload["atlas_order_test_euids"] == [ + "Z-AGX-TEST1", + "Z-AGX-TEST2", + ] + assert ( + payload["fulfillment_slots"][0]["atlas_fulfillment_slot_euid"] == "Z-AGX-SLOT1" + ) + + +def test_atlas_fulfillment_context_requires_order_for_order_tests(): + with pytest.raises(ValueError, match="atlas_order_euid is required"): + AtlasFulfillmentContext( + atlas_tenant_id="tenant-1", + atlas_order_test_euid="Z-AGX-TEST1", + ) diff --git a/tests/test_beta_modern_action_system.py b/tests/test_beta_modern_action_system.py index c0fea88..4f82f03 100644 --- a/tests/test_beta_modern_action_system.py +++ b/tests/test_beta_modern_action_system.py @@ -885,9 +885,18 @@ def test_beta_flow_records_modern_action_instances(bdb): "lane": "1", "library_barcode": "IDX-ACT-1", "library_prep_output_euid": lib_output_euid, + "library_material_euid": library_material_euid, + } + ], + "artifacts": [ + { + "artifact_type": "fastq", + "bucket": "codex-bloom-v0-test", + "filename": "lane1.fastq.gz", + "lane": "1", + "library_barcode": "IDX-ACT-1", } ], - "artifacts": [], }, ) assert run.status_code == 200, run.text @@ -909,29 +918,170 @@ def graph_ref_tuples(euid: str): (ref["system"], ref["root_euid"], ref["relationship_type"]) for ref in refs } - process_ref = atlas_context["fulfillment_items"][0][ + lineage_cls = bdb.Base.classes.generic_instance_lineage + + def first_child(parent_euid: str, relationship_type: str): + parent = instance(parent_euid) + lineage = ( + bdb.session.query(lineage_cls) + .filter( + lineage_cls.parent_instance_uid == parent.uid, + lineage_cls.relationship_type == relationship_type, + lineage_cls.is_deleted.is_(False), + ) + .one() + ) + return lineage.child_instance + + artifact_euid = first_child(run_euid, "beta_run_artifact").euid + pooling_run_euid = ( + bdb.session.query(lineage_cls) + .filter( + lineage_cls.child_instance_uid == instance(pool_euid).uid, + lineage_cls.relationship_type == "beta_pooling_run_output", + lineage_cls.is_deleted.is_(False), + ) + .one() + .parent_instance.euid + ) + + def v0_edge_tuples(): + rows = ( + bdb.session.query(lineage_cls) + .filter(lineage_cls.is_deleted.is_(False)) + .all() + ) + found = set() + for row in rows: + properties = (row.json_addl or {}).get("properties") + if not isinstance(properties, dict): + continue + metadata = properties.get("v0_edge") + if not isinstance(metadata, dict): + continue + found.add( + ( + metadata["edge_type"], + metadata["semantic_source"]["euid"], + metadata["semantic_source"]["role"], + metadata["semantic_target"]["euid"], + metadata["semantic_target"]["role"], + ) + ) + return found + + fulfillment_slot_ref = atlas_context["fulfillment_items"][0][ "atlas_test_fulfillment_item_euid" ] test_ref = atlas_context["fulfillment_items"][0]["atlas_test_euid"] expected_atlas_refs = { ("atlas", atlas_context["atlas_trf_euid"], "received_from_atlas_trf"), ("atlas", test_ref, "prepared_for_atlas_test"), - ("atlas", process_ref, "fulfills_atlas_test_fulfillment_item"), + ("atlas", fulfillment_slot_ref, "fulfills_atlas_test_fulfillment_item"), } assert expected_atlas_refs.issubset(graph_ref_tuples(specimen_euid)) assert ( "atlas", - process_ref, + fulfillment_slot_ref, "fulfills_atlas_test_fulfillment_item", ) in graph_ref_tuples(lib_output_euid), _props(instance(lib_output_euid)) assert ( "atlas", - process_ref, + fulfillment_slot_ref, "fulfills_atlas_test_fulfillment_item", ) in graph_ref_tuples(library_material_euid), _props( instance(library_material_euid) ) + assert { + ( + "HOLDS_MATERIAL", + well_euid, + "container", + extraction_output_euid, + "held_material", + ), + ( + "HOLDS_MATERIAL", + library_well_euid, + "container", + library_material_euid, + "held_material", + ), + ( + "DERIVED_FROM", + extraction_output_euid, + "derived_material", + specimen_euid, + "source_material", + ), + ( + "DERIVED_FROM", + library_material_euid, + "derived_material", + extraction_output_euid, + "source_material", + ), + ( + "DERIVED_FROM", + pool_euid, + "derived_material", + library_material_euid, + "source_material", + ), + ( + "RUN_CONSUMED", + extraction_body["extraction_run_euid"], + "workflow_run", + specimen_euid, + "consumed_material", + ), + ( + "RUN_PRODUCED", + extraction_body["extraction_run_euid"], + "workflow_run", + extraction_output_euid, + "produced_material", + ), + ( + "RUN_CONSUMED", + pooling_run_euid, + "workflow_run", + library_material_euid, + "consumed_material", + ), + ( + "RUN_PRODUCED", + pooling_run_euid, + "workflow_run", + pool_euid, + "produced_material", + ), + ("RUN_CONSUMED", run_euid, "workflow_run", pool_euid, "consumed_material"), + ( + "RUN_CONSUMED", + run_euid, + "workflow_run", + lib_output_euid, + "consumed_artifact", + ), + ( + "RUN_CONSUMED", + run_euid, + "workflow_run", + library_material_euid, + "consumed_material", + ), + ("RUN_PRODUCED", run_euid, "workflow_run", artifact_euid, "produced_artifact"), + ( + "SLOT_SATISFIED_BY", + fulfillment_slot_ref, + "fulfillment_slot", + library_material_euid, + "satisfying_bloom_object", + ), + }.issubset(v0_edge_tuples()) + plate_graph = _props(instance(plate_euid))["graph"] well_graph = _props(instance(well_euid))["graph"] library_plate_graph = _props(instance(library_plate_euid))["graph"] @@ -953,7 +1103,7 @@ def graph_ref_tuples(euid: str): assert pool_fanout[("beta_pool_member",)] == 1 assert ( "beta_sequencing_run", - "contains", + "HOLDS_MATERIAL", "executed_on", "execution_subject_lease", "execution_subject_record", @@ -962,7 +1112,7 @@ def graph_ref_tuples(euid: str): assert { tuple(entry["relationship_types"]): entry["max_child_count"] for entry in run_graph["expected_fanout"] - } == {("beta_sequenced_library_assignment",): 1, ("beta_run_artifact",): 0} + } == {("beta_sequenced_library_assignment",): 1, ("beta_run_artifact",): 1} subtypes = { row.subtype diff --git a/tests/test_bloom_prefix_taxonomy.py b/tests/test_bloom_prefix_taxonomy.py index 119c60a..901f31d 100644 --- a/tests/test_bloom_prefix_taxonomy.py +++ b/tests/test_bloom_prefix_taxonomy.py @@ -9,7 +9,6 @@ from bloom_lims.core.validation import ValidationError, validate_euid - PROJECT_ROOT = Path(__file__).resolve().parents[1] FORBIDDEN_PREFIX_CHARS = set("ILOU") @@ -25,9 +24,8 @@ def _template_pack() -> dict: def _templates_by_code() -> dict[str, dict]: templates: dict[str, dict] = {} for template in _template_pack()["templates"]: - semantic_category = template["json_addl"].get("semantic_category") code = ( - f"{semantic_category}/{template['type']}/" + f"{template['category']}/{template['type']}/" f"{template['subtype']}/{template['version']}" ) templates[code] = template @@ -46,14 +44,14 @@ def test_wet_lab_templates_use_bloom_prefix_taxonomy() -> None: "container/bottle/generic/1.0": "BCB", "container/flowcell/generic/1.0": "BCF", "container/flowcell_lane/generic/1.0": "BCE", - "content/specimen/blood-whole/1.0": "BNB", - "content/specimen/buccal-swab/1.0": "BNS", - "content/specimen/saliva/1.0": "BNA", - "content/sample/gdna/1.0": "BNG", - "content/sample/cfdna/1.0": "BNC", - "content/sample/sequencing-library/1.0": "BNQ", - "content/pool/sequencing-library/1.0": "BNP", - "content/reagent/sequencing-index/1.0": "BNX", + "material/specimen/blood-whole/1.0": "BNB", + "material/specimen/buccal-swab/1.0": "BNS", + "material/specimen/saliva/1.0": "BNA", + "material/sample/gdna/1.0": "BNG", + "material/sample/cfdna/1.0": "BNC", + "material/sample/sequencing-library/1.0": "BNQ", + "material/pool/sequencing-library/1.0": "BNP", + "material/reagent/sequencing-index/1.0": "BNX", "data/generic/gdna-quantification/1.0": "BDQ", "data/quantification/gdna/1.0": "BDQ", "data/operation/extraction/1.0": "BDX", @@ -63,14 +61,34 @@ def test_wet_lab_templates_use_bloom_prefix_taxonomy() -> None: "data/library-index-assignment/sequencing-library/1.0": "BDA", "data/execution/transfer/1.0": "BDT", "generic/generic/external_object_link/1.0": "BGX", + "set/run-set/generic/1.01": "BGS", } for code, expected_prefix in expected_prefixes.items(): template = templates[code] - assert template["category"] == expected_prefix + assert template["category"] != expected_prefix assert template["instance_prefix"] == expected_prefix +def test_anomaly_template_is_packaged_for_observability() -> None: + templates = _template_pack()["templates"] + anomaly = next( + template + for template in templates + if ( + template["category"], + template["type"], + template["subtype"], + template["version"], + ) + == ("ops", "anomaly-record", "generic", "1.0") + ) + + assert anomaly["instance_prefix"] == "BAN" + assert anomaly["json_addl"]["managed_by"] == "bloom" + assert anomaly["json_addl"]["semantic_category"] == "observability_anomaly" + + def test_prefix_taxonomy_rejects_forbidden_letters_and_leading_zero_examples() -> None: prefixes = { template["instance_prefix"] @@ -103,6 +121,7 @@ def test_prefixes_are_declared_in_packaged_ownership_registry() -> None: claims = registry["ownership"]["Z"] for prefix in { + "BAN", "BC", "BCT", "BCP", @@ -151,12 +170,10 @@ def test_readme_documents_taxonomy_and_historical_object_rule() -> None: readme = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") for snippet in ( - "BC*` | Containers", - "BN*` | Contents, materials, and reagents", - "BD*` | Data and evidence objects", - "BR*` | Runs and executions", - "BG*` | Generic helpers and external-object mappings", + "Bloom models physical/material execution", + "containers, materials/content, equipment", + "recursive template creation", "Existing historical objects keep their already minted EUIDs", - "Prefixes are governance and display labels only", + "issuance and governance labels only", ): assert snippet in readme diff --git a/tests/test_cli_db_command_paths.py b/tests/test_cli_db_command_paths.py index c0487b6..b93d20d 100644 --- a/tests/test_cli_db_command_paths.py +++ b/tests/test_cli_db_command_paths.py @@ -489,15 +489,23 @@ def test_seed_templates_split_core_and_client_ownership( lambda _dirs, strict: ( [ { - "category": "SYS", - "type": "system", - "subtype": "config", + "category": "actor", + "type": "user", + "subtype": "system", "version": "1.0", "instance_prefix": "SYS", "_source_file": str(core_template_path), }, { - "category": "BAC", + "category": "reference", + "type": "external_identifier", + "subtype": "tapdb_object", + "version": "1.0", + "instance_prefix": "XRF", + "_source_file": str(core_template_path), + }, + { + "category": "action", "type": "beta_lab", "subtype": "claim_material_in_queue", "version": "1.0", @@ -520,6 +528,7 @@ def fake_seed_templates(session, templates, **kwargs): # noqa: ANN001 - test do "templates": [ template["instance_prefix"] for template in templates ], + "overwrite": kwargs["overwrite"], }, ) ) @@ -579,12 +588,22 @@ def dispose(self): db_commands._seed_tapdb_templates("target", overwrite=False) assert events == [ - ("seed", {"owner_repo_name": "daylily-tapdb", "templates": ["SYS"]}), + ( + "seed", + { + "owner_repo_name": "daylily-tapdb", + "templates": ["SYS", "XRF"], + "overwrite": True, + }, + ), ( "claim", {"owner_repo_name": "bloom", "domain_code": "Z", "templates": ["BAC"]}, ), - ("seed", {"owner_repo_name": "bloom", "templates": ["BAC"]}), + ( + "seed", + {"owner_repo_name": "bloom", "templates": ["BAC"], "overwrite": False}, + ), ] assert any("tapdb_identity_prefix_config" in sql for sql in fake_session.executed) assert fake_session.committed == 1 @@ -596,31 +615,42 @@ def dispose(self): def test_retire_obsolete_template_variants_only_deletes_stale_prefixes() -> None: templates = [ { - "category": "BRM", + "category": "data", "type": "sequencing_run", "subtype": "illumina", "version": "1.0", + "instance_prefix": "BRM", "json_addl": {"semantic_category": "data"}, }, { - "category": "BRN", + "category": "data", "type": "sequencing_run", "subtype": "ont", "version": "1.0", + "instance_prefix": "BRN", "json_addl": {"semantic_category": "data"}, }, { - "category": "BCT", + "category": "material", + "type": "sample", + "subtype": "gdna", + "version": "1.0", + "instance_prefix": "BNG", + "json_addl": {"semantic_category": "content"}, + }, + { + "category": "container", "type": "tube", "subtype": "tube-generic-10ml", "version": "1.0", + "instance_prefix": "BCN", "json_addl": {"semantic_category": "container"}, }, ] current = SimpleNamespace( domain_code="Z", type="sequencing_run", - category="BRM", + category="data", subtype="illumina", version="1.0", is_deleted=False, @@ -657,6 +687,16 @@ def test_retire_obsolete_template_variants_only_deletes_stale_prefixes() -> None bstatus="active", json_addl={"semantic_category": "container"}, ) + material_content = SimpleNamespace( + domain_code="Z", + type="sample", + category="material", + subtype="gdna", + version="1.0", + is_deleted=False, + bstatus="active", + json_addl={"semantic_category": "content"}, + ) non_data = SimpleNamespace( domain_code="Z", type="sequencing_run", @@ -687,6 +727,7 @@ def all(self): stale_prefix, stale_subtype, stale_container_prefix, + material_content, non_data, ] @@ -709,13 +750,15 @@ def flush(self): domain_code="Z", ) - assert retired == 2 + assert retired == 3 assert current.is_deleted is False assert stale_prefix.is_deleted is True assert stale_prefix.bstatus == "retired" - assert stale_subtype.is_deleted is False + assert stale_subtype.is_deleted is True + assert stale_subtype.bstatus == "retired" assert stale_container_prefix.is_deleted is True assert stale_container_prefix.bstatus == "retired" + assert material_content.is_deleted is False assert non_data.is_deleted is False assert fake_session.flushed == 1 @@ -753,6 +796,7 @@ def test_claim_client_template_prefixes_ignores_reserved_core_prefixes( templates=[ {"instance_prefix": "SYS"}, {"instance_prefix": "MSG"}, + {"instance_prefix": "XRF"}, {"instance_prefix": "BAC"}, ], ) diff --git a/tests/test_common_access_logging.py b/tests/test_common_access_logging.py new file mode 100644 index 0000000..9447735 --- /dev/null +++ b/tests/test_common_access_logging.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +import logging +from types import SimpleNamespace + +from bloom_lims.app import _access_log_payload, _emit_access_log + + +def test_common_access_log_payload_is_json(caplog) -> None: + request = SimpleNamespace( + state=SimpleNamespace( + request_id="req-1", + correlation_id="corr-1", + auth_mode="ai_agent_token", + ai_agent_id="agent-1", + authorizing_human="johnm@lsmc.com", + denial_reason="forbidden", + ), + client=SimpleNamespace(host="127.0.0.1"), + method="GET", + url=SimpleNamespace(path="/api/v1/search"), + ) + payload = _access_log_payload( + request=request, + service_id="bloom", + status_code=403, + duration_ms=12.34, + route_template="/api/v1/search", + ) + + with caplog.at_level(logging.WARNING, logger="lsmc.access"): + _emit_access_log(payload, level=logging.WARNING) + + records = [record for record in caplog.records if record.name == "lsmc.access"] + body = json.loads(records[-1].getMessage()) + assert body["event"] == "request_completed" + assert body["request_id"] == "req-1" + assert body["service_id"] == "bloom" + assert body["actor"] == "johnm@lsmc.com" + assert body["ai_agent_id"] == "agent-1" + assert body["authorizing_human"] == "johnm@lsmc.com" + assert body["ip"] == "127.0.0.1" + assert body["route_template"] == "/api/v1/search" + assert body["status"] == 403 + assert body["duration_ms"] == 12.34 + assert body["denial_reason"] == "forbidden" + assert body["auth_mode"] == "ai_agent_token" diff --git a/tests/test_config_runtime.py b/tests/test_config_runtime.py index 9a0adec..627a372 100644 --- a/tests/test_config_runtime.py +++ b/tests/test_config_runtime.py @@ -348,7 +348,9 @@ def test_create_app_does_not_mount_upload_or_tmp_static_dirs( get_settings.cache_clear() app = create_app() - mount_paths = {route.path for route in app.routes} + mount_paths = { + path for route in app.routes if (path := getattr(route, "path", None)) + } assert "/static" in mount_paths assert "/templates" in mount_paths @@ -445,7 +447,7 @@ def test_tapdb_contract_defaults_match_shipped_templates(monkeypatch, tmp_path: settings = BloomSettings(storage={"upload_dir": str(tmp_path / "uploads")}) expected_tapdb_spec = read_pyproject_dependency_spec("daylily-tapdb") - assert expected_tapdb_spec == "==7.0.9" + assert expected_tapdb_spec == "==9.0.10" assert assert_tapdb_version() assert settings.tapdb.owner_repo_name == DEFAULT_TAPDB_OWNER_REPO_NAME assert settings.tapdb.domain_code == DEFAULT_TAPDB_DOMAIN_CODE @@ -503,6 +505,7 @@ def test_tapdb_contract_defaults_match_shipped_templates(monkeypatch, tmp_path: assert set(packaged_prefix_registry["ownership"]["Z"]) == { "ADT", "BAC", + "BAN", "BAR", "BBX", "BC", @@ -556,9 +559,12 @@ def test_tapdb_contract_defaults_match_shipped_templates(monkeypatch, tmp_path: "BWF", "BWS", "EDG", + "GSE", + "GVR", "MSG", "SYS", "TPX", + "XRF", } assert { claim["issuer_app_code"] diff --git a/tests/test_container_entry_runtime.py b/tests/test_container_entry_runtime.py new file mode 100644 index 0000000..00656b1 --- /dev/null +++ b/tests/test_container_entry_runtime.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from cli_core_yo import runtime as cli_runtime + + +def test_container_entry_initializes_cli_runtime(monkeypatch, tmp_path): + import bloom_lims.container_entry as entry + + config_path = tmp_path / "bloom.yaml" + config_path.write_text("application: {}\n", encoding="utf-8") + monkeypatch.setenv("BLOOM_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("PORT", "8912") + monkeypatch.setenv("HOST", "0.0.0.0") + monkeypatch.setenv("DEPLOYMENT_CODE", "inf9") + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg" / "config")) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg" / "state")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg" / "data")) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg" / "cache")) + + observed: dict[str, object] = {} + + def fake_start(**kwargs: object) -> None: + observed["kwargs"] = kwargs + observed["config_path"] = cli_runtime.get_context().config_path + + monkeypatch.setattr(entry, "_start_server", fake_start) + cli_runtime._reset() + try: + entry.main() + finally: + cli_runtime._reset() + + assert observed["config_path"] == config_path + assert observed["kwargs"] == { + "port": 8912, + "host": "0.0.0.0", + "reload": False, + "background": False, + "ssl": False, + "cert": None, + "key": None, + } diff --git a/tests/test_core_validation.py b/tests/test_core_validation.py index 0301f3d..eed3cb0 100644 --- a/tests/test_core_validation.py +++ b/tests/test_core_validation.py @@ -2,17 +2,26 @@ Tests for bloom_lims.core.validation module. """ +import json +import tempfile +from pathlib import Path + import pytest +from bloom_lims.core.template_validation import ( + TemplateDefinition, + TemplateValidator, + ValidationResult, +) from bloom_lims.core.validation import ( ValidationError, validate_euid, validate_json_addl, - validate_type, validate_not_empty, validate_positive_int, - validated, validate_schema, + validate_type, + validated, ) @@ -68,17 +77,19 @@ def test_invalid_format_with_leading_zero(self): with pytest.raises(ValidationError) as exc_info: validate_euid("CX01") assert "No leading zeros" in str(exc_info.value) + + class TestValidateJsonAddl: """Tests for validate_json_addl function.""" - + def test_valid_dict(self): """Test validation of valid dict.""" assert validate_json_addl({"key": "value"}) is True - + def test_none_allowed(self): """Test that None is allowed.""" assert validate_json_addl(None) is True - + def test_non_dict(self): """Test that non-dict raises ValidationError.""" with pytest.raises(ValidationError) as exc_info: @@ -91,6 +102,7 @@ class TestValidatedDecorator: def test_valid_arguments(self): """Test that valid arguments pass through.""" + @validated(euid=validate_euid) def process(euid: str) -> str: return f"processed: {euid}" @@ -100,6 +112,7 @@ def process(euid: str) -> str: def test_invalid_argument_raises(self): """Test that invalid arguments raise ValidationError.""" + @validated(euid=validate_euid) def process(euid: str) -> str: return f"processed: {euid}" @@ -109,6 +122,7 @@ def process(euid: str) -> str: def test_multiple_validators(self): """Test multiple validators on different arguments.""" + @validated(euid=validate_euid, data=validate_json_addl) def process(euid: str, data: dict) -> dict: return {"euid": euid, "data": data} @@ -155,16 +169,6 @@ def test_wrong_type(self): # Template Validation Tests -from bloom_lims.core.template_validation import ( - TemplateValidator, - ValidationResult, - TemplateDefinition, -) -from pathlib import Path -import tempfile -import json - - class TestValidationResult: """Tests for ValidationResult dataclass.""" @@ -224,7 +228,7 @@ def test_valid_reference_pattern(self): # Valid patterns assert validator._is_valid_reference("action/generic/test/1.0") assert validator._is_valid_reference("workflow/dna_extraction/v1/1.0") - assert validator._is_valid_reference("content/sample/blood/*/") + assert validator._is_valid_reference("material/sample/blood/*/") # Invalid patterns assert not validator._is_valid_reference("invalid") @@ -244,9 +248,7 @@ def test_validate_with_temp_directory(self): "test_workflow": { "1.0": { "singleton": "0", - "action_imports": { - "test_action": "action/generic/test/1.0" - } + "action_imports": {"test_action": "action/generic/test/1.0"}, } } } @@ -274,9 +276,7 @@ def test_grouped_action_imports_is_rejected(self): "1.0": { "singleton": "0", "action_imports": { - "default": { - "actions": {"action/generic/test/1.0": {}} - } + "default": {"actions": {"action/generic/test/1.0": {}}} }, } } @@ -313,21 +313,25 @@ class TestValidatorImports: def test_import_validation_error(self): """Test ValidationError import.""" from bloom_lims.core.validation import ValidationError + assert ValidationError is not None def test_import_validate_euid(self): """Test validate_euid import.""" from bloom_lims.core.validation import validate_euid + assert callable(validate_euid) def test_import_validate_json_addl(self): """Test validate_json_addl import.""" from bloom_lims.core.validation import validate_json_addl + assert callable(validate_json_addl) def test_import_validated_decorator(self): """Test validated decorator import.""" from bloom_lims.core.validation import validated + assert callable(validated) @@ -361,7 +365,6 @@ def test_validate_schema_missing_required(self): def test_validate_not_empty_with_values(self): """Test validate_not_empty with various values.""" - from bloom_lims.core.validation import validate_not_empty assert validate_not_empty("hello") is True assert validate_not_empty(" test ") is True @@ -370,7 +373,7 @@ def test_validate_not_empty_with_values(self): def test_validate_not_empty_with_empty(self): """Test validate_not_empty with empty values.""" - from bloom_lims.core.validation import validate_not_empty, ValidationError + from bloom_lims.core.validation import ValidationError with pytest.raises(ValidationError): validate_not_empty("") @@ -383,7 +386,6 @@ def test_validate_not_empty_with_empty(self): def test_validate_positive_int_valid(self): """Test validate_positive_int with valid values.""" - from bloom_lims.core.validation import validate_positive_int assert validate_positive_int(1) is True assert validate_positive_int(100) is True @@ -391,7 +393,7 @@ def test_validate_positive_int_valid(self): def test_validate_positive_int_invalid(self): """Test validate_positive_int with invalid values.""" - from bloom_lims.core.validation import validate_positive_int, ValidationError + from bloom_lims.core.validation import ValidationError with pytest.raises(ValidationError): validate_positive_int(0) @@ -410,54 +412,49 @@ def test_validate_euid_with_prefix(self): # Valid EUIDs require PREFIX (2-3 uppercase letters) + sequence number (no leading zeros) # Pattern: [A-Z]{2,3}[1-9][0-9]*$ - assert validate_euid("CX1") == True - assert validate_euid("WF123") == True - assert validate_euid("MRX42") == True + assert validate_euid("CX1") is True + assert validate_euid("WF123") is True + assert validate_euid("MRX42") is True def test_validate_json_addl_with_dict(self): """Test validate_json_addl with dictionary.""" from bloom_lims.core.validation import validate_json_addl # Valid dict should pass - assert validate_json_addl({"key": "value"}) == True - assert validate_json_addl({}) == True + assert validate_json_addl({"key": "value"}) is True + assert validate_json_addl({}) is True def test_validate_json_addl_with_none(self): """Test validate_json_addl with None.""" from bloom_lims.core.validation import validate_json_addl # None should be valid (optional field) - assert validate_json_addl(None) == True + assert validate_json_addl(None) is True def test_validate_type_container(self): """Test validate_type with container type.""" - from bloom_lims.core.validation import validate_type - assert validate_type("container") == True + assert validate_type("container") is True def test_validate_type_content(self): """Test validate_type with content type.""" - from bloom_lims.core.validation import validate_type - assert validate_type("content") == True + assert validate_type("content") is True def test_validate_type_workflow(self): """Test validate_type with workflow type.""" - from bloom_lims.core.validation import validate_type - assert validate_type("workflow") == True + assert validate_type("workflow") is True def test_validate_not_empty_with_string(self): """Test validate_not_empty with non-empty string.""" - from bloom_lims.core.validation import validate_not_empty - assert validate_not_empty("test") == True + assert validate_not_empty("test") is True def test_validate_not_empty_with_list(self): """Test validate_not_empty with non-empty list.""" - from bloom_lims.core.validation import validate_not_empty - assert validate_not_empty([1, 2, 3]) == True + assert validate_not_empty([1, 2, 3]) is True def test_validate_schema_with_valid_data(self): """Test validate_schema with valid data.""" @@ -465,7 +462,7 @@ def test_validate_schema_with_valid_data(self): schema = { "name": {"type": str, "required": True}, - "count": {"type": int, "required": False} + "count": {"type": int, "required": False}, } data = {"name": "test", "count": 5} errors = validate_schema(data, schema) @@ -475,9 +472,7 @@ def test_validate_schema_with_missing_required(self): """Test validate_schema with missing required field.""" from bloom_lims.core.validation import validate_schema - schema = { - "name": {"type": str, "required": True} - } + schema = {"name": {"type": str, "required": True}} data = {} errors = validate_schema(data, schema) assert len(errors) > 0 diff --git a/tests/test_cytoscape_dag.py b/tests/test_cytoscape_dag.py index 1e2ae8c..5728d5a 100644 --- a/tests/test_cytoscape_dag.py +++ b/tests/test_cytoscape_dag.py @@ -13,12 +13,12 @@ - Graph data structure validation """ -import pytest -import json import os -from pathlib import Path -from unittest.mock import MagicMock, patch, AsyncMock from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest from bloom_lims.gui.routes.graph import _build_dag_filename @@ -33,9 +33,7 @@ def test_valid_dag_structure(self): "nodes": [ {"data": {"id": "CX1", "euid": "CX1", "obj_type": "container"}} ], - "edges": [ - {"data": {"source": "CX1", "target": "CX2", "id": "LX1"}} - ] + "edges": [{"data": {"source": "CX1", "target": "CX2", "id": "LX1"}}], } } assert "elements" in valid_dag @@ -45,8 +43,15 @@ def test_valid_dag_structure(self): def test_node_required_fields(self): """Test that nodes have required fields for Cytoscape rendering.""" required_fields = ["id", "euid", "obj_type"] - node = {"data": {"id": "CX123", "euid": "CX123", "obj_type": "container", - "name": "Test", "color": "#8B00FF"}} + node = { + "data": { + "id": "CX123", + "euid": "CX123", + "obj_type": "container", + "name": "Test", + "color": "#8B00FF", + } + } for field in required_fields: assert field in node["data"], f"Missing required field: {field}" @@ -54,9 +59,15 @@ def test_node_required_fields(self): def test_edge_required_fields(self): """Test that edges have required fields.""" required_fields = ["source", "target", "id"] - edge = {"data": {"source": "CX1", "target": "CX2", "id": "LX1", - "relationship_type": "generic"}} - + edge = { + "data": { + "source": "CX1", + "target": "CX2", + "id": "LX1", + "relationship_type": "generic", + } + } + for field in required_fields: assert field in edge["data"], f"Missing required field: {field}" @@ -97,23 +108,23 @@ def _detect_cycle(self, existing_edges, source, target): """Helper function implementing cycle detection algorithm.""" if source == target: return True - + # Build adjacency list graph = {} for src, tgt in existing_edges: if src not in graph: graph[src] = [] graph[src].append(tgt) - + # Add proposed edge temporarily if source not in graph: graph[source] = [] graph[source].append(target) - + # DFS to detect cycle from target to source visited = set() stack = [target] - + while stack: current = stack.pop() if current == source: @@ -123,7 +134,7 @@ def _detect_cycle(self, existing_edges, source, target): for neighbor in graph.get(current, []): if neighbor not in visited: stack.append(neighbor) - + return False @@ -375,7 +386,7 @@ def test_add_edge_to_graph(self): {"data": {"id": "CX1"}}, {"data": {"id": "CX2"}}, ], - "edges": [] + "edges": [], } } new_edge = {"data": {"source": "CX1", "target": "CX2", "id": "LX1"}} @@ -392,20 +403,18 @@ def test_remove_node_removes_connected_edges(self): {"data": {"id": "CX1"}}, {"data": {"id": "CX2"}}, ], - "edges": [ - {"data": {"source": "CX1", "target": "CX2", "id": "LX1"}} - ] + "edges": [{"data": {"source": "CX1", "target": "CX2", "id": "LX1"}}], } } # Remove node CX1 node_to_remove = "CX1" graph["elements"]["nodes"] = [ - n for n in graph["elements"]["nodes"] - if n["data"]["id"] != node_to_remove + n for n in graph["elements"]["nodes"] if n["data"]["id"] != node_to_remove ] graph["elements"]["edges"] = [ - e for e in graph["elements"]["edges"] + e + for e in graph["elements"]["edges"] if e["data"]["source"] != node_to_remove and e["data"]["target"] != node_to_remove ] @@ -488,7 +497,7 @@ def mock_request(self): def test_get_dagv2_returns_empty_when_file_missing(self, mock_request): """Test get_dagv2 returns empty structure when file doesn't exist.""" - with patch('os.path.exists', return_value=False): + with patch("os.path.exists", return_value=False): # Simulating the endpoint behavior dag_fn = mock_request.session["user_data"]["dag_fnv2"] dag_data = {"elements": {"nodes": [], "edges": []}} @@ -511,10 +520,7 @@ def test_delete_object_endpoint_structure(self): def test_add_new_edge_request_structure(self): """Test add_new_edge endpoint request structure.""" - request_data = { - "parent_euid": "CX1", - "child_euid": "CX2" - } + request_data = {"parent_euid": "CX1", "child_euid": "CX2"} assert "parent_euid" in request_data assert "child_euid" in request_data @@ -530,19 +536,19 @@ def test_all_edge_nodes_exist(self): {"data": {"id": "CX1"}}, {"data": {"id": "CX2"}}, ], - "edges": [ - {"data": {"source": "CX1", "target": "CX2", "id": "LX1"}} - ] + "edges": [{"data": {"source": "CX1", "target": "CX2", "id": "LX1"}}], } } node_ids = {n["data"]["id"] for n in graph["elements"]["nodes"]} for edge in graph["elements"]["edges"]: - assert edge["data"]["source"] in node_ids, \ + assert edge["data"]["source"] in node_ids, ( f"Edge source {edge['data']['source']} not in nodes" - assert edge["data"]["target"] in node_ids, \ + ) + assert edge["data"]["target"] in node_ids, ( f"Edge target {edge['data']['target']} not in nodes" + ) def test_no_duplicate_node_ids(self): """Test that there are no duplicate node IDs.""" @@ -568,8 +574,9 @@ def test_no_duplicate_edge_ids(self): def test_valid_euid_format_in_nodes(self): """Test that node EUIDs follow valid format.""" import re + # EUID pattern: 2-3 uppercase letters + sequence number (no leading zeros) - euid_pattern = re.compile(r'^[A-Z]{2,3}[1-9][0-9]*$') + euid_pattern = re.compile(r"^[A-Z]{2,3}[1-9][0-9]*$") valid_euids = ["CX1", "CX123", "WX1000", "MRX42"] invalid_euids = ["CX01", "cx1", "C1", "BCN-123"] @@ -628,9 +635,13 @@ def test_fuzzy_search_no_matches(self): """Test fuzzy search with no matches.""" nodes = [{"id": "CX1", "name": "Test", "obj_type": "container"}] query = "xyz" - matches = [n for n in nodes if query.lower() in n["id"].lower() or - query.lower() in n["name"].lower() or - query.lower() in n["obj_type"].lower()] + matches = [ + n + for n in nodes + if query.lower() in n["id"].lower() + or query.lower() in n["name"].lower() + or query.lower() in n["obj_type"].lower() + ] assert len(matches) == 0 @@ -664,6 +675,7 @@ def test_layout_data_includes_zoom_pan(self): def test_layout_expiration_check(self): """Test layout expiration logic (7 days).""" import time + max_age_ms = 7 * 24 * 60 * 60 * 1000 # 7 days in milliseconds current_time = int(time.time() * 1000) @@ -687,7 +699,7 @@ def test_timestamp_based_click_detection(self): clicks = [0, 100, 200] # timestamps in ms click_count = 1 for i in range(1, len(clicks)): - if clicks[i] - clicks[i-1] < click_threshold_ms: + if clicks[i] - clicks[i - 1] < click_threshold_ms: click_count += 1 else: click_count = 1 @@ -702,7 +714,7 @@ def test_click_reset_on_timeout(self): clicks = [0, 100, 700] # 700ms gap resets count click_count = 1 for i in range(1, len(clicks)): - if clicks[i] - clicks[i-1] < click_threshold_ms: + if clicks[i] - clicks[i - 1] < click_threshold_ms: click_count += 1 else: click_count = 1 @@ -802,3 +814,19 @@ def test_graph_js_preserves_tapdb_gestures_and_restored_shortcuts(self): assert 'runCogsForEuid("children", node.id()' in graph_js assert "runNeighborhoodFromNode(node)" in graph_js assert "openNodeActionDialog(node.data())" in graph_js + + def test_graph_js_requires_z_for_mouse_wheel_zoom_and_points_arrows_to_parents( + self, + ): + graph_js = Path("static/js/graph.js").read_text(encoding="utf-8") + template = Path("templates/modern/dag_explorer.html").read_text( + encoding="utf-8" + ) + + assert "configureCytoscapeInteractions(container);" in graph_js + assert 'key === "z"' in graph_js + assert '"wheel"' in graph_js + assert "stopImmediatePropagation()" in graph_js + assert '"source-arrow-shape": "triangle"' in graph_js + assert '"target-arrow-shape": "none"' in graph_js + assert "Arrowheads point to parent nodes." in template diff --git a/tests/test_deploy_contract.py b/tests/test_deploy_contract.py index baf5d85..5701efe 100644 --- a/tests/test_deploy_contract.py +++ b/tests/test_deploy_contract.py @@ -43,3 +43,11 @@ def test_db_build_help_mentions_target_option() -> None: result = runner.invoke(build_app(), ["db", "build", "--help"]) assert result.exit_code == 0 assert "--target" in result.output + + +def test_container_packages_gui_auth_module() -> None: + dockerfile = (PROJECT_ROOT / "Dockerfile").read_text(encoding="utf-8") + pyproject = (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8") + + assert "COPY auth ./auth" in dockerfile + assert '"auth*"' in pyproject diff --git a/tests/test_domain_workflows_helpers.py b/tests/test_domain_workflows_helpers.py index e243353..768e3c6 100644 --- a/tests/test_domain_workflows_helpers.py +++ b/tests/test_domain_workflows_helpers.py @@ -4,10 +4,13 @@ import pytest -pytestmark = pytest.mark.skip(reason="Workflow helper surface is retired in queue-centric Bloom beta.") +pytest.skip( + "Workflow helper surface is retired in queue-centric Bloom beta.", + allow_module_level=True, +) -import bloom_lims.domain.workflows as workflows_mod -from bloom_lims.domain.workflows import BloomWorkflowStep +import bloom_lims.domain.workflows as workflows_mod # noqa: E402 +from bloom_lims.domain.workflows import BloomWorkflowStep # noqa: E402 def _subject() -> BloomWorkflowStep: @@ -59,7 +62,9 @@ def test_iter_active_child_instances_filters_deleted_and_type(): s = _subject() keep = _obj("KEEP", category="container", type_name="well") deleted_lineage = _obj("DEL-LIN", category="container", type_name="well") - deleted_child = _obj("DEL-CHILD", category="container", type_name="well", is_deleted=True) + deleted_child = _obj( + "DEL-CHILD", category="container", type_name="well", is_deleted=True + ) wrong_type = _obj("WRONG", category="container", type_name="plate") parent = SimpleNamespace( parent_of_lineages=[ @@ -69,7 +74,9 @@ def test_iter_active_child_instances_filters_deleted_and_type(): _lineage(wrong_type), ] ) - found = list(s._iter_active_child_instances(parent, category="container", type_name="well")) + found = list( + s._iter_active_child_instances(parent, category="container", type_name="well") + ) assert [child.euid for _, child in found] == ["KEEP"] @@ -105,7 +112,10 @@ def test_get_workflow_steps_sorts_by_step_number(): def test_default_queue_name_handles_special_tokens(): s = _subject() - assert s._default_queue_name("input-gdna-normalization-eligible") == "Input gDNA Normalization Eligible" + assert ( + s._default_queue_name("input-gdna-normalization-eligible") + == "Input gDNA Normalization Eligible" + ) assert s._default_queue_name("ont-novaseq-libprep") == "ONT Novaseq LibPrep" @@ -181,16 +191,33 @@ def test_content_helpers_cover_first_active_child_and_occupancy(): def test_get_plate_wells_in_address_order_and_destination_resolution(): s = _subject() - w2 = _obj("W2", category="container", type_name="well", cont_address={"row_idx": "2", "col_idx": "1"}) - w1 = _obj("W1", category="container", type_name="well", cont_address={"row_idx": "1", "col_idx": "2"}) - w0 = _obj("W0", category="container", type_name="well", cont_address={"row_idx": "1", "col_idx": "1"}) + w2 = _obj( + "W2", + category="container", + type_name="well", + cont_address={"row_idx": "2", "col_idx": "1"}, + ) + w1 = _obj( + "W1", + category="container", + type_name="well", + cont_address={"row_idx": "1", "col_idx": "2"}, + ) + w0 = _obj( + "W0", + category="container", + type_name="well", + cont_address={"row_idx": "1", "col_idx": "1"}, + ) plate = _obj("PLATE1", category="container", type_name="plate") plate.parent_of_lineages = [_lineage(w2), _lineage(w1), _lineage(w0)] ordered = s._get_plate_wells_in_address_order(plate) assert [well.euid for well in ordered] == ["W0", "W1", "W2"] - s._resolve_well_by_plate_and_address = lambda plate_euid, addr: _obj(f"{plate_euid}.{addr}") + s._resolve_well_by_plate_and_address = lambda plate_euid, addr: _obj( + f"{plate_euid}.{addr}" + ) assert s._resolve_destination_well_ref(plate, "B2").euid == "PLATE1.B2" assert s._resolve_destination_well_ref(plate, "PLATE1.A1").euid == "PLATE1.A1" with pytest.raises(Exception, match="does not target destination plate"): @@ -204,20 +231,34 @@ def test_parse_mapping_rows_and_quant_rows_validation(): "mapping_csv_text": "source_ref,destination_ref\nPLATE1.A1,A1\nPLATE1.B1,B1\n" } } - assert s._parse_mapping_rows(mapping_ds) == [("PLATE1.A1", "A1"), ("PLATE1.B1", "B1")] + assert s._parse_mapping_rows(mapping_ds) == [ + ("PLATE1.A1", "A1"), + ("PLATE1.B1", "B1"), + ] with pytest.raises(Exception, match="Invalid mapping row"): - s._parse_mapping_rows({"captured_data": {"mapping_csv_text": "source_ref,destination_ref\nONLYSOURCE\n"}}) + s._parse_mapping_rows( + { + "captured_data": { + "mapping_csv_text": "source_ref,destination_ref\nONLYSOURCE\n" + } + } + ) quant_ds = { "captured_data": { "quant_csv_text": "plate_euid,well,quant\nPLATE1,A1,1.5\nPLATE1,B1,2.0\n" } } - assert s._parse_quant_rows(quant_ds) == [("PLATE1", "A1", 1.5), ("PLATE1", "B1", 2.0)] + assert s._parse_quant_rows(quant_ds) == [ + ("PLATE1", "A1", 1.5), + ("PLATE1", "B1", 2.0), + ] with pytest.raises(Exception, match="Quant value must be float"): - s._parse_quant_rows({"captured_data": {"quant_csv_text": "PLATE1,A1,not-a-float\n"}}) + s._parse_quant_rows( + {"captured_data": {"quant_csv_text": "PLATE1,A1,not-a-float\n"}} + ) def test_get_library_prep_targets_from_properties_or_defaults(): @@ -233,7 +274,9 @@ def test_get_library_prep_targets_from_properties_or_defaults(): ] wf_with_string = _obj("WF2", category="workflow") - wf_with_string.json_addl["properties"]["library_prep_queue_subtypes"] = "ont-libprep-eligible" + wf_with_string.json_addl["properties"]["library_prep_queue_subtypes"] = ( + "ont-libprep-eligible" + ) assert s._get_library_prep_targets(wf_with_string) == ["ont-libprep-eligible"] wf_default = _obj("WF3", category="workflow") @@ -273,7 +316,9 @@ def test_route_instances_to_library_prep_queues_links_each_instance(): def test_get_or_create_queue_step_returns_existing_without_creation(): s = _subject() existing = _obj("STEP-EXIST", category="workflow_step", subtype="target") - s._get_workflow_steps = lambda _wf, subtype=None: [existing] if subtype == "target" else [existing] + s._get_workflow_steps = lambda _wf, subtype=None: ( + [existing] if subtype == "target" else [existing] + ) s.create_instance_by_code = lambda *_args, **_kwargs: _obj("STEP-NEW") s.create_generic_instance_lineage_by_euids = lambda *_args, **_kwargs: None s.session = SimpleNamespace(commit=lambda: None) @@ -283,7 +328,9 @@ def test_get_or_create_queue_step_returns_existing_without_creation(): def test_get_or_create_queue_step_creates_new_step_with_incremented_number(): s = _subject() - highest = _obj("STEP-HIGH", category="workflow_step", subtype="other", step_number="4") + highest = _obj( + "STEP-HIGH", category="workflow_step", subtype="other", step_number="4" + ) call_state = {"count": 0} def _get_steps(_wf, subtype=None): @@ -295,11 +342,11 @@ def _get_steps(_wf, subtype=None): created = _obj("STEP-NEW", category="workflow_step", subtype="target-queue") captured = {"layout": "", "payload": None, "lineage": None} s._get_workflow_steps = _get_steps - s.create_instance_by_code = ( - lambda layout, payload: captured.update({"layout": layout, "payload": payload}) or created + s.create_instance_by_code = lambda layout, payload: ( + captured.update({"layout": layout, "payload": payload}) or created ) - s.create_generic_instance_lineage_by_euids = ( - lambda parent, child: captured.update({"lineage": (parent, child)}) + s.create_generic_instance_lineage_by_euids = lambda parent, child: captured.update( + {"lineage": (parent, child)} ) s.session = SimpleNamespace(commit=lambda: None) workflow = _obj("WF1", category="workflow") @@ -329,7 +376,9 @@ def test_ensure_lineage_skips_existing_and_creates_when_missing(): def test_fill_plate_from_sources_undirected_and_directed_error_paths(): s = _subject() - queue_step = _obj("Q1", category="workflow_step", subtype="input-gdna-normalization-eligible") + queue_step = _obj( + "Q1", category="workflow_step", subtype="input-gdna-normalization-eligible" + ) plate = _obj("PLATE1", category="container", type_name="plate") source1 = _obj("SRC1", category="container", type_name="well") source2 = _obj("SRC2", category="container", type_name="well") @@ -359,7 +408,9 @@ def _resolve(token): s._resolve_destination_well_ref = lambda _plate, _dest: dst1 s._is_well_occupied = lambda _well: True with pytest.raises(Exception, match="Destination well already occupied"): - s._fill_plate_from_sources(queue_step, plate, ["A1"], directed_mapping=[("A1", "A1")]) + s._fill_plate_from_sources( + queue_step, plate, ["A1"], directed_mapping=[("A1", "A1")] + ) def test_fill_plate_from_sources_rejects_insufficient_wells(): @@ -374,7 +425,9 @@ def test_fill_plate_from_sources_rejects_insufficient_wells(): s._is_well_occupied = lambda _well: False s._ensure_lineage = lambda *_args, **_kwargs: None s._get_first_active_child_content = lambda _instance: None - s._create_output_gdna_content = lambda normalized=False: _obj("OUT", category="content", type_name="sample") + s._create_output_gdna_content = lambda normalized=False: _obj( + "OUT", category="content", type_name="sample" + ) with pytest.raises(Exception, match="Not enough available wells"): s._fill_plate_from_sources(queue_step, plate, ["A1", "B1"]) @@ -387,7 +440,9 @@ def test_create_output_gdna_content_sets_normalization_flags(monkeypatch): touched: list[str] = [] s.query_template_by_component_v2 = lambda *_args: [template] s.create_instances = lambda _template_euid: [[content_obj]] - monkeypatch.setattr(workflows_mod, "flag_modified", lambda _obj, field: touched.append(field)) + monkeypatch.setattr( + workflows_mod, "flag_modified", lambda _obj, field: touched.append(field) + ) out = s._create_output_gdna_content(normalized=True) assert out.euid == "MX1" diff --git a/tests/test_gui_endpoints.py b/tests/test_gui_endpoints.py index a290ad0..f7a117c 100644 --- a/tests/test_gui_endpoints.py +++ b/tests/test_gui_endpoints.py @@ -451,11 +451,11 @@ class TestQueueEndpoints: """Tests for queue management endpoints.""" def test_queue_details_returns_html(self, client): - """Test queue details returns HTML.""" + """Bare queue details route renders the retired queue GUI explanation.""" response = client.get("/queue_details") - assert response.status_code in [200, 422, 500] - if response.status_code == 200: - assert "text/html" in response.headers["content-type"] + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "Queue Runtime Hidden" in response.text def test_set_filter(self, client): """Test set filter endpoint works.""" @@ -653,11 +653,11 @@ def test_footer_has_version(self, client): assert "footer" in content.lower() def test_pages_have_breadcrumb_or_header(self, client): - """Queue runtime remains visible from the dashboard after assay-page retirement.""" + """Dashboard renders normal modern page header after queue GUI retirement.""" response = client.get("/") assert response.status_code == 200 content = response.text - assert "page-header" in content or "Queue Runtime" in content + assert "page-header" in content class TestModernAPIs: @@ -960,7 +960,7 @@ def test_dashboard_has_navigation_links(self, client): # Check for main navigation links assert 'href="/"' in content # Dashboard - assert 'href="/queue_details"' in content + assert 'href="/queue_details"' not in content assert 'href="/assays"' not in content assert 'href="/workflows"' not in content assert 'href="/admin"' in content @@ -1789,7 +1789,8 @@ class TestQueueEndpointsV2: def test_queue_details_no_params(self, client): """Test queue details without parameters.""" response = client.get("/queue_details") - assert response.status_code in [200, 302, 307, 400, 422, 500] + assert response.status_code == 200 + assert "Queue Runtime Hidden" in response.text def test_queue_details_with_euid(self, client): """Test queue details with EUID.""" diff --git a/tests/test_lab_actions_api.py b/tests/test_lab_actions_api.py new file mode 100644 index 0000000..500cd01 --- /dev/null +++ b/tests/test_lab_actions_api.py @@ -0,0 +1,1011 @@ +"""API coverage for Bloom lab actions.""" + +from __future__ import annotations + +import os +import zipfile +from io import BytesIO +from unittest.mock import patch + +os.environ["BLOOM_DEV_AUTH_BYPASS"] = "true" +os.environ["BLOOM_OAUTH"] = "no" + +from fastapi.testclient import TestClient + +from bloom_lims.api.v1 import container_actions as container_actions_api +from bloom_lims.domain.lab_action_spreadsheets import parse_workbook +from main import app + + +def _client() -> TestClient: + return TestClient(app) + + +def _create_object( + client: TestClient, + *, + category: str, + type_name: str, + subtype: str, + name: str, + version: str = "1.0", + properties: dict | None = None, + count: int = 1, +) -> dict: + response = client.post( + "/api/v1/object-creation/create", + json={ + "category": category, + "type": type_name, + "subtype": subtype, + "version": version, + "name": name, + "properties": properties or {}, + "count": count, + }, + ) + assert response.status_code == 200, response.text + return response.json() + + +def _link_material( + client: TestClient, *, container_euid: str, content_euid: str +) -> None: + response = client.post( + "/api/v1/lineages/", + json={ + "parent_euid": container_euid, + "child_euid": content_euid, + "relationship_type": "HOLDS_MATERIAL", + }, + ) + assert response.status_code == 200, response.text + + +def _create_filled_tube(client: TestClient, *, suffix: str) -> tuple[str, str]: + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + tube = _create_object( + client, + category="container", + type_name="tube", + subtype="tube-generic-10ml", + name=f"pytest incoming tube {suffix}", + ) + content = _create_object( + client, + category="content", + type_name="specimen", + subtype="buccal-swab", + name=f"pytest buccal specimen {suffix}", + ) + _link_material(client, container_euid=tube["euid"], content_euid=content["euid"]) + return tube["euid"], content["euid"] + + +def _create_plate(client: TestClient, *, name: str) -> str: + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + plate = _create_object( + client, + category="container", + type_name="plate", + subtype="fixed-plate-96", + name=name, + ) + return plate["euid"] + + +def _create_reagent_index(client: TestClient, *, name: str) -> str: + reagent = _create_object( + client, + category="content", + type_name="reagent", + subtype="sequencing-index", + name=name, + ) + return reagent["euid"] + + +def _create_instrument(client: TestClient, *, name: str) -> str: + instrument = _create_object( + client, + category="equipment", + type_name="sequencers", + subtype="novaseq-6000", + name=name, + ) + return instrument["euid"] + + +def test_lab_actions_full_ilmn_flow_and_csv_export() -> None: + client = _client() + tube_1, _ = _create_filled_tube(client, suffix="a") + tube_2, _ = _create_filled_tube(client, suffix="b") + + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + extraction = client.post( + "/api/v1/lab-actions/extraction-plates", + json={ + "mode": "auto", + "tube_euids": [tube_1, tube_2], + "plate_name": "pytest extraction plate", + "create_run_set": True, + }, + ) + assert extraction.status_code == 200, extraction.text + extraction_payload = extraction.json() + assert extraction_payload["plate_euid"] + assert extraction_payload["run_set_euid"] + assert [item["well_name"] for item in extraction_payload["mappings"]] == [ + "A1", + "A2", + ] + assert all(item["output_content_euid"] for item in extraction_payload["mappings"]) + + plate_csv = client.get( + f"/api/v1/lab-actions/plates/{extraction_payload['plate_euid']}/mapping.csv" + ) + assert plate_csv.status_code == 200, plate_csv.text + assert "tube_euid,plate_euid,well_euid,well_row,well_col" in plate_csv.text + assert tube_1 in plate_csv.text + + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + library = client.post( + "/api/v1/lab-actions/seq-library-plates", + json={ + "mode": "plate_1_to_1", + "source_plate_euid": extraction_payload["plate_euid"], + "plate_name": "pytest library plate", + }, + ) + assert library.status_code == 200, library.text + library_payload = library.json() + assert library_payload["plate_euid"] + assert len(library_payload["mappings"]) == 2 + library_contents = [ + item["library_content_euid"] for item in library_payload["mappings"] + ] + + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + pool = client.post( + "/api/v1/lab-actions/seq-library-pools", + json={ + "input_euids": library_contents, + "platform": "ILMN", + "pool_name": "pytest sequencing pool", + }, + ) + assert pool.status_code == 200, pool.text + pool_payload = pool.json() + assert pool_payload["pool_tube_euid"] + assert pool_payload["pool_content_euid"] + assert pool_payload["member_euids"] == library_contents + + seq_run = client.post( + "/api/v1/lab-actions/seq-runs", + json={ + "pool_tube_euid": pool_payload["pool_tube_euid"], + "pool_content_euid": pool_payload["pool_content_euid"], + "platform": "ILMN", + "operator": "pytest@example.com", + "flowcell_barcode": "PYTEST-FLOWCELL", + "status": "created", + }, + ) + assert seq_run.status_code == 200, seq_run.text + run_payload = seq_run.json() + assert run_payload["set_euid"] + assert run_payload["assignments"] + assert run_payload["assignments"][0]["flowcell_id"] == "PYTEST-FLOWCELL" + assert run_payload["assignments"][0]["lane"] == "1" + + sample_sheet = client.get( + f"/api/v1/lab-actions/seq-runs/{run_payload['set_euid']}/samplesheet" + ) + assert sample_sheet.status_code == 200, sample_sheet.text + assert "[Header]" in sample_sheet.text + assert f"RunSetEUID,{run_payload['set_euid']}" in sample_sheet.text + assert "PYTEST-FLOWCELL" in sample_sheet.text + for library_content in library_contents: + assert library_content in sample_sheet.text + + +def test_container_actions_validate_and_explicit_execute_dispatch(monkeypatch) -> None: + client = _client() + tube_euid = "Z-BCT-CONTAINER-ACTION" + + def fake_object_kind(euid, user): + return { + "euid": euid, + "exists": True, + "kind": "container/tube/tube-generic-10ml/1.0", + } + + monkeypatch.setattr(container_actions_api, "_object_kind", fake_object_kind) + + validation = client.post( + "/api/v1/container-actions/validate", + json={ + "rows": f"{tube_euid},CREATE.container/plate/96-well/1.0,A1\n", + "child_name": "pytest container action plate", + "child_content_type": "content/gdna/generic/1.0", + }, + ) + assert validation.status_code == 200, validation.text + payload = validation.json() + assert payload["valid"] is True + assert payload["row_count"] == 1 + assert payload["planned"][0]["source_euid"] == tube_euid + assert payload["planned"][0]["create_type"] == "container/plate/96-well/1.0" + assert payload["planned"][0]["target_well"] == "A1" + assert payload["source_types"][tube_euid]["exists"] is True + + tsv_validation = client.post( + "/api/v1/container-actions/validate", + json={ + "rows": f"{tube_euid}\tCREATE.container/plate/96-well/1.0\tB2\n", + "child_name": "pytest tsv container action plate", + "child_content_type": "content/gdna/generic/1.0", + }, + ) + assert tsv_validation.status_code == 200, tsv_validation.text + tsv_payload = tsv_validation.json() + assert tsv_payload["valid"] is True + assert tsv_payload["planned"][0]["create_type"] == "container/plate/96-well/1.0" + assert tsv_payload["planned"][0]["target_well"] == "B2" + + execution = client.post( + "/api/v1/container-actions/execute", + json={"rows": f"{tube_euid},CREATE.container/plate/96-well/1.0,A1\n"}, + ) + assert execution.status_code == 400, execution.text + assert "operation_type is required" in execution.text + + class FakeLabActionsService: + def __init__(self): + self.closed = False + + def create_lab_set(self, request): + return { + "set_euid": "Z-SET-CONTAINER-ACTION", + "members": request.members, + "external_members": request.external_members, + } + + def close(self): + self.closed = True + + fake_service = FakeLabActionsService() + monkeypatch.setattr( + container_actions_api, + "_service_for_user", + lambda user: fake_service, + ) + + explicit = client.post( + "/api/v1/container-actions/execute", + json={ + "rows": f"{tube_euid},CREATE.set/run-set/generic/1.01,A1\n", + "operation_type": "lab_set", + "operation_payload": { + "name": "pytest container action set", + "members": [tube_euid], + "metadata": {"source": "container-actions-test"}, + }, + }, + ) + assert explicit.status_code == 200, explicit.text + executed = explicit.json() + assert executed["operation_type"] == "lab_set" + assert executed["validation"]["row_count"] == 1 + assert executed["result"]["set_euid"] + assert executed["result"]["members"] == [tube_euid] + assert fake_service.closed is True + + +def test_lab_actions_validation_and_ont_samplesheet_download() -> None: + client = _client() + invalid = client.post( + "/api/v1/lab-actions/extraction-plates", + json={"mode": "auto", "tube_euids": []}, + ) + assert invalid.status_code == 422 + + tube, _ = _create_filled_tube(client, suffix="non-ilmn") + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + pool = client.post( + "/api/v1/lab-actions/seq-library-pools", + json={ + "input_euids": [tube], + "platform": "ONT", + "pool_name": "pytest ont pool", + }, + ) + assert pool.status_code == 200, pool.text + run = client.post( + "/api/v1/lab-actions/seq-runs", + json={ + "pool_tube_euid": pool.json()["pool_tube_euid"], + "pool_content_euid": pool.json()["pool_content_euid"], + "platform": "ONT", + "flowcell_barcode": "ONT-FLOWCELL", + }, + ) + assert run.status_code == 200, run.text + sample_sheet = client.get( + f"/api/v1/lab-actions/seq-runs/{run.json()['set_euid']}/samplesheet" + ) + assert sample_sheet.status_code == 200, sample_sheet.text + assert sample_sheet.headers["content-type"].startswith("text/tab-separated-values") + assert "_ONT_manifest.tsv" in sample_sheet.headers["content-disposition"] + assert "sample_id\talias\tbarcode\trun_set_euid" in sample_sheet.text + assert run.json()["set_euid"] in sample_sheet.text + assert "ONT-FLOWCELL" in sample_sheet.text + + +def test_object_creation_count_and_print_euid_mock() -> None: + client = _client() + created = _create_object( + client, + category="set", + type_name="run-set", + subtype="generic", + version="1.01", + name="pytest run set", + count=3, + ) + assert len(created["created_euids"]) == 3 + assert created["euid"] == created["created_euids"][0] + + with patch("bloom_lims.domain.lab_actions.ZebraDayService") as service_cls: + service = service_cls.return_value + service.submit_print_job.side_effect = [ + {"job_euid": f"JOB-{index}", "status": "queued"} for index in range(3) + ] + response = client.post( + "/api/v1/lab-actions/print-euids", + json={ + "euids": created["created_euids"], + "lab": "SSF", + "printer_id": "pytest-printer", + "label_zpl_style": "euid", + "copies": 1, + "dry_run": True, + }, + ) + assert response.status_code == 200, response.text + assert response.json()["printed"] == 0 + assert response.json()["dry_run"] is True + assert service.submit_print_job.call_count == 0 + assert len(response.json()["results"]) == 3 + + recursive_batch = client.post( + "/api/v1/object-creation/create", + json={ + "category": "container", + "type": "plate", + "subtype": "fixed-plate-96", + "version": "1.0", + "name": "unsafe recursive plate batch", + "count": 2, + }, + ) + assert recursive_batch.status_code == 400 + assert "recursive templates" in recursive_batch.json()["detail"] + + +def test_lab_actions_gui_renders_wizard() -> None: + client = _client() + response = client.get("/lab-actions") + assert response.status_code == 200, response.text + html = response.text + assert "Lab Actions" in html + assert "Extraction Plate" in html + assert "Sequencing-Library Plate" in html + assert "Sequencing Pool Tube" in html + assert "Sequencing Run Set" in html + assert "/api/v1/lab-actions/extraction-plates" in html + assert 'data-testid="bloom-lab-library-assignments"' in html + assert 'data-testid="bloom-lab-run-platform"' in html + assert "mode: 'directed'" in html + assert "Download ${platform} sample sheet" in html + + +def test_container_actions_gui_renders_dedicated_api_backed_page() -> None: + client = _client() + response = client.get("/container-actions") + assert response.status_code == 200, response.text + html = response.text + assert "Container Actions" in html + assert "/api/v1/container-actions/validate" in html + assert "/api/v1/container-actions/execute" in html + assert 'data-testid="bloom-container-action-rows"' in html + assert 'data-testid="bloom-container-action-operation"' in html + assert 'data-testid="bloom-container-action-payload"' in html + assert "Lab Actions" not in html.partition("")[0] + + +def test_directed_extraction_into_existing_plate_with_quant_and_reuse_guard() -> None: + client = _client() + existing_plate_euid = _create_plate(client, name="pytest existing extraction plate") + tube, _ = _create_filled_tube(client, suffix="directed") + + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + response = client.post( + "/api/v1/lab-actions/extraction-plates", + json={ + "mode": "directed", + "plate_euid": existing_plate_euid, + "assignments": [ + { + "tube_euid": tube, + "row": "B", + "col": 3, + "quant": { + "method": "Qubit", + "concentration": 42.5, + "concentration_units": "ng/uL", + }, + } + ], + "run_set": { + "name": "pytest directed extraction set", + "members": [tube], + "external_members": ["atlas:pytest-external-member"], + "metadata": {"purpose": "directed-test"}, + }, + }, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["plate_euid"] == existing_plate_euid + assert payload["new_container_euids"] == [] + assert payload["run_set_euid"] + assert payload["mappings"][0]["well_name"] == "B3" + assert payload["mappings"][0]["quant_euid"] + + repeat = client.post( + "/api/v1/lab-actions/extraction-plates", + json={ + "mode": "directed", + "plate_euid": existing_plate_euid, + "assignments": [{"tube_euid": tube, "row": "B", "col": 3}], + }, + ) + assert repeat.status_code == 400 + assert "already filled" in repeat.json()["detail"] + + +def test_directed_extraction_rejects_duplicate_tubes_and_wells() -> None: + client = _client() + tube_1, _ = _create_filled_tube(client, suffix="dup-1") + tube_2, _ = _create_filled_tube(client, suffix="dup-2") + + duplicate_tube = client.post( + "/api/v1/lab-actions/extraction-plates", + json={ + "mode": "directed", + "assignments": [ + {"tube_euid": tube_1, "row": "A", "col": 1}, + {"tube_euid": tube_1, "row": "A", "col": 2}, + ], + }, + ) + assert duplicate_tube.status_code == 400 + assert "duplicate assignment tube_euid" in duplicate_tube.json()["detail"] + + duplicate_well = client.post( + "/api/v1/lab-actions/extraction-plates", + json={ + "mode": "directed", + "assignments": [ + {"tube_euid": tube_1, "row": "A", "col": 1}, + {"tube_euid": tube_2, "row": "A", "col": 1}, + ], + }, + ) + assert duplicate_well.status_code == 400 + assert "duplicate destination well" in duplicate_well.json()["detail"] + + +def test_directed_library_plate_with_index_and_run_set() -> None: + client = _client() + tube, _ = _create_filled_tube(client, suffix="library-directed") + extraction = client.post( + "/api/v1/lab-actions/extraction-plates", + json={"mode": "auto", "tube_euids": [tube]}, + ) + assert extraction.status_code == 200, extraction.text + source_well = extraction.json()["mappings"][0]["well_euid"] + index_euid = _create_reagent_index(client, name="pytest sequencing index") + + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + response = client.post( + "/api/v1/lab-actions/seq-library-plates", + json={ + "mode": "directed", + "plate_name": "pytest directed library plate", + "assignments": [ + { + "source_well_euid": source_well, + "row": "C", + "col": 4, + "index_barcode": "ACGTACGT", + "index_euid": index_euid, + "data": {"cycles": 8}, + } + ], + "create_run_set": True, + "run_set": {"name": "pytest library run set"}, + }, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["run_set_euid"] + assert payload["mappings"][0]["well_name"] == "C4" + assert payload["mappings"][0]["index_barcode"] == "ACGTACGT" + assert payload["mappings"][0]["index_euid"] == index_euid + + duplicate_destination = client.post( + "/api/v1/lab-actions/seq-library-plates", + json={ + "mode": "directed", + "assignments": [ + {"source_well_euid": source_well, "row": "A", "col": 1}, + {"source_well_euid": source_well, "row": "A", "col": 1}, + ], + }, + ) + assert duplicate_destination.status_code == 400 + assert "duplicate destination well" in duplicate_destination.json()["detail"] + + +def test_pool_existing_tube_pool_from_pool_and_seq_run_links() -> None: + client = _client() + tube, _ = _create_filled_tube(client, suffix="pool-existing") + extraction = client.post( + "/api/v1/lab-actions/extraction-plates", + json={"mode": "auto", "tube_euids": [tube]}, + ) + assert extraction.status_code == 200, extraction.text + library = client.post( + "/api/v1/lab-actions/seq-library-plates", + json={ + "mode": "plate_1_to_1", + "source_plate_euid": extraction.json()["plate_euid"], + }, + ) + assert library.status_code == 200, library.text + library_content = library.json()["mappings"][0]["library_content_euid"] + + existing_pool_tube = _create_object( + client, + category="container", + type_name="tube", + subtype="tube-generic-10ml", + name="pytest existing pool tube", + )["euid"] + first_pool = client.post( + "/api/v1/lab-actions/seq-library-pools", + json={ + "input_euids": [library_content], + "pool_tube_euid": existing_pool_tube, + "platform": "ILMN", + "pool_name": "pytest first pool", + "create_run_set": True, + }, + ) + assert first_pool.status_code == 200, first_pool.text + first_payload = first_pool.json() + assert first_payload["pool_tube_euid"] == existing_pool_tube + assert first_payload["new_container_euids"] == [] + assert first_payload["run_set_euid"] + + second_pool = client.post( + "/api/v1/lab-actions/seq-library-pools", + json={ + "input_euids": [first_payload["pool_tube_euid"]], + "platform": "ILMN", + "pool_name": "pytest pool from pool", + }, + ) + assert second_pool.status_code == 200, second_pool.text + assert second_pool.json()["member_euids"] == [first_payload["pool_content_euid"]] + + duplicate_inputs = client.post( + "/api/v1/lab-actions/seq-library-pools", + json={ + "input_euids": [library_content, library_content], + "platform": "ILMN", + }, + ) + assert duplicate_inputs.status_code == 400 + assert "duplicate input_euids" in duplicate_inputs.json()["detail"] + + instrument_euid = _create_instrument(client, name="pytest novaseq") + reagent_euid = _create_reagent_index(client, name="pytest run reagent") + seq_run = client.post( + "/api/v1/lab-actions/seq-runs", + json={ + "pool_tube_euid": second_pool.json()["pool_tube_euid"], + "platform": "ILMN", + "operator": "pytest@example.com", + "instrument_euid": instrument_euid, + "machine": "NovaSeq 6000", + "flowcell_barcode": "FLOWCELL-LINKS", + "reagent_euids": [reagent_euid], + "status": "running", + "metadata": {"purpose": "link coverage"}, + }, + ) + assert seq_run.status_code == 200, seq_run.text + assert seq_run.json()["status"] == "running" + + +def test_lab_action_schema_validation_edges() -> None: + client = _client() + bad_auto = client.post( + "/api/v1/lab-actions/extraction-plates", + json={ + "mode": "auto", + "tube_euids": ["Z-BCT-1"], + "assignments": [{"tube_euid": "Z-BCT-2", "row": "A", "col": 1}], + }, + ) + assert bad_auto.status_code == 422 + + bad_directed = client.post( + "/api/v1/lab-actions/extraction-plates", + json={"mode": "directed", "tube_euids": ["Z-BCT-1"]}, + ) + assert bad_directed.status_code == 422 + + bad_library = client.post( + "/api/v1/lab-actions/seq-library-plates", + json={"mode": "plate_1_to_1"}, + ) + assert bad_library.status_code == 422 + + too_many_prints = client.post( + "/api/v1/lab-actions/print-euids", + json={ + "euids": [f"Z-BCT-{index}" for index in range(501)], + "lab": "SSF", + "printer_id": "pytest-printer", + "label_zpl_style": "euid", + }, + ) + assert too_many_prints.status_code == 422 + + +def test_qc_plate_data_sets_and_pool_from_well_and_tube() -> None: + client = _client() + tube_1, _ = _create_filled_tube(client, suffix="qc-a") + tube_2, _ = _create_filled_tube(client, suffix="qc-b") + + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + extraction = client.post( + "/api/v1/lab-actions/extraction-plates", + json={ + "mode": "auto", + "tube_euids": [tube_1], + "plate_name": "pytest spreadsheet extraction plate", + }, + ) + assert extraction.status_code == 200, extraction.text + extraction_payload = extraction.json() + source_well = extraction_payload["mappings"][0]["well_euid"] + + qc = client.post( + "/api/v1/lab-actions/extraction-qc-plates", + json={ + "source_plate_euid": extraction_payload["plate_euid"], + "qc_plate_name": "pytest extraction QC plate", + "assignments": [ + { + "source_well_euid": source_well, + "qc_row": "A", + "qc_col": 1, + "result": "pass", + "status": "recorded", + "data": {"concentration_ng_ul": 12.3}, + } + ], + }, + ) + assert qc.status_code == 200, qc.text + qc_payload = qc.json() + assert qc_payload["qc_plate_euid"] + assert qc_payload["mappings"][0]["result"] == "pass" + assert qc_payload["mappings"][0]["data_euid"] + + data = client.post( + "/api/v1/lab-actions/plate-well-data", + json={ + "data_template_code": "data/operation/extraction-qc/1.0/", + "records": [ + { + "plate_euid": extraction_payload["plate_euid"], + "row": "A", + "col": 1, + "target": "content", + "data": {"a260_280": 1.82}, + } + ], + }, + ) + assert data.status_code == 200, data.text + data_payload = data.json() + assert data_payload["plate_euid"] == extraction_payload["plate_euid"] + assert data_payload["plate_euids"] == [extraction_payload["plate_euid"]] + assert data_payload["mappings"][0]["plate_euid"] == extraction_payload["plate_euid"] + assert data_payload["mappings"][0]["data_euid"] + + lab_set = client.post( + "/api/v1/lab-actions/sets", + json={ + "name": "pytest extraction set", + "members": [extraction_payload["plate_euid"], qc_payload["qc_plate_euid"]], + "external_members": ["external-reagent-lot-1"], + }, + ) + assert lab_set.status_code == 200, lab_set.text + set_euid = lab_set.json()["set_euid"] + add_members = client.post( + f"/api/v1/lab-actions/sets/{set_euid}/members", + json={"members": [tube_2], "external_members": ["operator-note-1"]}, + ) + assert add_members.status_code == 200, add_members.text + fetched = client.get(f"/api/v1/lab-actions/sets/{set_euid}") + assert fetched.status_code == 200 + assert tube_2 in fetched.json()["members"] + assert "operator-note-1" in fetched.json()["external_members"] + + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + pool = client.post( + "/api/v1/lab-actions/seq-library-pools", + json={ + "input_euids": [source_well, tube_2], + "platform": "ILMN", + "pool_name": "pytest well plus tube pool", + }, + ) + assert pool.status_code == 200, pool.text + assert len(pool.json()["member_euids"]) == 2 + + +def _minimal_xlsx(sheet_name: str, rows: list[list[str | None]]) -> bytes: + def cell_ref(row_index: int, col_index: int) -> str: + col = "" + value = col_index + while value: + value, remainder = divmod(value - 1, 26) + col = chr(65 + remainder) + col + return f"{col}{row_index}" + + sheet_rows = [] + for row_index, row in enumerate(rows, start=1): + cells = [] + for col_index, value in enumerate(row, start=1): + if value is None: + continue + cells.append( + f'{value}' + ) + sheet_rows.append(f'{"".join(cells)}') + worksheet = ( + '' + '' + f"{''.join(sheet_rows)}" + ) + output = BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr( + "[Content_Types].xml", + '' + '' + '' + '' + "", + ) + archive.writestr( + "xl/workbook.xml", + '' + '' + f'', + ) + archive.writestr( + "xl/_rels/workbook.xml.rels", + '' + '' + '' + "", + ) + archive.writestr("xl/worksheets/sheet1.xml", worksheet) + return output.getvalue() + + +def test_spreadsheet_import_csv_and_xlsx_preview() -> None: + client = _client() + tube, _ = _create_filled_tube(client, suffix="spreadsheet") + csv_bytes = ( + f"tube_euid,row,col,plate_name\n{tube},A,1,pytest upload extraction plate\n" + ).encode() + dry_run = client.post( + "/api/v1/lab-actions/spreadsheet-import", + params={"dry_run": "true"}, + files={"file": ("extraction.csv", csv_bytes, "text/csv")}, + ) + assert dry_run.status_code == 200, dry_run.text + assert dry_run.json()["actions"][0]["action"] == "extraction_plate" + assert dry_run.json()["actions"][0]["request"]["mode"] == "directed" + + with patch( + "bloom_lims.integrations.atlas.events.emit_bloom_event", return_value=None + ): + execute = client.post( + "/api/v1/lab-actions/spreadsheet-import", + params={"dry_run": "false"}, + files={"file": ("extraction.csv", csv_bytes, "text/csv")}, + ) + assert execute.status_code == 200, execute.text + assert execute.json()["actions"][0]["result"]["plate_euid"] + + workbook = _minimal_xlsx( + "Transfer", + [ + [], + [], + [ + "SOURCE CONTAINER(s)", + None, + None, + None, + None, + None, + None, + None, + None, + "DESTINATION CONTAINER(s)", + ], + ["Container EUID", "Container Template EUID", "Continer Cat/Type/Subtype"], + [ + "BCT-EXAMPLE", + "container/tube/tube-generic-10ml/1.0", + "container/tube/generic", + ], + ], + ) + parsed = parse_workbook("container_interactions.xlsx", workbook) + assert parsed[0].name == "Transfer" + assert "container_euid" in parsed[0].headers + preview = client.post( + "/api/v1/lab-actions/spreadsheet-import", + params={"dry_run": "true"}, + files={ + "file": ( + "container_interactions.xlsx", + workbook, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + }, + ) + assert preview.status_code == 200, preview.text + assert preview.json()["actions"][0]["action"] == "container_interaction_preview" + + +def _upload_csv(client: TestClient, csv_text: str, *, dry_run: bool = False): + return client.post( + "/api/v1/lab-actions/spreadsheet-import", + params={"dry_run": str(dry_run).lower()}, + files={"file": ("lab_action.csv", csv_text.encode(), "text/csv")}, + ) + + +def test_spreadsheet_import_executes_each_lab_action_sheet_shape() -> None: + client = _client() + tube, _ = _create_filled_tube(client, suffix="upload-flow") + extraction = client.post( + "/api/v1/lab-actions/extraction-plates", + json={"mode": "auto", "tube_euids": [tube]}, + ) + assert extraction.status_code == 200, extraction.text + extraction_payload = extraction.json() + source_well = extraction_payload["mappings"][0]["well_euid"] + + qc_csv = ( + "source_well_euid,qc_row,qc_col,result,status,data_concentration_ng_ul\n" + f"{source_well},A,1,pass,recorded,11.2\n" + ) + qc = _upload_csv(client, qc_csv) + assert qc.status_code == 200, qc.text + qc_action = qc.json()["actions"][0] + assert qc_action["action"] == "extraction_qc" + assert qc_action["result"]["mappings"][0]["result"] == "pass" + + library_csv = ( + "source_well_euid,row,col,index_barcode,data_cycles\n" + f"{source_well},A,1,ACGTACGT,8\n" + ) + library = _upload_csv(client, library_csv) + assert library.status_code == 200, library.text + library_action = library.json()["actions"][0] + assert library_action["action"] == "seq_library_plate" + library_content = library_action["result"]["mappings"][0]["library_content_euid"] + + pool_csv = ( + "input_euids,platform,pool_name,pool_batch\n" + f"{library_content},ILMN,pytest upload pool,batch-1\n" + ) + pool = _upload_csv(client, pool_csv) + assert pool.status_code == 200, pool.text + pool_action = pool.json()["actions"][0] + assert pool_action["action"] == "seq_pool" + pool_result = pool_action["result"] + + run_csv = ( + "pool_tube_euid,pool_content_euid,platform,operator,flowcell_barcode,status,run_batch\n" + f"{pool_result['pool_tube_euid']},{pool_result['pool_content_euid']},ILMN,pytest@example.com,FLOWCELL-UPLOAD,created,batch-1\n" + ) + run = _upload_csv(client, run_csv) + assert run.status_code == 200, run.text + run_action = run.json()["actions"][0] + assert run_action["action"] == "seq_run_set" + assert run_action["result"]["set_euid"] + + set_csv = ( + "name,members,external_members,status,set_note\n" + f"pytest upload set,{extraction_payload['plate_euid']};{pool_result['pool_tube_euid']},lot:123,created,upload\n" + ) + lab_set = _upload_csv(client, set_csv) + assert lab_set.status_code == 200, lab_set.text + set_action = lab_set.json()["actions"][0] + assert set_action["action"] == "sets" + assert set_action["result"][0]["set_euid"] + + data_csv = ( + "well_euid,data_template_code,target,name,annotation_value,metric_a260_280\n" + f"{source_well},data/operation/extraction-qc/1.0/,content,pytest uploaded data,ok,1.82\n" + ) + data = _upload_csv(client, data_csv) + assert data.status_code == 200, data.text + data_action = data.json()["actions"][0] + assert data_action["action"] == "plate_well_data" + assert data_action["result"]["mappings"][0]["data_euid"] + + bad_extension = client.post( + "/api/v1/lab-actions/spreadsheet-import", + files={"file": ("lab_action.txt", b"not,csv", "text/plain")}, + ) + assert bad_extension.status_code == 400 + assert "must be .csv or .xlsx" in bad_extension.json()["detail"] + + +def test_lab_actions_gui_renders_upload_qc_and_set_controls() -> None: + client = _client() + response = client.get("/lab-actions") + assert response.status_code == 200, response.text + assert "Extraction QC Plate" in response.text + assert "Spreadsheet Upload" in response.text + assert "Manage Sets" in response.text + assert 'data-action="upload-spreadsheet"' in response.text diff --git a/tests/test_lsmc_v0_graph_contract.py b/tests/test_lsmc_v0_graph_contract.py new file mode 100644 index 0000000..52e45e0 --- /dev/null +++ b/tests/test_lsmc_v0_graph_contract.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from bloom_lims.domain.v0_graph import attach_bloom_v0_edge, object_evidence + + +def test_attach_bloom_v0_edge_stores_metadata_under_properties(): + lineage = SimpleNamespace(json_addl={}) + + attach_bloom_v0_edge( + lineage, + edge_type="HOLDS_MATERIAL", + source_euid="Z-BCT-SRC", + target_euid="Z-BNB-TGT", + evidence_refs=[ + object_evidence("Z-BCT-SRC", role="container"), + object_evidence("Z-BNB-TGT", role="material"), + ], + correlation_id="bloom:test:correlation", + causation_id="bloom:test:causation", + ) + + v0_edge = lineage.json_addl["properties"]["v0_edge"] + assert v0_edge["contract"] == "LSMC_V0" + assert v0_edge["edge_type"] == "HOLDS_MATERIAL" + assert v0_edge["semantic_source"] == { + "system": "bloom", + "euid": "Z-BCT-SRC", + "role": "source", + } + assert v0_edge["semantic_target"] == { + "system": "bloom", + "euid": "Z-BNB-TGT", + "role": "target", + } + assert "source_euid" not in v0_edge + assert "target_euid" not in v0_edge + assert v0_edge["validity"]["valid_to"] is None + + +def test_attach_bloom_v0_edge_rejects_missing_evidence(): + with pytest.raises(ValueError, match="evidence_refs"): + attach_bloom_v0_edge( + SimpleNamespace(json_addl={}), + edge_type="MATERIAL_FROM_SUBJECT", + source_euid="Z-BNB-SRC", + target_euid="Z-AGX-TGT", + evidence_refs=[], + correlation_id="bloom:test:correlation", + causation_id="bloom:test:causation", + ) diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index b693012..ea1b0e0 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -15,9 +15,48 @@ def test_project_dependencies_pin_release_train_versions() -> None: assert "cli-core-yo==2.1.1" in dependencies assert "daylily-auth-cognito==2.1.5" in dependencies - assert "daylily-tapdb>=7.0.7,<8.0.0" in dependencies + assert ( + "daylily-tapdb @ git+https://github.com/Daylily-Informatics/daylily-tapdb.git@9.0.10" + in dependencies + ) assert "IPython>=8.18.1" in dependencies assert "psycopg2-binary==2.9.12" in dependencies assert not any(str(dep).startswith("zebra-day") for dep in dependencies) assert "djlint" in dependencies assert "optional-dependencies" not in data["project"] + + +def test_dockerfile_copies_tapdb_template_config() -> None: + dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text( + encoding="utf-8" + ) + + assert "COPY config ./config" in dockerfile + assert "COPY static ./static" in dockerfile + assert "COPY templates ./templates" in dockerfile + + +def test_dockerfile_installs_git_before_git_pinned_dependencies() -> None: + dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text( + encoding="utf-8" + ) + + first_uv_sync = dockerfile.index("uv sync") + git_install = dockerfile.index("git") + assert git_install < first_uv_sync + + +def test_dockerfile_uses_package_specific_scm_version() -> None: + dockerfile = (Path(__file__).resolve().parents[1] / "Dockerfile").read_text( + encoding="utf-8" + ) + + assert ( + "ENV SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION}" + not in dockerfile + ) + assert ( + "ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_BLOOM_LIMS=${SETUPTOOLS_SCM_PRETEND_VERSION}" + in dockerfile + ) + assert dockerfile.count("unset SETUPTOOLS_SCM_PRETEND_VERSION && uv sync") == 2 diff --git a/tests/test_queue_flow.py b/tests/test_queue_flow.py index c5e508b..b989bdd 100644 --- a/tests/test_queue_flow.py +++ b/tests/test_queue_flow.py @@ -67,8 +67,16 @@ def _assert_no_uuid_keys(payload): def _atlas_context_payload() -> dict[str, object]: + order_euid = _opaque("order") + primary_order_test_euid = _opaque("order-test-primary") return { "atlas_tenant_id": _opaque("tenant"), + "atlas_order_euid": order_euid, + "atlas_order_test_euid": primary_order_test_euid, + "atlas_order_test_euids": [ + primary_order_test_euid, + _opaque("order-test-secondary"), + ], "atlas_trf_euid": _opaque("trf"), "atlas_test_euid": _opaque("test-primary"), "atlas_test_euids": [_opaque("test-secondary")], @@ -746,3 +754,26 @@ def test_external_specimen_lookup_finds_queue_ready_material_by_container_refs() and item["container_euid"] == material["container_euid"] for item in payload["items"] ) + + +def test_external_specimen_lookup_finds_queue_ready_material_by_order_refs(): + app.dependency_overrides[require_external_token_auth] = _external_rw_user + + with TestClient(app) as client: + material, atlas_context = _create_material_and_queue(client) + lookup = client.get( + "/api/v1/external/specimens/by-reference", + params={ + "atlas_tenant_id": atlas_context["atlas_tenant_id"], + "order_euid": atlas_context["atlas_order_euid"], + "order_test_euid": atlas_context["atlas_order_test_euid"], + }, + ) + assert lookup.status_code == 200, lookup.text + payload = lookup.json() + assert payload["total"] >= 1 + assert any( + item["specimen_euid"] == material["specimen_euid"] + and item["container_euid"] == material["container_euid"] + for item in payload["items"] + ) diff --git a/tests/test_route_coverage_gaps_api.py b/tests/test_route_coverage_gaps_api.py index 8c5c8bf..4822d5b 100644 --- a/tests/test_route_coverage_gaps_api.py +++ b/tests/test_route_coverage_gaps_api.py @@ -113,6 +113,42 @@ def test_object_creation_creates_all_sequencing_run_platform_prefixes( assert created["euid"].startswith(f"{domain_code}-{prefix}-") +def test_object_creation_plate_creates_96_linked_wells(client: TestClient, bdb) -> None: + created = _create_instance_via_object_creation( + client, + category="container", + type_name="plate", + subtype="fixed-plate-96", + version="1.0", + name="pytest-fixed-96", + ) + plate_euid = created["euid"] + + lineage_cls = bdb.Base.classes.generic_instance_lineage + instance_cls = bdb.Base.classes.generic_instance + plate = bdb.session.query(instance_cls).filter_by(euid=plate_euid).one() + lineages = ( + bdb.session.query(lineage_cls) + .filter( + lineage_cls.parent_instance_uid == plate.uid, + lineage_cls.relationship_type == "contains", + lineage_cls.is_deleted.is_(False), + ) + .all() + ) + wells = [lineage.child_instance for lineage in lineages] + positions = { + ((well.json_addl or {}).get("cont_address") or {}).get("name") for well in wells + } + assert len(wells) == 96 + assert {well.type for well in wells} == {"well"} + assert {"A1", "H12"}.issubset(positions) + + layout = client.get(f"/api/v1/containers/{plate_euid}/layout") + assert layout.status_code == 200, layout.text + assert layout.json()["well_count"] == 96 + + def test_actions_endpoints_execute_handler_body(client: TestClient) -> None: resp = client.post( "/api/v1/actions/aliquot", @@ -248,6 +284,33 @@ def test_containers_content_link_layout_and_delete(client: TestClient, bdb) -> N }, ) assert link_resp.status_code == 200, link_resp.text + lineage_cls = bdb.Base.classes.generic_instance_lineage + parent = ( + bdb.session.query(bdb.Base.classes.generic_instance) + .filter_by(euid=container_euid) + .first() + ) + child = ( + bdb.session.query(bdb.Base.classes.generic_instance) + .filter_by(euid=content_euid) + .first() + ) + lineage = ( + bdb.session.query(lineage_cls) + .filter( + lineage_cls.parent_instance_uid == parent.uid, + lineage_cls.child_instance_uid == child.uid, + lineage_cls.relationship_type == "HOLDS_MATERIAL", + lineage_cls.is_deleted.is_(False), + ) + .one() + ) + v0_edge = lineage.json_addl["properties"]["v0_edge"] + assert v0_edge["edge_type"] == "HOLDS_MATERIAL" + assert v0_edge["semantic_source"]["euid"] == container_euid + assert v0_edge["semantic_source"]["role"] == "container" + assert v0_edge["semantic_target"]["euid"] == content_euid + assert v0_edge["semantic_target"]["role"] == "material" # Remove content from container unlink_resp = client.delete( diff --git a/tests/test_route_coverage_gaps_gui.py b/tests/test_route_coverage_gaps_gui.py index 710123d..f9dba10 100644 --- a/tests/test_route_coverage_gaps_gui.py +++ b/tests/test_route_coverage_gaps_gui.py @@ -41,9 +41,21 @@ def _warm_session(client: TestClient) -> None: assert resp.status_code == 200 -def _get_any_template_euid(bdb) -> str: +def _get_bloom_creatable_template_euid(bdb) -> str: GT = bdb.Base.classes.generic_template - row = bdb.session.query(GT).filter(GT.is_deleted.is_(False)).first() + row = ( + bdb.session.query(GT) + .filter( + GT.domain_code == "Z", + GT.category == "container", + GT.type == "tube", + GT.subtype == "tube-generic-10ml", + GT.version == "1.0", + GT.instance_prefix == "BCT", + GT.is_deleted.is_(False), + ) + .one_or_none() + ) assert row is not None return row.euid @@ -216,7 +228,7 @@ def test_user_audit_logs_renders(client: TestClient) -> None: def test_create_from_template_executes_handler(client: TestClient, bdb) -> None: _warm_session(client) - template_euid = _get_any_template_euid(bdb) + template_euid = _get_bloom_creatable_template_euid(bdb) resp = client.get( "/create_from_template", params={"euid": template_euid}, follow_redirects=False ) @@ -232,7 +244,7 @@ def test_file_set_urls_and_admin_template_routes_are_removed( resp = client.get("/file_set_urls", params={"fs_euid": "BFL-NOT-REAL"}) assert resp.status_code == 404 - template_euid = _get_any_template_euid(bdb) + template_euid = _get_bloom_creatable_template_euid(bdb) # Admin template editor was retired. resp = client.get("/admin_template", params={"euid": template_euid}) assert resp.status_code == 404 @@ -349,7 +361,4 @@ def test_logout_alias_routes_execute_handler_body(client: TestClient) -> None: logout = client.get("/logout", follow_redirects=False) assert logout.status_code == 303 - assert ( - logout.headers["location"] - == "https://example.auth.us-west-2.amazoncognito.com/logout" - ) + assert logout.headers["location"] == "/auth/logout" diff --git a/tests/test_route_inventory_guard.py b/tests/test_route_inventory_guard.py index f912ea5..8633583 100644 --- a/tests/test_route_inventory_guard.py +++ b/tests/test_route_inventory_guard.py @@ -89,6 +89,10 @@ def _iter_runtime_routes() -> set[tuple[str, str]]: def test_docs_routes_remain_enabled_and_render(client: TestClient) -> None: + root = client.get("/", follow_redirects=False) + assert root.status_code in {200, 302, 303, 307, 308} + assert root.status_code != 404 + openapi = client.get("/openapi.json") assert openapi.status_code == 200 assert openapi.json()["openapi"].startswith("3.") diff --git a/tests/test_sequencing_run_contract.py b/tests/test_sequencing_run_contract.py index cb6d1c2..0474c54 100644 --- a/tests/test_sequencing_run_contract.py +++ b/tests/test_sequencing_run_contract.py @@ -10,7 +10,6 @@ from bloom_lims.schemas.beta_lab import BetaRunCreateRequest - PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -63,7 +62,7 @@ def test_sequencing_run_templates_are_shipped_for_tapdb_seed(): for template_key, (platform, instance_prefix) in expected.items(): template = templates[template_key] props = template["json_addl"]["properties"] - assert template["category"] == instance_prefix + assert template["category"] == "data" assert template["instance_prefix"] == instance_prefix assert template["json_addl"]["semantic_category"] == "data" assert props["beta_kind"] == "sequencing_run" diff --git a/tests/test_tapdb_metrics.py b/tests/test_tapdb_metrics.py new file mode 100644 index 0000000..0ebd7ad --- /dev/null +++ b/tests/test_tapdb_metrics.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +from bloom_lims import tapdb_metrics + + +def _runtime_with_metrics(value: object, config_path: Path): + return ("target", config_path, {"metrics_enabled": value}) + + +def test_metrics_enabled_honors_explicit_yaml_over_helper_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + config_path = tmp_path / "tapdb-config.yaml" + config_path.write_text("target:\n metrics_enabled: false\n", encoding="utf-8") + monkeypatch.delitem(sys.modules, "pytest", raising=False) + monkeypatch.setattr( + tapdb_metrics, + "_resolved_bloom_runtime", + lambda _env_name=None: ("target", config_path, {"metrics_enabled": True}), + ) + + assert tapdb_metrics.metrics_enabled("target") is False + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (False, False), + ("false", False), + ("0", False), + ("off", False), + (True, True), + ("true", True), + ("1", True), + ("on", True), + ], +) +def test_metrics_enabled_honors_explicit_boolean_strings( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: object, expected: bool +) -> None: + config_path = tmp_path / "tapdb-config.yaml" + config_path.write_text("target: {}\n", encoding="utf-8") + monkeypatch.delitem(sys.modules, "pytest", raising=False) + monkeypatch.setattr( + tapdb_metrics, + "_resolved_bloom_runtime", + lambda _env_name=None: _runtime_with_metrics(value, config_path), + ) + + assert tapdb_metrics.metrics_enabled("target") is expected + + +def test_metrics_enabled_rejects_malformed_explicit_value( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + config_path = tmp_path / "tapdb-config.yaml" + config_path.write_text("target: {}\n", encoding="utf-8") + monkeypatch.setattr( + tapdb_metrics, + "_resolved_bloom_runtime", + lambda _env_name=None: _runtime_with_metrics("sometimes", config_path), + ) + + with pytest.raises(RuntimeError, match="target.metrics_enabled"): + tapdb_metrics.metrics_enabled("target") + + +def test_metrics_writer_not_created_when_config_string_disables_metrics( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + config_path = tmp_path / "tapdb-config.yaml" + config_path.write_text("target: {}\n", encoding="utf-8") + monkeypatch.delitem(sys.modules, "pytest", raising=False) + monkeypatch.setattr( + tapdb_metrics, + "_resolved_bloom_runtime", + lambda _env_name=None: _runtime_with_metrics("false", config_path), + ) + + assert tapdb_metrics._get_writer("target") is None diff --git a/tests/test_tapdb_mount.py b/tests/test_tapdb_mount.py index edd0f36..9ef6e74 100644 --- a/tests/test_tapdb_mount.py +++ b/tests/test_tapdb_mount.py @@ -15,29 +15,39 @@ def _client() -> TestClient: os.environ["BLOOM_TAPDB_MOUNT_ENABLED"] = "1" - os.environ["BLOOM_TAPDB_MOUNT_PATH"] = "/admin/tapdb" + os.environ["BLOOM_TAPDB_MOUNT_PATH"] = "/tapdb" return TestClient(create_app(), raise_server_exceptions=False) +def _dummy_tapdb_app() -> FastAPI: + app = FastAPI() + + @app.get("/") + async def _index(): + return {"tapdb": "ok"} + + return app + + def test_mounted_route_exists_under_bloom_app(): with _client() as client: - response = client.get("/admin/tapdb/login", follow_redirects=False) + response = client.get("/tapdb/", follow_redirects=False) assert response.status_code != 404 def test_unauthenticated_request_redirects_to_bloom_login(): with _client() as client: - response = client.get("/admin/tapdb/login", follow_redirects=False) - assert response.status_code == 303 - assert response.headers.get("location") == "/login" + response = client.get("/tapdb/", follow_redirects=False) + assert response.status_code == 302 + assert response.headers.get("location") == "/login?next=/tapdb/" json_response = client.get( - "/admin/tapdb/login", + "/tapdb/api/search", headers={"accept": "application/json"}, follow_redirects=False, ) assert json_response.status_code == 401 - assert json_response.json()["detail"] == "Authentication required" + assert json_response.json()["detail"] == "host_session_required" def test_non_admin_authenticated_user_is_denied(monkeypatch): @@ -47,17 +57,13 @@ def test_non_admin_authenticated_user_is_denied(monkeypatch): lambda _scope: {"email": "user@example.com", "role": "READ_WRITE"}, ) with _client() as client: - response = client.get("/admin/tapdb/login", follow_redirects=False) - assert response.status_code == 303 - assert response.headers.get("location") == "/user_home?admin_required=1" - json_response = client.get( - "/admin/tapdb/login", + "/tapdb/api/admin/readiness", headers={"accept": "application/json"}, follow_redirects=False, ) assert json_response.status_code == 403 - assert json_response.json()["detail"] == "Admin privileges required" + assert json_response.json()["detail"] == "tapdb_gui_admin_required" def test_admin_user_can_access_mounted_surface(monkeypatch): @@ -66,10 +72,14 @@ def test_admin_user_can_access_mounted_surface(monkeypatch): "_resolve_bloom_user_data", lambda _scope: {"email": "admin@example.com", "role": "ADMIN"}, ) + monkeypatch.setattr( + tapdb_mount, + "_load_tapdb_admin_app", + lambda **_kwargs: _dummy_tapdb_app(), + ) with _client() as client: - response = client.get("/admin/tapdb/login", follow_redirects=False) - assert response.status_code in {302, 303} - assert response.headers.get("location") == "/admin/tapdb/" + response = client.get("/tapdb/", follow_redirects=False) + assert response.status_code != 404 def test_tapdb_local_auth_not_required_in_mounted_mode(monkeypatch): @@ -78,10 +88,14 @@ def test_tapdb_local_auth_not_required_in_mounted_mode(monkeypatch): "_resolve_bloom_user_data", lambda _scope: {"email": "admin@example.com", "role": "ADMIN"}, ) + monkeypatch.setattr( + tapdb_mount, + "_load_tapdb_admin_app", + lambda **_kwargs: _dummy_tapdb_app(), + ) with _client() as client: - response = client.get("/admin/tapdb/login", follow_redirects=False) - assert response.status_code in {302, 303} - assert response.headers.get("location") == "/admin/tapdb/" + response = client.get("/tapdb/", follow_redirects=False) + assert response.status_code != 404 def test_bloom_single_app_serves_api_and_tapdb_mount(monkeypatch): @@ -90,9 +104,14 @@ def test_bloom_single_app_serves_api_and_tapdb_mount(monkeypatch): "_resolve_bloom_user_data", lambda _scope: {"email": "admin@example.com", "role": "ADMIN"}, ) + monkeypatch.setattr( + tapdb_mount, + "_load_tapdb_admin_app", + lambda **_kwargs: _dummy_tapdb_app(), + ) with _client() as client: api_response = client.get("/api/v1/") - tapdb_response = client.get("/admin/tapdb/login", follow_redirects=False) + tapdb_response = client.get("/tapdb/", follow_redirects=False) assert api_response.status_code == 200 assert tapdb_response.status_code != 404 diff --git a/tests/test_tapdb_user_repository.py b/tests/test_tapdb_user_repository.py index 29a386b..2ceef3c 100644 --- a/tests/test_tapdb_user_repository.py +++ b/tests/test_tapdb_user_repository.py @@ -3,12 +3,14 @@ from bloom_lims.auth.repositories.tapdb import users as repo -def test_user_select_sql_targets_sys_actor_users() -> None: - assert "gi.category = 'SYS'" in repo._USER_SELECT_SQL +def test_user_select_sql_targets_tapdb9_actor_users() -> None: + assert "gi.category = 'actor'" in repo._USER_SELECT_SQL + assert "gi.type = 'user'" in repo._USER_SELECT_SQL + assert "gi.subtype = 'system'" in repo._USER_SELECT_SQL assert "gi.category = 'generic'" not in repo._USER_SELECT_SQL -def test_set_user_role_updates_sys_actor_user_by_uid() -> None: +def test_set_user_role_updates_tapdb9_actor_user_by_uid() -> None: calls: list[dict[str, object]] = [] class FakeResult: @@ -22,5 +24,7 @@ def execute(self, statement, params): return FakeResult() assert repo.set_user_role(FakeSession(), 42, "ADMIN") is True - assert "gi.category = 'SYS'" in calls[0]["sql"] + assert "gi.category = 'actor'" in calls[0]["sql"] + assert "gi.type = 'user'" in calls[0]["sql"] + assert "gi.subtype = 'system'" in calls[0]["sql"] assert calls[0]["params"] == {"uid": 42, "role": "ADMIN"} diff --git a/tests/test_template_instantiation_recursion.py b/tests/test_template_instantiation_recursion.py new file mode 100644 index 0000000..19554a5 --- /dev/null +++ b/tests/test_template_instantiation_recursion.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import logging +from types import SimpleNamespace + +import pytest + +from bloom_lims.domain.base import BloomObj + + +class _FakeLineage: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +class _FakeSession: + def __init__(self): + self.added = [] + self.commit_count = 0 + + def add(self, obj): + self.added.append(obj) + + def commit(self): + self.commit_count += 1 + + +def _template(euid, category, type_name, subtype, *, layouts=None): + return SimpleNamespace( + euid=euid, + uid=hash(euid) & 0xFFFF, + name=euid, + category=category, + type=type_name, + subtype=subtype, + version="1.0", + bstatus="active", + json_addl={"instantiation_layouts": layouts or {}}, + polymorphic_discriminator=f"{category}_template", + ) + + +def _fake_bloom_obj(templates): + obj = BloomObj.__new__(BloomObj) + obj.logger = logging.getLogger("test-template-instantiation") + obj.session = _FakeSession() + obj.Base = SimpleNamespace( + classes=SimpleNamespace(generic_instance_lineage=_FakeLineage) + ) + obj.domain_code = "Z" + created = {"count": 0, "instances": {}} + templates_by_euid = {template.euid: template for template in templates} + + def get_by_euid(euid): + return templates_by_euid.get(euid) or created["instances"].get(euid) + + def query_template_by_component_v2( + category=None, type=None, subtype=None, version=None + ): + return [ + template + for template in templates + if (category is None or template.category == category) + and (type is None or template.type == type) + and (subtype is None or template.subtype == subtype) + and (version is None or template.version == version) + ] + + def create_instance(template_euid, json_addl_overrides=None): + template = templates_by_euid[template_euid] + created["count"] += 1 + instance = SimpleNamespace( + uid=created["count"], + euid=f"I-{created['count']}", + name=(json_addl_overrides or {}) + .get("properties", {}) + .get("name", template.name), + category=template.category, + type=template.type, + subtype=template.subtype, + version=template.version, + bstatus=template.bstatus, + json_addl=json_addl_overrides or {}, + polymorphic_discriminator=f"{template.category}_instance", + ) + created["instances"][instance.euid] = instance + return instance + + obj.get_by_euid = get_by_euid + obj.query_template_by_component_v2 = query_template_by_component_v2 + obj.create_instance = create_instance + return obj + + +def test_create_instances_recurses_nested_instantiation_layouts(): + leaf = _template("TPL-LEAF", "content", "material", "leaf") + mid = _template( + "TPL-MID", + "container", + "well", + "middle", + layouts=[ + { + "relationship_type": "HOLDS_MATERIAL", + "child_templates": [ + { + "template_code": "content/material/leaf/1.0/", + "json_addl": {"properties": {"name": "leaf child"}}, + } + ], + } + ], + ) + root = _template( + "TPL-ROOT", + "container", + "plate", + "nested", + layouts=[ + { + "relationship_type": "contains", + "child_templates": [ + { + "template_code": "container/well/middle/1.0/", + "json_addl": {"properties": {"name": "middle child"}}, + } + ], + } + ], + ) + obj = _fake_bloom_obj([root, mid, leaf]) + + parent_rows, child_rows = obj.create_instances("TPL-ROOT") + + assert len(parent_rows) == 1 + assert len(child_rows) == 2 + assert {child.type for child in child_rows} == {"well", "material"} + lineages = [row for row in obj.session.added if isinstance(row, _FakeLineage)] + assert [row.relationship_type for row in lineages] == [ + "HOLDS_MATERIAL", + "contains", + ] + + +def test_create_instances_rejects_template_cycles(): + cyclic = _template( + "TPL-CYCLE", + "container", + "plate", + "cycle", + layouts=[ + { + "relationship_type": "contains", + "child_templates": [{"template_code": "container/plate/cycle/1.0/"}], + } + ], + ) + obj = _fake_bloom_obj([cyclic]) + + with pytest.raises(Exception, match="cycle detected"): + obj.create_instances("TPL-CYCLE") + + +def test_create_instances_enforces_object_count_limit(): + child = _template("TPL-CHILD", "container", "well", "child") + root = _template( + "TPL-ROOT", + "container", + "plate", + "limited", + layouts=[ + { + "relationship_type": "contains", + "child_templates": [{"template_code": "container/well/child/1.0/"}], + } + ], + ) + obj = _fake_bloom_obj([root, child]) + + with pytest.raises(Exception, match="exceeded max object count 1"): + obj.create_instances("TPL-ROOT", max_instances=1) diff --git a/uv.lock b/uv.lock index 33b9495..d116384 100644 --- a/uv.lock +++ b/uv.lock @@ -160,6 +160,7 @@ dependencies = [ { name = "daylily-auth-cognito" }, { name = "daylily-carrier-tracking" }, { name = "daylily-tapdb" }, + { name = "defusedxml" }, { name = "djlint" }, { name = "fastapi" }, { name = "httpx" }, @@ -200,7 +201,8 @@ requires-dist = [ { name = "cli-core-yo", specifier = "==2.1.1" }, { name = "daylily-auth-cognito", specifier = "==2.1.5" }, { name = "daylily-carrier-tracking", specifier = "==0.5.1" }, - { name = "daylily-tapdb", specifier = "==7.0.9" }, + { name = "daylily-tapdb", git = "https://github.com/Daylily-Informatics/daylily-tapdb.git?rev=9.0.10" }, + { name = "defusedxml", specifier = ">=0.7.1" }, { name = "djlint" }, { name = "fastapi", specifier = ">=0.110.0" }, { name = "httpx", specifier = ">=0.27.2" }, @@ -704,8 +706,8 @@ wheels = [ [[package]] name = "daylily-tapdb" -version = "7.0.9" -source = { registry = "https://pypi.org/simple" } +version = "9.0.10" +source = { git = "https://github.com/Daylily-Informatics/daylily-tapdb.git?rev=9.0.10#12351b832276d35228527c3c9596e5d60e7c7824" } dependencies = [ { name = "cli-core-yo" }, { name = "jsonschema" }, @@ -718,10 +720,6 @@ dependencies = [ { name = "typer" }, { name = "uuid6" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/8e/e719791ed396e6dd54fef7fa095ad02926d7747b819831def09a4e828b78/daylily_tapdb-7.0.9.tar.gz", hash = "sha256:b0cb9a731ae9bde37369dcad38c427a4ed565e0c8bd676accfabd3fc5eddca17", size = 476038, upload-time = "2026-05-28T09:47:33.84Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/a6/2bfbfd9321e012e254dae4be7d1bb95dab536eac1f93dc6161e41e4e5f80/daylily_tapdb-7.0.9-py3-none-any.whl", hash = "sha256:c0d86106d417cb693a8046fc1fb236091930b348df65d4f619f76b3ede9cf2b1", size = 258536, upload-time = "2026-05-28T09:47:32.47Z" }, -] [[package]] name = "decorator" @@ -732,6 +730,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -1419,11 +1426,11 @@ wheels = [ [[package]] name = "meridian-euid" -version = "0.4.5" +version = "0.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/ae/2e51a25fd422f39d0b8cc7b65ad9f32309efad6cadaaa15ab16f29585a98/meridian_euid-0.4.5.tar.gz", hash = "sha256:5423d40c4a200d86dc1e0b5df47d3d1aecdf5a30f9668849e4f857deb956e218", size = 15247, upload-time = "2026-05-16T11:47:36.548Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/ea/eff4b7b442f20fa5aa968735ee17f43f9d8e9621da89230426a43203ef5b/meridian_euid-0.4.7.tar.gz", hash = "sha256:27b65eba282a904a876c7df16b48a8329900190cf5830a5fd61776f89d98a62a", size = 19238, upload-time = "2026-06-04T09:47:05.716Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/8d/02dc20a37f126beca0ca197796be8ee44db463c9417efa350715046915d0/meridian_euid-0.4.5-py3-none-any.whl", hash = "sha256:c07d760f26d47a7cee489b71fe11a4df69a9ba094cde57aad2fca8415a05bee8", size = 12073, upload-time = "2026-05-16T11:47:35.645Z" }, + { url = "https://files.pythonhosted.org/packages/21/53/8c9b0dd4f9ec901b0289e6f7fc20163d588d20019edd6f437233cf834c91/meridian_euid-0.4.7-py3-none-any.whl", hash = "sha256:a369a21b4bc7835848ea424bc0a9b1f8273afb13e7ab0c9e83bdd9111a7dd1c8", size = 15316, upload-time = "2026-06-04T09:47:04.808Z" }, ] [[package]]