Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions pydatalab/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions pydatalab/schemas/datalab_model_extra.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions pydatalab/src/pydatalab/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions pydatalab/src/pydatalab/models/schema_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion pydatalab/src/pydatalab/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""


Expand Down
1 change: 1 addition & 0 deletions pydatalab/src/pydatalab/routes/v0_1/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down
19 changes: 10 additions & 9 deletions pydatalab/src/pydatalab/routes/v0_1/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand All @@ -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),
}
},
Expand Down Expand Up @@ -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 = [
Expand All @@ -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),
}
},
Expand Down Expand Up @@ -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,
Expand Down
106 changes: 106 additions & 0 deletions pydatalab/tests/server/test_custom_item_behave_as.py
Original file line number Diff line number Diff line change
@@ -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)
51 changes: 51 additions & 0 deletions webapp/cypress/component/CustomItemBehaveAsTest.cy.jsx
Original file line number Diff line number Diff line change
@@ -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,
]);
});
});
Loading
Loading