diff --git a/pydatalab/schemas/cell.json b/pydatalab/schemas/cell.json index c66e9dcb3..01c88f2d1 100644 --- a/pydatalab/schemas/cell.json +++ b/pydatalab/schemas/cell.json @@ -1221,6 +1221,19 @@ "title": "Display Order", "type": "array" }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The place where the item is located.", + "title": "Location" + }, "collections": { "description": "Inlined info for the collections associated with this item.", "items": { diff --git a/pydatalab/schemas/equipment.json b/pydatalab/schemas/equipment.json index e0312f30a..39eeb6956 100644 --- a/pydatalab/schemas/equipment.json +++ b/pydatalab/schemas/equipment.json @@ -1057,6 +1057,19 @@ "title": "Display Order", "type": "array" }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The place where the item is located.", + "title": "Location" + }, "collections": { "description": "Inlined info for the collections associated with this item.", "items": { @@ -1306,19 +1319,6 @@ "description": "The manufacturer of this piece of equipment", "title": "Manufacturer" }, - "location": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Place where the equipment is located", - "title": "Location" - }, "contact": { "anyOf": [ { diff --git a/pydatalab/schemas/sample.json b/pydatalab/schemas/sample.json index 2d85b667b..d2861529e 100644 --- a/pydatalab/schemas/sample.json +++ b/pydatalab/schemas/sample.json @@ -1330,6 +1330,19 @@ "title": "Display Order", "type": "array" }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The place where the item is located.", + "title": "Location" + }, "collections": { "description": "Inlined info for the collections associated with this item.", "items": { diff --git a/pydatalab/schemas/startingmaterial.json b/pydatalab/schemas/startingmaterial.json index a096ffb55..1e8c7f9cb 100644 --- a/pydatalab/schemas/startingmaterial.json +++ b/pydatalab/schemas/startingmaterial.json @@ -1189,6 +1189,37 @@ }, "description": "A model for representing an experimental sample, based on the connection\nwith cheminventory.net, which mixes container-level and substance-level\ninformation.", "properties": { + "blocks_obj": { + "additionalProperties": { + "$ref": "#/$defs/DataBlockResponse" + }, + "default": {}, + "description": "A mapping from block ID to block data.", + "title": "Blocks Obj", + "type": "object" + }, + "display_order": { + "default": [], + "description": "The order in which to display block data in the UI.", + "items": { + "type": "string" + }, + "title": "Display Order", + "type": "array" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The place where the container is located.", + "title": "Location" + }, "chemform": { "anyOf": [ { @@ -1313,24 +1344,6 @@ "description": "Free-text details of the procedure applied to synthesise the sample", "title": "Synthesis Description" }, - "blocks_obj": { - "additionalProperties": { - "$ref": "#/$defs/DataBlockResponse" - }, - "default": {}, - "description": "A mapping from block ID to block data.", - "title": "Blocks Obj", - "type": "object" - }, - "display_order": { - "default": [], - "description": "The order in which to display block data in the UI.", - "items": { - "type": "string" - }, - "title": "Display Order", - "type": "array" - }, "collections": { "description": "Inlined info for the collections associated with this item.", "items": { @@ -1646,19 +1659,6 @@ "description": "Supplier or manufacturer of the chemical.", "title": "Supplier" }, - "location": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "The place where the container is located.", - "title": "Location" - }, "comment": { "anyOf": [ { diff --git a/pydatalab/src/pydatalab/models/equipment.py b/pydatalab/src/pydatalab/models/equipment.py index fb2dbd9d5..0a7f8932e 100644 --- a/pydatalab/src/pydatalab/models/equipment.py +++ b/pydatalab/src/pydatalab/models/equipment.py @@ -19,9 +19,6 @@ class Equipment(Item): manufacturer: str | None = None """The manufacturer of this piece of equipment""" - location: str | None = None - """Place where the equipment is located""" - contact: str | None = None """Contact information for equipment (e.g., email address or phone number).""" diff --git a/pydatalab/src/pydatalab/models/items.py b/pydatalab/src/pydatalab/models/items.py index d1295d180..6075c2546 100644 --- a/pydatalab/src/pydatalab/models/items.py +++ b/pydatalab/src/pydatalab/models/items.py @@ -6,6 +6,7 @@ from pydatalab.models.files import File from pydatalab.models.traits import ( HasBlocks, + HasLocation, HasOwner, HasRevisionControl, IsCollectable, @@ -18,7 +19,15 @@ ) -class Item(Entry, HasOwner, HasRevisionControl, IsCollectable, HasBlocks, abc.ABC): +class Item( + Entry, + HasOwner, + HasRevisionControl, + IsCollectable, + HasLocation, + HasBlocks, + abc.ABC, +): """The generic model for data types that will be exposed with their own named endpoints.""" refcode: Refcode | None = None diff --git a/pydatalab/src/pydatalab/models/starting_materials.py b/pydatalab/src/pydatalab/models/starting_materials.py index 8bbdad870..3cdef93ad 100644 --- a/pydatalab/src/pydatalab/models/starting_materials.py +++ b/pydatalab/src/pydatalab/models/starting_materials.py @@ -3,11 +3,11 @@ from pydantic import Field from pydatalab.models.items import Item -from pydatalab.models.traits import HasSubstanceInfo, HasSynthesisInfo +from pydatalab.models.traits import HasLocation, HasSubstanceInfo, HasSynthesisInfo from pydatalab.models.utils import IsoformatDateTime, StartingMaterialsStatus -class StartingMaterial(Item, HasSynthesisInfo, HasSubstanceInfo): +class StartingMaterial(Item, HasSynthesisInfo, HasSubstanceInfo, HasLocation): """A model for representing an experimental sample, based on the connection with cheminventory.net, which mixes container-level and substance-level information. diff --git a/pydatalab/src/pydatalab/models/traits.py b/pydatalab/src/pydatalab/models/traits.py index b7805c984..a9c80c719 100644 --- a/pydatalab/src/pydatalab/models/traits.py +++ b/pydatalab/src/pydatalab/models/traits.py @@ -13,6 +13,7 @@ "IsCollectable", "HasSynthesisInfo", "HasSubstanceInfo", + "HasLocation", ) @@ -229,3 +230,8 @@ def add_molar_mass(cls, v, info): return None return v + + +class HasLocation(BaseModel): + location: str | None = Field(alias="Location") + """The place where the item is located.""" diff --git a/pydatalab/src/pydatalab/routes/v0_1/items.py b/pydatalab/src/pydatalab/routes/v0_1/items.py index cee1b698c..bc3f1fa57 100644 --- a/pydatalab/src/pydatalab/routes/v0_1/items.py +++ b/pydatalab/src/pydatalab/routes/v0_1/items.py @@ -1809,3 +1809,26 @@ def get_access_token_info(refcode: str): ), 200 else: return jsonify({"status": "success", "has_token": False}), 200 + + +@ITEMS.route("/locations", methods=["GET"]) +def get_locations_for_items(): + items = flask_mongo.db.items.distinct( + "location", + { + "location": {"$ne": None}, + **get_default_permissions(user_only=False), + }, + ) + flat_locations = list(items) + nested_locations = {} + for location_locators in flat_locations: + comprising_locations = location_locators.split(">") + curr_dict = nested_locations + for location in comprising_locations: + location = location.strip() + if location not in curr_dict: + curr_dict[location] = {} + curr_dict = curr_dict[location] + + return jsonify({"flat_locations": flat_locations, "nested_locations": nested_locations}), 200 diff --git a/pydatalab/tests/server/conftest.py b/pydatalab/tests/server/conftest.py index b00855b89..85ee6c907 100644 --- a/pydatalab/tests/server/conftest.py +++ b/pydatalab/tests/server/conftest.py @@ -7,6 +7,7 @@ from pydatalab.models import Cell, Collection, Equipment, Sample, StartingMaterial from pydatalab.models.people import AccountStatus +from pydatalab.mongo import flask_mongo TEST_DATABASE_NAME = "__datalab-testing__" @@ -609,6 +610,24 @@ def _insert_and_cleanup_item_from_model(model): flask_mongo.db.items.delete_one({"refcode": model.refcode}) +@pytest.fixture(scope="function", name="item_creator") +def fixture_item_creator(): + ref_codes_to_remove = [] + + def _insert_item_from_model(model): + from pydatalab.models.utils import generate_unique_refcode + from pydatalab.mongo import flask_mongo + + refcode = generate_unique_refcode() + model.refcode = refcode + flask_mongo.db.items.insert_one(model.model_dump(exclude_unset=False)) + ref_codes_to_remove.append(refcode) + return model + + yield _insert_item_from_model + flask_mongo.db.items.delete_many({"refcode": {"$in": ref_codes_to_remove}}) + + @pytest.fixture(scope="module", name="insert_default_sample") def fixture_insert_default_sample(default_sample): yield from _insert_and_cleanup_item_from_model(default_sample) diff --git a/pydatalab/tests/server/test_items.py b/pydatalab/tests/server/test_items.py index 9c9ad1598..a40c6df0c 100644 --- a/pydatalab/tests/server/test_items.py +++ b/pydatalab/tests/server/test_items.py @@ -1,3 +1,6 @@ +from pydatalab.models import Cell + + def test_single_item_endpoints(client, inserted_default_items): for item in inserted_default_items: response = client.get(f"/items/{item.refcode}") @@ -26,3 +29,514 @@ def test_fts_fields(): fields = ("item_id", "name", "description", "refcode", "synthesis_description", "supplier") assert all(field in get_items_fts_fields() for field in fields) + + +def test_location_endpoint(client, item_creator, user_id): + item_creator( + Cell( + **{ + "item_id": "test_cell0", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 2.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 2.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 2000, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 100, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Place1>Place2>Place3", + } + ) + ) + item_creator( + Cell( + **{ + "item_id": "test_cell_2", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 5.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 1.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 500, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 10, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Place1>Place2", + } + ) + ) + item_creator( + Cell( + **{ + "item_id": "test_cell_3", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 5.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 1.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 500, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 10, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Place4>Place5>Place6", + } + ) + ) + item_creator( + Cell( + **{ + "item_id": "test_cell_4", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 5.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 1.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 500, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 10, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Place1>Place7>Place8", + } + ) + ) + response = client.get("/locations") + assert response.status_code == 200 + assert "flat_locations" in response.json + + assert set(response.json["flat_locations"]).issuperset( + { + "Place1>Place2>Place3", + "Place1>Place2", + "Place4>Place5>Place6", + "Place1>Place7>Place8", + } + ) + + # non-flat checks + assert "nested_locations" in response.json + assert response.json["nested_locations"] + print(response.json["nested_locations"]) + assert "Place4" in response.json["nested_locations"] + assert "Place5" in response.json["nested_locations"]["Place4"] + assert "Place6" in response.json["nested_locations"]["Place4"]["Place5"] + assert "Place1" in response.json["nested_locations"] + assert "Place2" in response.json["nested_locations"]["Place1"] + assert "Place7" in response.json["nested_locations"]["Place1"] + assert "Place3" in response.json["nested_locations"]["Place1"]["Place2"] + assert "Place8" in response.json["nested_locations"]["Place1"]["Place7"] + + +def test_location_endpoint_with_spaced_signs(client, item_creator, user_id): + item_creator( + Cell( + **{ + "item_id": "test_cell0", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 2.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 2.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 2000, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 100, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Lab 1 > Shelf 2", + } + ) + ) + item_creator( + Cell( + **{ + "item_id": "test_cell_2", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 5.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 1.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 500, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 10, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Lab 1 > Shelf 3", + } + ) + ) + item_creator( + Cell( + **{ + "item_id": "test_cell_3", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 5.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 1.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 500, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 10, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Lab 2 > Shelf 1", + } + ) + ) + item_creator( + Cell( + **{ + "item_id": "test_cell_4", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 5.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 1.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 500, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 10, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Lab 1 > Shelf 2 > Position A", + } + ) + ) + response = client.get("/locations") + assert response.status_code == 200 + assert "flat_locations" in response.json + + assert set(response.json["flat_locations"]).issuperset( + { + "Lab 1 > Shelf 2", + "Lab 1 > Shelf 3", + "Lab 2 > Shelf 1", + "Lab 1 > Shelf 2 > Position A", + } + ) + assert "nested_locations" in response.json + assert "Lab 1" in response.json["nested_locations"] + assert "Shelf 2" in response.json["nested_locations"]["Lab 1"] + assert "Shelf 3" in response.json["nested_locations"]["Lab 1"] + assert "Position A" in response.json["nested_locations"]["Lab 1"]["Shelf 2"] + + assert "Lab 2" in response.json["nested_locations"] + assert "Shelf 1" in response.json["nested_locations"]["Lab 2"] + + +def test_location_endpoint_with_single_non_nested_location(client, item_creator, user_id): + item_creator( + Cell( + **{ + "item_id": "test_cell_4", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 5.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 1.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 500, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 10, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Lab 1", + } + ) + ) + response = client.get("/locations") + assert response.status_code == 200 + assert "flat_locations" in response.json + + assert set(response.json["flat_locations"]).issuperset({"Lab 1"}) + assert "nested_locations" in response.json + assert "Lab 1" in response.json["nested_locations"] + + +def test_regression_test_against_overwriting_problem(client, item_creator, user_id): + # test ensures that items don't get over (i.e. Position A should not get overwritten) + item_creator( + Cell( + **{ + "item_id": "test_cell_4", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 5.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 1.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 500, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 10, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Cambridge > Lab 6 > Shelf 2 > Position A", + } + ) + ) + item_creator( + Cell( + **{ + "item_id": "test_cell0", + "name": "test cell", + "date": "1970-02-01", + "negative_electrode": [ + { + "item": {"item_id": "test", "type": "starting_materials"}, + "quantity": 2.0, + "unit": "mg", + }, + { + "item": {"item_id": "test_carbon", "chemform": "C", "type": "samples"}, + "quantity": 2.0, + "unit": "mg", + }, + ], + "positive_electrode": [ + { + "item": { + "item_id": "test_cathode", + "chemform": "LiCoO2", + "type": "samples", + }, + "quantity": 2000, + "unit": "kg", + } + ], + "electrolyte": [ + {"item": {"name": "inlined reference"}, "quantity": 100, "unit": "ml"} + ], + "cell_format": "swagelok", + "type": "cells", + "creator_ids": [user_id], + "Location": "Cambridge > Lab 6 > Shelf 1", + } + ) + ) + + response = client.get("/locations") + assert response.status_code == 200 + assert "flat_locations" in response.json + + assert set(response.json["flat_locations"]).issuperset( + { + "Cambridge > Lab 6 > Shelf 1", + "Cambridge > Lab 6 > Shelf 2 > Position A", + } + ) + assert "nested_locations" in response.json + assert "Cambridge" in response.json["nested_locations"] + assert "Lab 6" in response.json["nested_locations"]["Cambridge"] + assert "Shelf 1" in response.json["nested_locations"]["Cambridge"]["Lab 6"] + assert "Shelf 2" in response.json["nested_locations"]["Cambridge"]["Lab 6"] + assert "Position A" in response.json["nested_locations"]["Cambridge"]["Lab 6"]["Shelf 2"]