diff --git a/pydatalab/docs/plugins.md b/pydatalab/docs/plugins.md index da22dc8dc..8f956d837 100644 --- a/pydatalab/docs/plugins.md +++ b/pydatalab/docs/plugins.md @@ -105,8 +105,33 @@ class MySample(Sample): ) ``` -For now, all custom item types are created and listed from the Samples page, regardless of their -Python base model. +By default, custom item types are created and listed from the Samples page, and are owned by +their creators like samples, regardless of their Python base model. A custom type that subclasses +`StartingMaterial` or `Equipment` can instead opt into behaving like that built-in type, by +declaring the `datalab_behave_as` hint on its model config: + +```python +from pydantic import ConfigDict +from pydatalab.models.starting_materials import StartingMaterial + + +class Precursor(StartingMaterial): + model_config = ConfigDict( + title="Precursor", + json_schema_extra={"datalab_behave_as": "starting_materials"}, + ) + type: Literal["precursors"] = "precursors" +``` + +Such a type is then: + +- listed and created from the Inventory (or Equipment) page rather than the Samples page; +- offered by item pickers that search for the built-in type (e.g. synthesis constituents); +- shared like an inventory item: it has no creators, is readable and editable by all users + unless restricted to groups, and follows the `UNGROUPED_INVENTORY` server setting. + +The hint must name a built-in type that the model inherits from, otherwise registration fails. +Without it, the behaviour of a custom type is unchanged. There are two ways to register a custom item type, both of which run at server startup and require no changes to the core code: @@ -183,6 +208,7 @@ A few keys on the model's `model_config` control the type as a whole: | `datalab_ui_color` | accent colour for the navbar, field labels and the item's reference badge | | `datalab_ui_hidden_fields` | base-component sections to hide (`status`, `collections`, `description`, `substance_information`, `synthesis_information`, `tags`, `location`) | | `datalab_section_title` | title of the default custom-fields card | +| `datalab_behave_as` | built-in type (`starting_materials`, `equipment`, `samples`, `cells`) whose listing page, item pickers and sharing permissions the type follows (see above) | Only **scalar-like** fields are rendered automatically: strings, numbers, enums, booleans, unit quantities, and single item references. Lists, nested objects, computed values or charts need a diff --git a/pydatalab/schemas/datalab_model_extra.json b/pydatalab/schemas/datalab_model_extra.json index d1edc70fb..faf1ecf75 100644 --- a/pydatalab/schemas/datalab_model_extra.json +++ b/pydatalab/schemas/datalab_model_extra.json @@ -43,6 +43,25 @@ "default": null, "description": "Title of the default custom-fields card.", "title": "Datalab Section Title" + }, + "datalab_behave_as": { + "anyOf": [ + { + "enum": [ + "samples", + "cells", + "starting_materials", + "equipment" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Built-in type (which the model must inherit from) whose listing pages, item pickers\nand sharing permissions this item type follows. Without it, a custom type is listed\nwith samples and owned by its creators.", + "title": "Datalab Behave As" } }, "title": "DatalabModelExtra", diff --git a/pydatalab/src/pydatalab/models/__init__.py b/pydatalab/src/pydatalab/models/__init__.py index b83d5dad2..1cf41607f 100644 --- a/pydatalab/src/pydatalab/models/__init__.py +++ b/pydatalab/src/pydatalab/models/__init__.py @@ -103,6 +103,21 @@ def refresh_item_models() -> None: BUILTIN_ITEM_TYPES = frozenset(ITEM_MODELS) +def _behave_as_hint(model: type[Item]) -> str | None: + """Return the ``datalab_behave_as`` hint declared on a model's config, if any.""" + extra = model.model_config.get("json_schema_extra") + return extra.get("datalab_behave_as") if isinstance(extra, dict) else None + + +def item_types_behaving_as(builtin_type: str) -> list[str]: + """Return `builtin_type` and the registered custom types that behave as it.""" + return [ + item_type + for item_type, model in ITEM_MODELS.items() + if item_type == builtin_type or _behave_as_hint(model) == builtin_type + ] + + def _namespace_item_model(model: type[Item], item_type: str) -> str: """Rewrite the `type` literal of an item model *in place* to `item_type`. @@ -176,9 +191,22 @@ def register_item_model(model: type[Item]) -> None: validate_schema_hints(model) + behave_as = _behave_as_hint(model) + if behave_as is not None and not issubclass(model, ITEM_MODELS[behave_as]): + raise ValueError( + f"Custom item model {model.__name__!r} declares datalab_behave_as={behave_as!r} " + f"but does not inherit from {ITEM_MODELS[behave_as].__name__!r}." + ) + ITEM_MODELS[item_type] = model ITEM_SCHEMAS[item_type] = model.model_json_schema(by_alias=False) + # Imported lazily, as `pydatalab.permissions` imports this module (via the config) + from pydatalab.permissions import INVENTORY_TYPES + + if behave_as in INVENTORY_TYPES: + INVENTORY_TYPES.add(item_type) + def constituent_item_types() -> set[str]: """Return the set of registered item types that may be referenced as a @@ -255,6 +283,7 @@ def load_custom_item_models(paths: list[str]) -> None: "ITEM_MODELS", "ITEM_SCHEMAS", "BUILTIN_ITEM_TYPES", + "item_types_behaving_as", "register_item_model", "refresh_item_models", "load_custom_item_models", diff --git a/pydatalab/src/pydatalab/models/schema_hints.py b/pydatalab/src/pydatalab/models/schema_hints.py index ce17fcfd6..62b4c7972 100644 --- a/pydatalab/src/pydatalab/models/schema_hints.py +++ b/pydatalab/src/pydatalab/models/schema_hints.py @@ -9,6 +9,8 @@ and the attribute docstrings become the descriptions in the generated schema. """ +from typing import Literal + from pydantic import ConfigDict, ValidationError from pydatalab.models.units import DatalabQuantity @@ -59,6 +61,11 @@ class DatalabModelExtra(BaseModel): datalab_section_title: str | None = None """Title of the default custom-fields card.""" + datalab_behave_as: Literal["samples", "cells", "starting_materials", "equipment"] | None = None + """Built-in type (which the model must inherit from) whose listing pages, item pickers + and sharing permissions this item type follows. Without it, a custom type is listed + with samples and owned by its creators.""" + def _datalab_hint_keys(extra: dict) -> dict: """Pick the ``datalab_*`` hint keys out of a raw ``json_schema_extra`` dict. diff --git a/pydatalab/src/pydatalab/permissions.py b/pydatalab/src/pydatalab/permissions.py index 261a938d6..1c52d3d32 100644 --- a/pydatalab/src/pydatalab/permissions.py +++ b/pydatalab/src/pydatalab/permissions.py @@ -17,10 +17,11 @@ PUBLIC_USER_ID = ObjectId(24 * "0") -INVENTORY_TYPES = ("equipment", "starting_materials") +INVENTORY_TYPES = {"equipment", "starting_materials"} """Inventory-like item types that are shared across the deployment rather than owned by creators. These items are readable and editable by all users, unless they have been restricted to specific groups, in which case only members of those groups can access them. +Custom types declaring `datalab_behave_as` an inventory type are added at registration. """ diff --git a/pydatalab/src/pydatalab/routes/v0_1/info.py b/pydatalab/src/pydatalab/routes/v0_1/info.py index d16e61a2d..3b7f7496d 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/info.py +++ b/pydatalab/src/pydatalab/routes/v0_1/info.py @@ -282,6 +282,7 @@ def _type_attributes(item_type: str, schema: dict) -> dict: "base_fields": list(base_model.model_fields) if base_model is not None else [], "hidden_fields": extra.datalab_ui_hidden_fields or [], "ui_color": extra.datalab_ui_color, + "behave_as": extra.datalab_behave_as, } diff --git a/pydatalab/src/pydatalab/routes/v0_1/items.py b/pydatalab/src/pydatalab/routes/v0_1/items.py index 7821a281b..29a2b9b61 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/items.py +++ b/pydatalab/src/pydatalab/routes/v0_1/items.py @@ -19,10 +19,10 @@ from pydatalab.feature_flags import FEATURE_FLAGS from pydatalab.logger import LOGGER from pydatalab.models import ( - BUILTIN_ITEM_TYPES, ITEM_MODELS, ItemVersion, flagged_summary_fields, + item_types_behaving_as, ) from pydatalab.models.items import Item from pydatalab.models.relationships import RelationshipType @@ -100,7 +100,8 @@ def get_equipment_summary(): }, } - for field in flagged_summary_fields(("equipment",)): + equipment_types = item_types_behaving_as("equipment") + for field in flagged_summary_fields(equipment_types): _project.setdefault(field, 1) items = [ @@ -109,7 +110,7 @@ def get_equipment_summary(): [ { "$match": { - "type": "equipment", + "type": {"$in": equipment_types}, **get_default_permissions(user_only=False, inherit_from_collections=False), } }, @@ -167,7 +168,8 @@ def get_starting_materials(): }, } - for field in flagged_summary_fields(("starting_materials",)): + starting_material_types = item_types_behaving_as("starting_materials") + for field in flagged_summary_fields(starting_material_types): _project.setdefault(field, 1) items = [ @@ -176,7 +178,7 @@ def get_starting_materials(): [ { "$match": { - "type": "starting_materials", + "type": {"$in": starting_material_types}, **get_default_permissions(user_only=False, inherit_from_collections=False), } }, @@ -319,10 +321,9 @@ def get_samples_summary(match: dict | None = None, project: dict | None = None) if not match: match = {} match.update(get_default_permissions(user_only=False, inherit_from_collections=False)) - # Custom/plugin item types are surfaced in the samples listing for now (a - # `base_type`-aware split into samples/equipment/inventory can refine this later). - custom_item_types = [t for t in ITEM_MODELS if t not in BUILTIN_ITEM_TYPES] - match["type"] = {"$in": ["samples", "cells", *custom_item_types]} + # Custom/plugin item types are surfaced in the samples listing, unless they behave as + # an inventory type (via the `datalab_behave_as` model hint). + match["type"] = {"$in": [t for t in ITEM_MODELS if t not in INVENTORY_TYPES]} _project = { "_id": 0, diff --git a/pydatalab/tests/server/test_custom_item_behave_as.py b/pydatalab/tests/server/test_custom_item_behave_as.py new file mode 100644 index 000000000..fe032968a --- /dev/null +++ b/pydatalab/tests/server/test_custom_item_behave_as.py @@ -0,0 +1,106 @@ +"""Tests for the `datalab_behave_as` model hint, which lets a custom item type follow the +listing and permission behaviour of the built-in type it inherits from.""" + +import copy +from typing import Literal + +import pytest +from pydantic import ConfigDict + +from pydatalab.models.equipment import Equipment +from pydatalab.models.starting_materials import StartingMaterial + + +class HintedStartingMaterial(StartingMaterial): + model_config = ConfigDict(json_schema_extra={"datalab_behave_as": "starting_materials"}) + type: Literal["hinted_starting_materials"] = "hinted_starting_materials" # type: ignore[assignment] + + +class HintedEquipment(Equipment): + model_config = ConfigDict(json_schema_extra={"datalab_behave_as": "equipment"}) + type: Literal["hinted_equipment"] = "hinted_equipment" # type: ignore[assignment] + + +class PlainStartingMaterial(StartingMaterial): + """Without the hint: keeps today's sample-like behaviour.""" + + type: Literal["plain_starting_materials"] = "plain_starting_materials" # type: ignore[assignment] + + +@pytest.fixture(scope="module") +def behave_as_models(): + """Register the models, restoring the global registries afterwards.""" + import pydatalab.models as models + from pydatalab.permissions import INVENTORY_TYPES + + snapshots = [ + (r, copy.copy(r)) for r in (models.ITEM_MODELS, models.ITEM_SCHEMAS, INVENTORY_TYPES) + ] + for model in (HintedStartingMaterial, HintedEquipment, PlainStartingMaterial): + models.register_item_model(model) + yield + for registry, snapshot in snapshots: + registry.clear() + registry.update(snapshot) + + +def _create(client, item_type, item_id): + response = client.post("/new-sample/", json={"type": item_type, "item_id": item_id}) + assert response.status_code == 201, response.json + return response.json["sample_list_entry"] + + +def _listed(client, endpoint, key="items"): + return {item["item_id"] for item in client.get(endpoint).json[key]} + + +def test_hinted_types_are_listed_with_their_builtin_type(client, behave_as_models): + _create(client, "_hinted_starting_materials", "hinted-sm") + _create(client, "_hinted_equipment", "hinted-eq") + _create(client, "_plain_starting_materials", "plain-sm") + + samples = _listed(client, "/samples/", key="samples") + assert "hinted-sm" in _listed(client, "/starting-materials/") + assert "hinted-eq" in _listed(client, "/equipment/") + assert "plain-sm" in samples + assert not {"hinted-sm", "hinted-eq"} & samples + + info = client.get("/info/types/_hinted_starting_materials", follow_redirects=True).json + assert info["data"]["attributes"]["behave_as"] == "starting_materials" + + +def test_hinted_types_have_inventory_permissions( + client, another_client, unverified_client, group_id, behave_as_models +): + entry = _create(client, "_hinted_starting_materials", "perm-hinted-sm") + assert entry["creator_ids"] == [] + refcode = entry["refcode"] + + # Readable and editable by other users... + assert another_client.get(f"/items/{refcode}").status_code == 200 + response = another_client.post( + "/save-item/", json={"item_id": "perm-hinted-sm", "data": {"name": "edited"}} + ) + assert response.status_code == 200, response.json + + # ...unless restricted to a group they are not in + response = client.patch( + f"/items/{refcode}/permissions", json={"groups": [{"immutable_id": str(group_id)}]} + ) + assert response.status_code == 200, response.json + assert unverified_client.get(f"/items/{refcode}").status_code == 404 + + # Without the hint, the item stays private to its creator + plain = _create(client, "_plain_starting_materials", "perm-plain-sm") + assert another_client.get(f"/items/{plain['refcode']}").status_code == 404 + + +def test_behave_as_requires_inheritance(): + from pydatalab.models import register_item_model + + class NotEquipment(StartingMaterial): + model_config = ConfigDict(json_schema_extra={"datalab_behave_as": "equipment"}) + type: Literal["_not_equipment"] = "_not_equipment" # type: ignore[assignment] + + with pytest.raises(ValueError, match="does not inherit from"): + register_item_model(NotEquipment) diff --git a/webapp/cypress/component/CustomItemBehaveAsTest.cy.jsx b/webapp/cypress/component/CustomItemBehaveAsTest.cy.jsx new file mode 100644 index 000000000..719872da7 --- /dev/null +++ b/webapp/cypress/component/CustomItemBehaveAsTest.cy.jsx @@ -0,0 +1,51 @@ +import CreateItemModal from "@/components/CreateItemModal.vue"; +import CreateEquipmentModal from "@/components/CreateEquipmentModal.vue"; +import { + itemTypes, + registerDynamicItemType, + expandItemTypes, + INVENTORY_TABLE_TYPES, + EQUIPMENT_TABLE_TYPES, + INVENTORY_TYPES, +} from "@/resources.js"; + +const SM_TYPE = "_test_hinted_starting_material"; +const EQ_TYPE = "_test_hinted_equipment"; +const PLAIN_TYPE = "_test_plain_starting_material"; + +describe("Custom item types with datalab_behave_as", () => { + afterEach(() => { + for (const type of [SM_TYPE, EQ_TYPE, PLAIN_TYPE]) { + delete itemTypes[type]; + for (const list of [INVENTORY_TABLE_TYPES, EQUIPMENT_TABLE_TYPES, INVENTORY_TYPES]) { + if (list.includes(type)) list.splice(list.indexOf(type), 1); + } + } + }); + + it("lists, creates and searches hinted types with their built-in type", () => { + registerDynamicItemType(SM_TYPE, { behave_as: "starting_materials" }); + registerDynamicItemType(EQ_TYPE, { behave_as: "equipment" }); + registerDynamicItemType(PLAIN_TYPE, {}); + + const schemas = { [SM_TYPE]: {}, [EQ_TYPE]: {}, [PLAIN_TYPE]: {} }; + const allowedTypes = (types) => + CreateItemModal.computed.effectiveAllowedTypes.call({ + allowedTypes: types, + $store: { state: { schemas } }, + }); + + expect(allowedTypes(["samples", "cells"])).to.deep.equal(["samples", "cells", PLAIN_TYPE]); + expect(allowedTypes(INVENTORY_TABLE_TYPES)).to.deep.equal(["starting_materials", SM_TYPE]); + expect(Object.keys(CreateEquipmentModal.computed.availableTypes.call({}))).to.deep.equal([ + "equipment", + EQ_TYPE, + ]); + expect(INVENTORY_TYPES).to.include.members([SM_TYPE, EQ_TYPE]).and.not.include(PLAIN_TYPE); + expect(expandItemTypes(["samples", "starting_materials"])).to.deep.equal([ + "samples", + "starting_materials", + SM_TYPE, + ]); + }); +}); diff --git a/webapp/src/components/CreateEquipmentModal.vue b/webapp/src/components/CreateEquipmentModal.vue index 54be1f088..2eed0254e 100644 --- a/webapp/src/components/CreateEquipmentModal.vue +++ b/webapp/src/components/CreateEquipmentModal.vue @@ -24,7 +24,7 @@ v-model="item_type" class="form-control" required - disabled + :disabled="Object.keys(availableTypes).length < 2" >