From c22f4c68b43b1ce99c0c3f4fd36e4acc204e32d9 Mon Sep 17 00:00:00 2001 From: ravi Date: Thu, 1 Oct 2026 19:13:38 -0400 Subject: [PATCH 01/26] Add routes listing existing compounds and storage places The add-resource page will offer dropdowns of what already exists in the ELN instead of free-typed fields: /compounds_list returns every compound as {id, name, cas, formula} sorted by name, /storage_tree every storage place as {id, name, parent_id, full_path} so the page can build Room -> Place dropdowns. Both act as the caller's API key, like /categories. get_compounds() now returns all compounds (it was capped at 1000) and raises on HTTP errors; get_storage_units() is new. Co-Authored-By: Claude Opus 5.5 --- eln_common/resourcemanage.py | 16 +++++++++++-- tests/test_routes_offline.py | 37 +++++++++++++++++++++++++++++ web/interface.py | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index f98df10..0433d55 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -130,10 +130,22 @@ def associate_compound(self, comp_id:int,res_id:int): def get_compounds(self): """ - Gets a list of compounds in the ELN as dictionaries. + Gets every (non-deleted) compound in the ELN as dictionaries. :return: A list of dictionaries containing the compounds. """ - return self.get_url("/compounds?limit=1000").json() + response = self.get_url("/compounds?limit=9999") + response.raise_for_status() + return response.json() + + def get_storage_units(self) -> list[dict[str, Any]]: + """ + Gets every storage place (room, cabinet, ...) in the ELN. + :return: A list of dictionaries with {id, name, parent_id, full_path, ...}; + parent_id is None for top-level places (rooms). + """ + response = self.get_url("/storage_units?hierarchy=true") + response.raise_for_status() + return response.json() def add_tag(self, item_id: int, tag: str): """ Adds a tag to an item in the ELN with the given item ID and tag. Take care to use correct capitalization/whitespace diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 3637ac0..43987f3 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -371,6 +371,43 @@ def fake_autofill_item(rmn, item_id, **kwargs): assert autofill_calls == [999] +class TestLookupLists: + """The add-resource page loads existing compounds and storage places into dropdowns.""" + + def test_compounds_list_is_trimmed_and_sorted(self, client, monkeypatch): + fake_rm = SimpleNamespace(get_compounds=lambda: [ + {"id": 72, "name": "acetone", "cas_number": "67-64-1", "molecular_formula": "C3H6O", "smiles": "CC(C)=O"}, + {"id": 111, "name": "Chlorobenzene", "cas_number": "108-90-7", "molecular_formula": None}, + {"id": 196, "name": None, "cas_number": None, "molecular_formula": None}, + ]) + monkeypatch.setattr(interface, "rm", lambda: fake_rm) + resp = client.get("/compounds_list") + assert resp.status_code == 200 + assert resp.get_json() == [ + {"id": 196, "name": "", "cas": "", "formula": ""}, + {"id": 72, "name": "acetone", "cas": "67-64-1", "formula": "C3H6O"}, + {"id": 111, "name": "Chlorobenzene", "cas": "108-90-7", "formula": ""}, + ] + + def test_storage_tree_keeps_hierarchy(self, client, monkeypatch): + fake_rm = SimpleNamespace(get_storage_units=lambda: [ + {"id": 4, "name": "Room 3057", "parent_id": None, "full_path": "Room 3057", "level_depth": 0}, + {"id": 5, "name": "Front hood", "parent_id": 4, "full_path": "Room 3057 > Front hood", "level_depth": 1}, + ]) + monkeypatch.setattr(interface, "rm", lambda: fake_rm) + resp = client.get("/storage_tree") + assert resp.status_code == 200 + assert resp.get_json() == [ + {"id": 4, "name": "Room 3057", "parent_id": None, "full_path": "Room 3057"}, + {"id": 5, "name": "Front hood", "parent_id": 4, "full_path": "Room 3057 > Front hood"}, + ] + + @pytest.mark.parametrize("path", ["/compounds_list", "/storage_tree"]) + def test_lists_need_an_api_key(self, client, path): + resp = client.get(path) + assert resp.status_code == 401 + + class TestCreateItemFromTemplate: """New resources start from their template, so they get its category and default status (items created from a bare category had no status).""" diff --git a/web/interface.py b/web/interface.py index 0106e9a..155f463 100644 --- a/web/interface.py +++ b/web/interface.py @@ -79,6 +79,51 @@ def get_statuses(): return jsonify({"status": "error", "error": str(e)}), 400 +@interface_bp.route('/compounds_list', methods=['GET']) +@cross_origin(origins="http://localhost:8000") +def compounds_list(): + """Existing compounds as [{id, name, cas, formula}], sorted by name, for the + add-resource compound dropdown.""" + try: + compounds = rm().get_compounds() + listing = [ + { + "id": c["id"], + "name": c.get("name") or "", + "cas": c.get("cas_number") or "", + "formula": c.get("molecular_formula") or "", + } + for c in compounds + ] + return jsonify(sorted(listing, key=lambda c: c["name"].lower())) + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 401 + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 400 + + +@interface_bp.route('/storage_tree', methods=['GET']) +@cross_origin(origins="http://localhost:8000") +def storage_tree(): + """Storage places as [{id, name, parent_id, full_path}], for the + Room -> Place dropdowns (rooms have parent_id null).""" + try: + units = rm().get_storage_units() + return jsonify([ + { + "id": u["id"], + "name": u["name"], + "parent_id": u.get("parent_id"), + "full_path": u.get("full_path") or u["name"], + } + for u in units + ]) + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 401 + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 400 + + # the team-ID settings that the web settings page may change, with their coercers SETTINGS_SCHEMA = { "status_open": int, From 92fa38252abdf60686fc63c6587e721fb23a0a08 Mon Sep 17 00:00:00 2001 From: ravi Date: Thu, 1 Oct 2026 19:19:15 -0400 Subject: [PATCH 02/26] Import compounds from PubChem without overwriting existing ones eLabFTW's compound create (both the PubChem "duplicate" import and a plain POST) upserts: if CAS, InChIKey or PubChem ID matches any compound, even a deleted one, it overwrites that compound and returns its id. In the lab's ELN that turned Hexitol (#80) into Mannitol and restored deleted #8. New eln_common/compound_import.py: - find_existing() checks a PubChem result against every compound (all states) on PubChem ID, InChIKey and the CAS that would be saved - create_compound_safely() refuses a clash (CompoundClash), saves the user's CAS rather than PubChem's, and stops if eLabFTW still returns an existing id Routes for the add-resource page: - GET /compounds/pubchem?cas=|name= read-only preview, flags existing - POST /compounds {cid, cas} re-fetches by PubChem ID and creates safely; 201 new, 409 with the existing compound, 404 not in PubChem Tried on a local 6.0.5 copy: new compound created, repeat refused (409), deleted compound #2 refused and left deleted. Co-Authored-By: Claude Opus 5.5 --- eln_common/compound_import.py | 109 ++++++++++++++++++++++++++++++++ eln_common/resourcemanage.py | 55 +++++++++++++++++ tests/test_routes_offline.py | 113 ++++++++++++++++++++++++++++++++++ web/interface.py | 74 ++++++++++++++++++++++ 4 files changed, 351 insertions(+) create mode 100644 eln_common/compound_import.py diff --git a/eln_common/compound_import.py b/eln_common/compound_import.py new file mode 100644 index 0000000..7eeec65 --- /dev/null +++ b/eln_common/compound_import.py @@ -0,0 +1,109 @@ +"""Bring compounds in from PubChem without damaging existing ones. + +eLabFTW's own "import from PubChem" (POST /compounds with action=duplicate) and its +plain create both *upsert*: when a unique field (CAS, InChIKey, PubChem ID...) +matches any existing compound, including a deleted one, eLabFTW overwrites that +compound and restores it if deleted, then returns its id. In the lab's ELN this +replaced compound #80 (Hexitol) with Mannitol and brought back deleted #8. + +So: look the compound up in PubChem first (read only), refuse if it would clash +with a compound we already have, create it with the CAS the user gave, and check +that eLabFTW really made a new compound. +""" + +from typing import Any + +from eln_common.resourcemanage import Resource_Manager + + +class CompoundClash(Exception): + """The compound already exists in the ELN (possibly deleted).""" + + def __init__(self, existing: dict[str, Any], reason: str): + self.existing = existing + self.reason = reason + super().__init__(reason) + + +def summary(compound: dict[str, Any]) -> dict[str, Any]: + """The few fields the web page shows for an ELN compound.""" + return { + "id": compound["id"], + "name": compound.get("name") or "", + "cas": compound.get("cas_number") or "", + "formula": compound.get("molecular_formula") or "", + "deleted": compound.get("state") not in (None, 1), + } + + +def find_existing(pubchem: dict[str, Any], cas: str | None, + compounds: list[dict[str, Any]]) -> tuple[dict[str, Any], str] | None: + """ + The ELN compound (any state) that a PubChem result would collide with, and why. + Checks the unique fields that would be sent: PubChem ID, InChIKey, and the CAS + actually saved (the user's, else PubChem's; see build_compound_body). + """ + keys = [ + ("pubchem_cid", pubchem.get("cid"), "same PubChem ID"), + ("inchi_key", pubchem.get("inChIKey"), "same structure (InChIKey)"), + ("cas_number", cas or pubchem.get("cas"), "same CAS number"), + ] + for field, value, reason in keys: + if not value: + continue + for compound in compounds: + if compound.get(field) is not None and str(compound.get(field)) == str(value): + return compound, reason + return None + + +def build_compound_body(pubchem: dict[str, Any], cas: str | None) -> dict[str, Any]: + """The fields sent to eLabFTW, from a PubChem result. The user's CAS wins over PubChem's.""" + body = { + "name": pubchem.get("name"), + "cas_number": cas or pubchem.get("cas"), + "pubchem_cid": pubchem.get("cid"), + "inchi": pubchem.get("inChI"), + "inchi_key": pubchem.get("inChIKey"), + "smiles": pubchem.get("smiles"), + "iupac_name": pubchem.get("iupacName"), + "molecular_formula": pubchem.get("molecularFormula"), + "is_corrosive": pubchem.get("isCorrosive"), + "is_explosive": pubchem.get("isExplosive"), + "is_flammable": pubchem.get("isFlammable"), + "is_gas_under_pressure": pubchem.get("isGasUnderPressure"), + "is_hazardous2env": pubchem.get("isHazardous2env"), + "is_hazardous2health": pubchem.get("isHazardous2health"), + "is_serious_health_hazard": pubchem.get("isSeriousHealthHazard"), + "is_oxidising": pubchem.get("isOxidising"), + "is_toxic": pubchem.get("isToxic"), + } + # leave out empty fields: an empty unique field can also trigger the upsert + return {k: v for k, v in body.items() if v not in (None, "")} + + +def create_compound_safely(rm: Resource_Manager, pubchem: dict[str, Any], + cas: str | None) -> dict[str, Any]: + """ + Creates the compound described by a PubChem result, unless it would touch an + existing one. + :raises CompoundClash: the ELN already has this compound (maybe deleted). + :raises RuntimeError: eLabFTW returned an existing compound's id anyway. + :return: The new compound as eLabFTW stores it. + """ + compounds = rm.get_all_compounds() + clash = find_existing(pubchem, cas, compounds) + if clash: + raise CompoundClash(*clash) + + known_ids = {c["id"] for c in compounds} + new_id = rm.create_compound(build_compound_body(pubchem, cas)) + if new_id in known_ids: + # the check above should make this impossible; never link to an overwritten compound + raise RuntimeError( + f"eLabFTW saved this into existing compound #{new_id} instead of creating a new one; " + "check that compound by hand") + if pubchem.get("molecularWeight"): + # not accepted on create, so it is set right after + rm.patch_compound(new_id, {"molecular_weight": pubchem["molecularWeight"]}) + return rm.get_compound(new_id) diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index 0433d55..dcd6258 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -137,6 +137,61 @@ def get_compounds(self): response.raise_for_status() return response.json() + def get_all_compounds(self) -> list[dict[str, Any]]: + """ + Gets every compound including archived and deleted ones (state 1, 2, 3). + Deleted compounds still hold their CAS, PubChem ID and InChIKey, so they + matter when checking whether a new compound would clash. + """ + response = self.get_url("/compounds?limit=9999&state=1,2,3") + response.raise_for_status() + return response.json() + + def pubchem_lookup(self, cas: str | None = None, name: str | None = None, + cid: int | None = None) -> list[dict[str, Any]]: + """ + Looks a compound up in PubChem through eLabFTW, without saving anything. + :return: Matching PubChem compounds as dictionaries with {cid, cas, name, + inChI, inChIKey, smiles, iupacName, molecularFormula, molecularWeight, + isFlammable, ...}; an empty list when PubChem has no match. + """ + if cid is not None: + query = {"search_pubchem_cid": cid} + elif cas: + query = {"search_pubchem_cas": cas} + elif name: + query = {"search_pubchem_name": name} + else: + raise ValueError("Give a CAS number, a name or a PubChem CID") + response = requests.get(config.URL + "/compounds", headers=self.header, params=query) + # eLabFTW answers 500 with PubChem's "PUGREST.NotFound" when there is no match + if response.status_code == 500 and "NotFound" in response.text: + return [] + response.raise_for_status() + found = response.json() + return found if isinstance(found, list) else [found] + + def create_compound(self, body: dict[str, Any]) -> int: + """ + Creates a compound from the given fields. Prefer compound_import.create_compound_safely(): + if any unique field (CAS, InChIKey, PubChem ID...) matches an existing or deleted + compound, eLabFTW overwrites that compound and returns its id instead. + :return: The id eLabFTW reports for the saved compound. + """ + response = self.post_url("/compounds", json=body) + response.raise_for_status() + return int(str(response.headers["Location"]).rstrip("/").split("/").pop()) + + def patch_compound(self, id: int, body: dict[str, Any]) -> None: + """Changes fields of the compound with the given ID.""" + self.patch_url("/compounds/" + str(id), json=body).raise_for_status() + + def get_compound(self, id: int) -> dict[str, Any]: + """Gets one compound as a dictionary.""" + response = self.get_url("/compounds/" + str(id)) + response.raise_for_status() + return response.json() + def get_storage_units(self) -> list[dict[str, Any]]: """ Gets every storage place (room, cabinet, ...) in the ELN. diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 43987f3..ccc22fb 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -11,6 +11,7 @@ import pytest import automations.autofill as autofill +import eln_common.compound_import as compound_import import web.interface as interface import web.search_process as search_process from automations.labels.generate_label import LabelGenerator @@ -408,6 +409,118 @@ def test_lists_need_an_api_key(self, client, path): assert resp.status_code == 401 +class FakeCompoundRM: + """Stand-in for the compound calls of Resource_Manager.""" + + def __init__(self, compounds, pubchem=None, create_returns=200): + self.compounds = compounds + self.pubchem = pubchem or [] + self.create_returns = create_returns + self.created, self.patched = [], [] + + def get_all_compounds(self): + return self.compounds + + def pubchem_lookup(self, cas=None, name=None, cid=None): + return self.pubchem + + def create_compound(self, body): + self.created.append(body) + return self.create_returns + + def patch_compound(self, id, body): + self.patched.append((id, body)) + + def get_compound(self, id): + body = self.created[-1] + return {"id": id, "name": body.get("name"), "cas_number": body.get("cas_number"), + "molecular_formula": body.get("molecular_formula"), "state": 1} + + +MANNITOL = {"cid": 6251, "cas": "87-78-5", "name": "Mannitol", "inChIKey": "FBPFZTCFMRRESA-KVTDHHQDSA-N", + "molecularFormula": "C6H14O6", "molecularWeight": 182.17, "smiles": "C(...)O", "isFlammable": False} +HEXITOL_80 = {"id": 80, "name": "Hexitol", "cas_number": "87-78-5", "pubchem_cid": 453, + "inchi_key": "FBPFZTCFMRRESA-UHFFFAOYSA-N", "state": 1} + + +class TestCompoundImport: + """Rules from the 2026-09 cleanup: eLabFTW overwrites a compound that shares a + unique field, even a deleted one, so clashes are refused before creating.""" + + def test_users_cas_is_kept_and_pubchems_cas_does_not_clash(self): + # Mannitol: the bottle says 69-65-8, PubChem lists 87-78-5 which Hexitol (#80) holds + rm = FakeCompoundRM([HEXITOL_80], create_returns=126) + created = compound_import.create_compound_safely(rm, MANNITOL, "69-65-8") + assert created["id"] == 126 and created["cas_number"] == "69-65-8" + assert rm.created[0]["pubchem_cid"] == 6251 + assert rm.patched == [(126, {"molecular_weight": 182.17})] + # empty/None fields are left out (False flags are kept) + assert "iupac_name" not in rm.created[0] and rm.created[0]["is_flammable"] is False + + def test_same_pubchem_id_is_refused(self): + existing = {**HEXITOL_80, "pubchem_cid": 6251} + rm = FakeCompoundRM([existing]) + with pytest.raises(compound_import.CompoundClash) as e: + compound_import.create_compound_safely(rm, MANNITOL, "69-65-8") + assert e.value.existing["id"] == 80 and "PubChem ID" in e.value.reason + assert rm.created == [] + + def test_deleted_compound_with_same_cas_is_refused(self): + # like #8 Phenylboronic acid: deleted, but it still holds its CAS + deleted = {"id": 8, "name": "Phenylboronic acid", "cas_number": "98-80-6", "state": 3} + pc = {"cid": 66827, "cas": "98-80-6", "name": "Phenylboronic Acid", "inChIKey": "HXIT-X"} + rm = FakeCompoundRM([deleted]) + with pytest.raises(compound_import.CompoundClash) as e: + compound_import.create_compound_safely(rm, pc, "98-80-6") + assert e.value.existing["id"] == 8 and compound_import.summary(e.value.existing)["deleted"] + + def test_existing_id_returned_by_elabftw_stops(self): + # if eLabFTW still overwrote something, never treat that compound as new + rm = FakeCompoundRM([HEXITOL_80], create_returns=80) + with pytest.raises(RuntimeError, match="#80"): + compound_import.create_compound_safely(rm, MANNITOL, "69-65-8") + assert rm.patched == [] + + +class TestCompoundRoutes: + def test_preview_marks_existing_compound(self, client, monkeypatch): + acetone = {"cid": 180, "cas": "67-64-1", "name": "Acetone", "molecularFormula": "C3H6O"} + rm = FakeCompoundRM([{"id": 72, "name": "Acetone", "cas_number": "67-64-1", "pubchem_cid": 180, + "molecular_formula": "C3H6O", "state": 1}], pubchem=[acetone]) + monkeypatch.setattr(interface, "rm", lambda: rm) + resp = client.get("/compounds/pubchem?cas=67-64-1") + assert resp.status_code == 200 + candidate = resp.get_json()["candidates"][0] + assert candidate["pubchem"]["name"] == "Acetone" + assert candidate["existing"]["id"] == 72 and candidate["reason"] == "same PubChem ID" + + def test_preview_not_in_pubchem_is_404(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeCompoundRM([], pubchem=[])) + assert client.get("/compounds/pubchem?cas=1599466-85-9").status_code == 404 + + def test_preview_needs_cas_or_name(self, client): + assert client.get("/compounds/pubchem").status_code == 400 + + def test_create_returns_201_with_new_compound(self, client, monkeypatch): + rm = FakeCompoundRM([HEXITOL_80], pubchem=[MANNITOL], create_returns=126) + monkeypatch.setattr(interface, "rm", lambda: rm) + resp = client.post("/compounds", json={"cid": 6251, "cas": "69-65-8"}) + assert resp.status_code == 201 + assert resp.get_json() == {"id": 126, "name": "Mannitol", "cas": "69-65-8", + "formula": "C6H14O6", "deleted": False} + + def test_create_clash_is_409_with_existing(self, client, monkeypatch): + rm = FakeCompoundRM([{**HEXITOL_80, "pubchem_cid": 6251}], pubchem=[MANNITOL]) + monkeypatch.setattr(interface, "rm", lambda: rm) + resp = client.post("/compounds", json={"cid": 6251, "cas": "69-65-8"}) + assert resp.status_code == 409 + assert resp.get_json()["existing"]["id"] == 80 + + @pytest.mark.parametrize("body", [{}, {"cid": "abc"}, {"cid": 6251, "cas": "not-a-cas"}]) + def test_create_rejects_bad_input(self, client, body): + assert client.post("/compounds", json=body).status_code == 400 + + class TestCreateItemFromTemplate: """New resources start from their template, so they get its category and default status (items created from a bare category had no status).""" diff --git a/web/interface.py b/web/interface.py index 155f463..114865c 100644 --- a/web/interface.py +++ b/web/interface.py @@ -6,7 +6,9 @@ from flask_cors import cross_origin import automations.autofill as autofill +import eln_common.compound_import as compound_import import eln_common.config as config +from eln_common.fill_info import check_if_cas from eln_common.resourcemanage import Resource_Manager import web.label_creating as label_creating import web.print_handling as print_handling @@ -102,6 +104,78 @@ def compounds_list(): return jsonify({"status": "error", "error": str(e)}), 400 +def _pubchem_preview(pc: dict) -> dict: + """The PubChem fields the page shows before the user confirms an import.""" + return { + "cid": pc.get("cid"), + "name": pc.get("name") or "", + "cas": pc.get("cas") or "", + "formula": pc.get("molecularFormula") or "", + "molecular_weight": pc.get("molecularWeight"), + "smiles": pc.get("smiles") or "", + } + + +@interface_bp.route('/compounds/pubchem', methods=['GET']) +@cross_origin(origins="http://localhost:8000") +def compounds_pubchem(): + """Read-only PubChem lookup by ?cas= or ?name=. Saves nothing. For each match + (at most 5) says which ELN compound, if any, it already corresponds to.""" + cas = (request.args.get('cas') or '').strip() + name = (request.args.get('name') or '').strip() + if not cas and not name: + return jsonify({"status": "error", "error": "Give a CAS number or a name"}), 400 + try: + rmn = rm() + found = rmn.pubchem_lookup(cas=cas or None, name=None if cas else name)[:5] + if not found: + return jsonify({"status": "error", "error": "No match in PubChem"}), 404 + compounds = rmn.get_all_compounds() + candidates = [] + for pc in found: + match = compound_import.find_existing(pc, cas or None, compounds) + candidates.append({ + "pubchem": _pubchem_preview(pc), + "existing": compound_import.summary(match[0]) if match else None, + "reason": match[1] if match else "", + }) + return jsonify({"candidates": candidates}) + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 401 + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 400 + + +@interface_bp.route('/compounds', methods=['POST']) +@cross_origin(origins="http://localhost:8000") +def create_compound(): + """Creates a compound from PubChem: {cid, cas}. The details are fetched again here + by PubChem ID rather than trusted from the page. 409 with the existing compound + if the ELN already has it (even deleted).""" + data = request.get_json(force=True, silent=True) or {} + try: + cid = int(data.get('cid')) + except (TypeError, ValueError): + return jsonify({"status": "error", "error": "Missing or invalid PubChem ID (cid)"}), 400 + cas = (data.get('cas') or '').strip() or None + if cas and not check_if_cas(cas): + return jsonify({"status": "error", "error": f"'{cas}' is not a valid CAS number"}), 400 + try: + rmn = rm() + found = rmn.pubchem_lookup(cid=cid) + if not found: + return jsonify({"status": "error", "error": f"PubChem has no compound {cid}"}), 404 + created = compound_import.create_compound_safely(rmn, found[0], cas) + return jsonify(compound_import.summary(created)), 201 + except compound_import.CompoundClash as e: + return jsonify({"status": "error", "error": f"Already in the ELN ({e.reason})", + "existing": compound_import.summary(e.existing)}), 409 + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 401 + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + + @interface_bp.route('/storage_tree', methods=['GET']) @cross_origin(origins="http://localhost:8000") def storage_tree(): From 2b3eca0ff1dbfa23104bc6ee5713088da38d6cbe Mon Sep 17 00:00:00 2001 From: ravi Date: Thu, 1 Oct 2026 19:37:09 -0400 Subject: [PATCH 03/26] Remove autofill Autofill copied PubChem details (full name, SMILES, molecular weight, PubChem and hazards links) into every resource's extra fields, creating the fields when missing, every 10 minutes and after each new resource. Resources are now linked to a shared compound that holds those details, so the copies are redundant, they would put the old fields back on trimmed templates, and the re-saves flooded the changelog. Removed: automations/autofill.py, POST /api/autofill, the background autofill after /add_resource, the eln-autofill systemd units and the "autofill" timer task, and the legacy auto_upload_labels setting (labels are generated on the fly). install.sh now takes the old autofill timer off hosts that still have it. Kept for the new add-resource flow: automations/image_generator.py (the structure image will be drawn once when a resource is added) and the chemical_categories setting, reworded. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 2 +- README.md | 28 +++---- automations/autofill.py | 145 -------------------------------- automations/image_generator.py | 4 +- client/eln-autofill.service | 10 --- client/eln-autofill.timer | 10 --- client/eln_timer_client.py | 3 - client/install.sh | 15 +++- config-ex.yaml | 10 +-- eln_common/config.py | 5 -- secrets.example.yaml | 2 +- static/settings.html | 2 +- tests/test_live_eln.py | 20 ----- tests/test_routes_offline.py | 146 ++------------------------------- web/automation_api.py | 47 +---------- web/interface.py | 15 ---- 16 files changed, 35 insertions(+), 429 deletions(-) delete mode 100644 automations/autofill.py delete mode 100644 client/eln-autofill.service delete mode 100644 client/eln-autofill.timer diff --git a/Dockerfile b/Dockerfile index 5032d63..bd7a24f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,5 +37,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5000/ping', timeout=4).status==200 else 1)" # 1 worker, as on the old server (~160 MB each). --timeout matches the timer client's -# 600 s wait, so a long autofill or peroxide check isn't killed halfway. +# 600 s wait, so a long peroxide check isn't killed halfway. CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:5000", "--timeout", "600", "--access-logfile", "-", "app:app"] diff --git a/README.md b/README.md index 3b1b710..1e4e2c9 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Unified server for the DDOM Lab's custom ELN (eLabFTW) tooling. Consolidates the former `eln_web_backend`, `eln_packages_backend`, and `eln_packages_common` repositories into one Flask application: the web interface, the eLab API -wrapper, and the automations (autofill, label generation, RDKit images, +wrapper, and the automations (label generation, RDKit images, peroxide-former Slack reminders) all live here and run through the server. To install an eLabFTW instance, follow the eLabFTW installation instructions at https://doc.elabftw.net/docs/category/installation/ @@ -21,7 +21,6 @@ To install an eLabFTW instance, follow the eLabFTW installation instructions at - Generating custom labels - Performing 'batch actions' (above actions taken on multiple items at once) - ELN Automations - - Autofilling Resource information from CAS (hazards, molecular weight, RDKit image, SMILES, etc.) - Reminders to check peroxide formers with formatted list of peroxide formers @@ -31,7 +30,7 @@ To install an eLabFTW instance, follow the eLabFTW installation instructions at |---|---| | `app.py` | Flask entry point; registers blueprints (gunicorn target `app:app`) | | `eln_common/` | eLab API wrapper (`Resource_Manager`, `config`, `fill_info`) — formerly `eln_packages_common` | -| `automations/` | autofill, label generation, RDKit images, peroxide checks, Slack bot — formerly `eln_packages_backend` | +| `automations/` | label generation, RDKit images, peroxide checks, Slack bot — formerly `eln_packages_backend` | | `web/` | Flask blueprints: `interface` (the UI routes) and `automation_api` (`/api/...`) | | `static/` | Web UI pages and label templates | | `scripts/` | One-off maintenance scripts (inventory dumps, compound linking) | @@ -42,7 +41,7 @@ To install an eLabFTW instance, follow the eLabFTW installation instructions at Server settings live in `config.yaml` at the repo root (relative paths resolve from there): the instance URLs (`eln_url`, `eln_web_url` — required), -`printer_path`, `auto_upload_labels`, and the team-specific status/category +`printer_path`, and the team-specific status/category IDs (`status_open`, `status_empty`, `chemical_categories`, `label_date_categories`). The ID settings can also be set from dropdowns at `/settings_interface`, which reads your team's actual lists from the eLabFTW @@ -70,21 +69,15 @@ The Slack bot token is also server-side: set `slack_bot_token` in `secrets.yaml` ## Automation API -- `POST /api/autofill` — PubChem info fill, RDKit image (and, legacy, label upload). - Optional JSON body: `{"id": 123}` for one item, or - `{"start": 0, "end": null, "size": 5, "force": false, "info": true, "label": true, "image": true}` - (defaults shown, autofills the 5 most recently modified Resources with IDs between 0 and `null` (infinity)). Errors are reported to the Slack error channel, like the old `main.py`. - `POST /api/check_peroxides` — checks the inventory against the class A–D peroxide-former lists and sends Slack reminders. Returns match counts. -The `/add_resource` UI route also triggers an autofill of the new item in -the background, so info/images appear immediately after creation. - Label printing (`/print`) generates the PDF on the fly from the item's current -data. Attaching a `label.pdf` upload to each resource during autofill is a -legacy feature, disabled by default; set `auto_upload_labels: true` in -`config.yaml` to re-enable it (the `label` flag in the autofill body is -ignored unless it is set). +data. + +There is no autofill any more (removed 2026-10): chemical details live on the +compound each resource is linked to, instead of being copied from PubChem into +every resource's fields. ## Running the server @@ -141,9 +134,8 @@ The PTH tracker is a separate project (https://github.com/ddomlab/pth_analysis). The systemd timers (in `client/`) execute the automated actions. They can be automatically installed with the `install.sh` script in `client/` -This installs `eln-autofill.timer` (every 10 minutes — adjust `OnCalendar` to -taste, including excluding backup windows) and `eln-peroxide-check.timer` -(May 1 and Nov 1). The key is stored at `/etc/eln-client/api_key`; the server +This installs `eln-peroxide-check.timer` (May 1 and Nov 1), and removes the old +`eln-autofill.timer` from hosts that still have it. The key is stored at `/etc/eln-client/api_key`; the server URL is set via `ELN_SERVER_URL` in the `.service` files. Pass it at install time to skip editing them; with the Docker deploy on the same host that's the local port: diff --git a/automations/autofill.py b/automations/autofill.py deleted file mode 100644 index dbcb036..0000000 --- a/automations/autofill.py +++ /dev/null @@ -1,145 +0,0 @@ -import json - -import pubchempy as pcp - -import automations.image_generator as ig -import automations.slackbot as slackbot -from automations.labels.generate_label import LabelGenerator -import eln_common.config as config -import eln_common.fill_info as fill_info -from eln_common.resourcemanage import Resource_Manager - - -def create_and_upload_labels(rm: Resource_Manager, id: int): - for file in rm.get_uploaded_files(id): - if file.to_dict()["real_name"] == "label.pdf": - print(f"Label already exists for {id}") - return - # rm.delete_upload(id, file.to_dict()["id"]) - labelgen = LabelGenerator(rm) - labelgen.add_item(id) - labelgen.write_labels() - rm.upload_file(id, str(labelgen.path)) - - -def check_and_fill_image(rm: Resource_Manager, smiles: str, id: int, force=False): - ## upload RDKit image if it isn't there - files = rm.get_uploaded_files(id) - for file in files: - if file.to_dict()["real_name"] == "RDKitImage.png": - if not force: - print("Image already exists") - return - # regenerating: delete the old image first (needs write access to the item) - rm.delete_upload(id, file.to_dict()["id"]) - if smiles != "": - imagepath = ig.generate_image(smiles) - print(imagepath) - rm.upload_file(id, imagepath) - - -def item_tags(item: dict) -> set[str]: - """The item's tags as a set; eLabFTW sends them as one "a|b|c" string, or None.""" - return set((item.get("tags") or "").split("|")) - {""} - - -def process_item(rm: Resource_Manager, item: dict, force=False, info=True, label=True, image=True): - """Runs the autofill steps (label upload, info fill, RDKit image) on a single item dict.""" - if item['category'] is None: # skip items that don't have a category - return - type: int = int(item["category"]) - id = item["id"] - # Legacy: /print now generates labels on the fly, so the label.pdf upload is - # only made when ELN_AUTO_UPLOAD_LABELS is set (see eln_common/config.py). - if label and config.AUTO_UPLOAD_LABELS: - create_and_upload_labels(rm, id) - if type in config.setting("chemical_categories", [2, 3]): # only chemical-like categories get info/images, default to DDOMLab settings - if "metadata" not in item: - # newer eLabFTW omits metadata from GET /items list responses, - # so batch autofill has to fetch the full item - item = rm.get_item(id) - try: - metadata = json.loads(item["metadata"] or "{}") - except json.JSONDecodeError: - raise ValueError(f"Invalid JSON in metadata for item {id}") - - # skip items that were already autofilled or are tagged "Skip Autofill", unless force is true - tags = item_tags(item) - if (force or not tags & {"Autofilled", "Skip Autofill"}) and not rm.is_item_busy(id): - if info: - try: - fill_info.fill_in(rm, id) - rm.add_tag(id, "Autofilled") - # the fill may have just set the SMILES, so the image step needs the updated fields - metadata = json.loads(rm.get_item(id)["metadata"] or "{}") - except pcp.PubChemHTTPError as e: - if e.code < 500: - raise - # PubChem outage/hiccup: skip quietly. The item stays - # untagged, so the next scheduled run retries it. - print(f"PubChem unavailable ({e}), skipping info fill for item {id}") - except ValueError as e: - if "Null molecule" in str(e): - slackbot.send_message(f"Invalid SMILES provided in SMILES field for item {id}. See {config.item_web_url(id)}") - if "Not In PubChem" not in tags: - rm.add_tag(id, "Not In PubChem") - print(str(e)) - if "No compound" in str(e): - print(f"No compound found for item {id}") - slackbot.send_message(f"No compound found in PubChem for item {id}. See {config.item_web_url(id)}") - elif "Multiple compounds" in str(e): - print(f"Multiple compounds found for item {id}") - slackbot.send_message(f"Multiple compounds found in PubChem for item {id}. Manual addition of chemical properties required. See {config.item_web_url(id)}") - if image: - try: - smiles: str = metadata["extra_fields"]["SMILES"]["value"] - check_and_fill_image(rm, smiles, id, force=force) - except KeyError: - print(f"No SMILES found for item {id}") - except ValueError: - if "Invalid SMILES" not in tags: - rm.add_tag(id, "Invalid SMILES") - slackbot.send_message(f"Invalid SMILES found for item {id}, cannot generate image.") - else: - print(f"Item {id} has already been filled in") - - -def autofill_item(rm: Resource_Manager, id: int, force=False, info=True, label=True, image=True): - """Fetches a single item by id and runs the autofill steps on it.""" - process_item(rm, rm.get_item(id), force=force, info=info, label=label, image=image) - - -def autofill(rm: Resource_Manager, start=0, end=None, force=False, info=True, label=True, image=True, size=5): - """ - This method controls which functions are called and handles deciding which items to autofill. - The start and end parameters can be used to edit a certain range of items. - This is not necessary in typical use, when the method is run automatically on - the 5 most recently created items, but the functionality is there if - needed--for example: manually running autofill on a specific range of items. - - :param Resource_Manager rm: the Resource_Manager (and thus the API key) to act as - :param int start: lowest bound of item id to autofill, 0 (no bound) by default - :param int end: highest bound of item id to autofill, no end by default - :param bool force: whether to fill in items that have already been filled in--False by default - :param bool info: whether to fill in the information fields--True by default - :param bool label: whether to generate a label pdf--True by default - :param bool image: whether to generate an RDKit image--True by default - :param int size: number of recent entries to check. Default is 5 to prevent unnecessary traffic. Set to higher to check old entries. - - ## NOTE: - if you want to edit a range of old Resources, and you set `start` to a very low number, - you will likely have to set `size` to a higher number in order to pull enough entires to reach the start number - """ - items: list[dict] = rm.get_items(size=size, with_metadata=True) - failures: list[Exception] = [] - for item in items: - id = item["id"] - if (end is None and id >= start) or (end is not None and id in range(start, end)): - try: - process_item(rm, item, force=force, info=info, label=label, image=image) - except Exception as e: - e.add_note(f"while processing item {id} ({config.item_web_url(id)})") # type: ignore # add_note is 3.11+ - failures.append(e) - if failures: - # every item still got its turn; report what failed in one exception - raise ExceptionGroup(f"autofill failed on {len(failures)} item(s)", failures) diff --git a/automations/image_generator.py b/automations/image_generator.py index 20fa744..fcb42ff 100644 --- a/automations/image_generator.py +++ b/automations/image_generator.py @@ -7,8 +7,8 @@ def generate_image(smiles: str) -> str: mol = Chem.MolFromSmiles(smiles) - # the basename becomes the uploaded file's real_name, which autofill checks - # for, so it must stay "RDKitImage.png" + # the basename becomes the uploaded file's real_name; existing resources + # carry their structure image as "RDKitImage.png", so keep the name filename = str(Path(tempfile.gettempdir()) / "RDKitImage.png") Draw.MolToFile(mol, filename) return filename diff --git a/client/eln-autofill.service b/client/eln-autofill.service deleted file mode 100644 index e510317..0000000 --- a/client/eln-autofill.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=ELN autofill run (labels, PubChem info, RDKit images) -Wants=network-online.target -After=network-online.target - -[Service] -Type=oneshot -Environment=ELN_SERVER_URL=http://localhost:5000 -Environment=ELN_API_KEY_FILE=/etc/eln-client/api_key -ExecStart=/usr/bin/python3 /opt/eln-client/eln_timer_client.py autofill diff --git a/client/eln-autofill.timer b/client/eln-autofill.timer deleted file mode 100644 index f9e85ea..0000000 --- a/client/eln-autofill.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Run ELN autofill every 10 minutes - -[Timer] -OnCalendar=*:0/10 -# don't pile up a missed run backlog after downtime -Persistent=false - -[Install] -WantedBy=timers.target diff --git a/client/eln_timer_client.py b/client/eln_timer_client.py index 8d3a9b4..cc67410 100755 --- a/client/eln_timer_client.py +++ b/client/eln_timer_client.py @@ -6,7 +6,6 @@ to be run by the systemd timers in this directory (replacing the old cron jobs), but can be run by hand: - eln_timer_client.py autofill eln_timer_client.py check_peroxides Uses only the Python standard library, so it needs no environment to run. @@ -25,8 +24,6 @@ API_KEY_FILE = os.environ.get("ELN_API_KEY_FILE", "/etc/eln-client/api_key") TASKS = { - # autofill the 5 most recently created items - "autofill": {"size": 5, "info": True, "label": False, "image": True}, "check_peroxides": {}, } diff --git a/client/install.sh b/client/install.sh index d2ae7a3..487cda6 100755 --- a/client/install.sh +++ b/client/install.sh @@ -14,14 +14,13 @@ KEY_FILE=/etc/eln-client/api_key install -d /opt/eln-client install -m 755 "$SCRIPT_DIR/eln_timer_client.py" /opt/eln-client/ -install -m 644 "$SCRIPT_DIR"/eln-autofill.{service,timer} /etc/systemd/system/ install -m 644 "$SCRIPT_DIR"/eln-peroxide-check.{service,timer} /etc/systemd/system/ # optional: point the timers at a server other than the default in the .service files, # e.g. sudo ELN_SERVER_URL=http://127.0.0.1:5001 ./install.sh (the Docker deploy's local port) if [[ -n ${ELN_SERVER_URL:-} ]]; then sed -i "s|^Environment=ELN_SERVER_URL=.*|Environment=ELN_SERVER_URL=$ELN_SERVER_URL|" \ - /etc/systemd/system/eln-autofill.service /etc/systemd/system/eln-peroxide-check.service + /etc/systemd/system/eln-peroxide-check.service echo "Timers will call $ELN_SERVER_URL" fi @@ -42,12 +41,20 @@ if [[ ! -f $KEY_FILE ]]; then echo "Wrote $KEY_FILE" fi +# autofill was removed (compounds now hold the chemical details): take its old +# timer off hosts that still have it, so it stops calling a route that is gone +if [[ -f /etc/systemd/system/eln-autofill.timer ]]; then + systemctl disable --now eln-autofill.timer || true + rm -f /etc/systemd/system/eln-autofill.service /etc/systemd/system/eln-autofill.timer + echo "Removed the old eln-autofill timer" +fi + systemctl daemon-reload -systemctl enable --now eln-autofill.timer eln-peroxide-check.timer +systemctl enable --now eln-peroxide-check.timer echo echo "Installed. Check status with:" echo " systemctl list-timers 'eln-*'" echo "To point at a non-local server, edit ELN_SERVER_URL in" -echo " /etc/systemd/system/eln-autofill.service and eln-peroxide-check.service," +echo " /etc/systemd/system/eln-peroxide-check.service," echo "then: systemctl daemon-reload" diff --git a/config-ex.yaml b/config-ex.yaml index af4147c..ac6c3e5 100644 --- a/config-ex.yaml +++ b/config-ex.yaml @@ -14,12 +14,6 @@ eln_web_url: https://eln.ddomlab.org # Where generated label PDFs are written before printing/upload printer_path: /tmp/label.pdf -# Legacy feature: /print used to fetch a label.pdf stored on each resource, so -# autofill uploaded one to every new item. /print now generates labels on the -# fly, making the uploads redundant; set this to true to keep attaching -# label.pdf to resources anyway. -auto_upload_labels: false - ## Team-specific IDs ########################################################## # eLabFTW statuses and resource categories are per-team lists with arbitrary # IDs, so they must be mapped here for your instance. Two ways to find them: @@ -33,13 +27,13 @@ auto_upload_labels: false status_open: 4 # Status a resource gets when marked as empty status_empty: 5 -# Resource categories that get PubChem info + an RDKit structure image on autofill +# Resource categories that hold chemicals chemical_categories: [2, 3] # Resource categories whose printed labels show a "Received" date label_date_categories: [2, 3, 4] ## Slack integration ########################################################## -# Set to true to have the automations report to Slack (autofill errors, +# Set to true to have the automations report to Slack (errors, # peroxide reminders). Requires slack_bot_token in secrets.yaml. When false # (the default), messages are written to the server log instead. slack_enabled: false diff --git a/eln_common/config.py b/eln_common/config.py index a61d307..8c8e575 100644 --- a/eln_common/config.py +++ b/eln_common/config.py @@ -95,11 +95,6 @@ def render(v) -> str: else: lines.append(new_line) CONFIG_PATH.write_text("\n".join(lines) + "\n") -# Legacy feature: /print used to fetch a label.pdf stored on each resource, so -# autofill uploaded one to every new item. /print now generates labels on the -# fly, making the uploads redundant; set auto_upload_labels: true in -# config.yaml to keep attaching label.pdf to resources anyway. -AUTO_UPLOAD_LABELS = bool(_cfg.get("auto_upload_labels", False)) ################################################## # allows the connection diff --git a/secrets.example.yaml b/secrets.example.yaml index c760907..8dfb78e 100644 --- a/secrets.example.yaml +++ b/secrets.example.yaml @@ -5,5 +5,5 @@ # Generate keys at https://eln.ddomlab.org/ucp.php?tab=3 eln_api_key: "" -# Token for the lab Slack bot (autofill error reports, peroxide reminders) +# Token for the lab Slack bot (error reports, peroxide reminders) slack_bot_token: "" diff --git a/static/settings.html b/static/settings.html index 32cb2a7..935246c 100644 --- a/static/settings.html +++ b/static/settings.html @@ -81,7 +81,7 @@

ELN Server Settings

- Resource categories that get PubChem info and an RDKit structure image on autofill. + Resource categories that hold chemicals.
diff --git a/tests/test_live_eln.py b/tests/test_live_eln.py index c278db0..bcfc725 100644 --- a/tests/test_live_eln.py +++ b/tests/test_live_eln.py @@ -166,26 +166,6 @@ def test_print_generates_label_on_the_fly(self, client, auth_headers): assert resp.data.startswith(b"%PDF") -class TestAutomationApi: - def test_autofill_single_item(self, client, auth_headers, slack_messages): - """Default autofill on 393: idempotent when the item is already filled - (existing label is kept, 'Autofilled' tag short-circuits info/image).""" - resp = client.post("/api/autofill", json={"id": TEST_ITEM_ID}, headers=auth_headers) - assert resp.status_code == 200, resp.get_json() - assert resp.get_json() == {"status": "ok"} - assert slack_messages == [] # no error reports - - def test_autofill_bad_key_reports_error(self, client, slack_messages): - resp = client.post( - "/api/autofill", - json={"id": TEST_ITEM_ID}, - headers={"Authorization": "not-a-real-key"}, - ) - assert resp.status_code == 500 - assert resp.get_json()["status"] == "error" - assert len(slack_messages) == 1 # failure went to the (stubbed) error channel - - class TestSearch: TEMPLATE = { "title": "", diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index ccc22fb..cb60364 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -4,13 +4,10 @@ """ import json -import threading from types import SimpleNamespace -import pubchempy as pcp import pytest -import automations.autofill as autofill import eln_common.compound_import as compound_import import web.interface as interface import web.search_process as search_process @@ -72,10 +69,9 @@ def test_missing_key_raises(self, flask_app): class TestAutomationApiAuth: - def test_autofill_requires_key(self, client): - resp = client.post("/api/autofill", json={}) - assert resp.status_code == 401 - assert resp.get_json()["status"] == "error" + def test_autofill_route_is_gone(self, client): + # removed 2026-10; the old timer must not find a working endpoint + assert client.post("/api/autofill", json={}).status_code == 404 def test_check_peroxides_requires_key(self, client): resp = client.post("/api/check_peroxides") @@ -179,7 +175,6 @@ def test_config_yaml_values_are_loaded(self): import eln_common.config as config assert config.URL.startswith("http") - assert isinstance(config.AUTO_UPLOAD_LABELS, bool) # configured paths are absolute after repo-root resolution assert config.PRINTER_PATH.startswith("/") @@ -340,20 +335,10 @@ def test_null_metadata_leaves_date_blank(self): assert gen.records[0]["received_date"] == "" -class TestAddResourceAutofill: - def test_add_resource_triggers_background_autofill(self, client, monkeypatch): +class TestAddResource: + def test_add_resource_creates_item_from_template(self, client, monkeypatch): fake_rm = FakeRM(create_id=999) monkeypatch.setattr(interface, "rm", lambda: fake_rm) - - autofilled = threading.Event() - autofill_calls = [] - - def fake_autofill_item(rmn, item_id, **kwargs): - autofill_calls.append(item_id) - autofilled.set() - - monkeypatch.setattr(interface.autofill, "autofill_item", fake_autofill_item) - resp = client.post("/add_resource", json={ "title": "new thing", "body": "", @@ -367,9 +352,6 @@ def fake_autofill_item(rmn, item_id, **kwargs): "id": 999, } assert fake_rm.created[0][0] == 2 - # the autofill runs on a background thread right after creation - assert autofilled.wait(timeout=2), "autofill was not triggered by /add_resource" - assert autofill_calls == [999] class TestLookupLists: @@ -539,124 +521,6 @@ def test_posts_template_and_patches_the_rest(self): assert patched == [(615, {"title": "Sudan I", "body": "", "metadata": "{}"})] -class TestAutofillResilience: - """PubChem outages are soft skips, and one bad item can't spoil a batch.""" - - @staticmethod - def _item(id): - return { - "id": id, - "category": 2, - "tags": None, - "metadata": json.dumps({"extra_fields": {}}), - } - - def _rm(self, tags): - return SimpleNamespace( - is_item_busy=lambda id: False, - get_item=self._item, - add_tag=lambda id, tag: tags.append(tag), - get_items=lambda size, with_metadata: [self._item(i) for i in (1, 2, 3)], - ) - - @pytest.fixture(autouse=True) - def default_settings(self, monkeypatch): - # pin chemical_categories etc. to their defaults regardless of config.yaml - monkeypatch.setattr(autofill.config, "setting", lambda key, default=None: default) - - def test_pubchem_5xx_soft_skips_info_fill(self, monkeypatch): - def outage(rm, id): - raise pcp.PubChemHTTPError(502, "Bad Gateway", []) - monkeypatch.setattr(autofill.fill_info, "fill_in", outage) - tags = [] - # must not raise... - autofill.process_item(self._rm(tags), self._item(1), label=False, image=False) - # ...and must leave the item untagged so the next run retries it - assert "Autofilled" not in tags - - def test_pubchem_4xx_still_raises(self, monkeypatch): - def bad_request(rm, id): - raise pcp.BadRequestError(400, "Bad Request", []) - monkeypatch.setattr(autofill.fill_info, "fill_in", bad_request) - with pytest.raises(pcp.PubChemHTTPError): - autofill.process_item(self._rm([]), self._item(1), label=False, image=False) - - def test_batch_continues_past_failing_item(self, monkeypatch): - processed = [] - - def fake_process(rm, item, **kwargs): - processed.append(item["id"]) - if item["id"] == 2: - raise RuntimeError("boom") - monkeypatch.setattr(autofill, "process_item", fake_process) - with pytest.raises(ExceptionGroup) as excinfo: - autofill.autofill(self._rm([])) - assert processed == [1, 2, 3] - assert len(excinfo.value.exceptions) == 1 - assert "item 2" in "".join(excinfo.value.exceptions[0].__notes__) - - -class TestAutofillSkipRules: - """Each item is autofilled once: the scheduled run must not keep re-filling - items it already tagged, which rewrote them every 10 minutes.""" - - @staticmethod - def _item(tags): - return {"id": 1, "category": 2, "tags": tags, - "metadata": json.dumps({"extra_fields": {}})} - - @pytest.fixture(autouse=True) - def default_settings(self, monkeypatch): - monkeypatch.setattr(autofill.config, "setting", lambda key, default=None: default) - - @pytest.fixture() - def filled(self, monkeypatch): - calls = [] - monkeypatch.setattr(autofill.fill_info, "fill_in", lambda rm, id: calls.append(id)) - return calls - - def _run(self, tags, force=False, image=False): - added = [] - # after the fill, the item has the SMILES that PubChem supplied - filled_item = {"metadata": json.dumps({"extra_fields": {"SMILES": {"value": "CCO"}}})} - rm = SimpleNamespace(is_item_busy=lambda id: False, - add_tag=lambda id, tag: added.append(tag), - get_item=lambda id: filled_item) - autofill.process_item(rm, self._item(tags), force=force, label=False, image=image) - return added - - def test_untagged_item_is_filled_and_tagged(self, filled): - assert self._run(None) == ["Autofilled"] - assert filled == [1] - - @pytest.mark.parametrize("tags", ["Autofilled", "Skip Autofill", "UV-Vis|Autofilled"]) - def test_tagged_item_is_left_alone(self, filled, tags): - assert self._run(tags) == [] - assert filled == [] - - def test_other_tags_do_not_block_fill(self, filled): - self._run("UV-Vis|PRF") - assert filled == [1] - - def test_force_refills_autofilled_item(self, filled): - self._run("Autofilled", force=True) - assert filled == [1] - - def test_image_uses_smiles_from_the_same_fill(self, filled, monkeypatch): - # the item starts without a SMILES; since it is autofilled only once, - # the image has to come from the SMILES that this pass filled in - images = [] - monkeypatch.setattr(autofill, "check_and_fill_image", - lambda rm, smiles, id, force=False: images.append(smiles)) - self._run(None, image=True) - assert images == ["CCO"] - - def test_item_tags_parses_elabftw_format(self): - assert autofill.item_tags({"tags": "a|b"}) == {"a", "b"} - assert autofill.item_tags({"tags": None}) == set() - assert autofill.item_tags({}) == set() - - class TestCreateLabel: def test_create_label_returns_pdf(self, client): resp = client.post( diff --git a/web/automation_api.py b/web/automation_api.py index f874208..307a4d6 100644 --- a/web/automation_api.py +++ b/web/automation_api.py @@ -1,5 +1,5 @@ """ -API endpoints for the backend automations (autofill, peroxide checks). +API endpoints for the backend automations (peroxide checks). Authentication: the caller passes their own eLabFTW API key in the Authorization header (or the apiKey cookie from the web UI); all ELN operations run as that @@ -9,9 +9,8 @@ import traceback -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify -import automations.autofill import automations.peroxides.check_peroxides as check_peroxides import automations.slackbot as slackbot from web.auth import rm @@ -19,48 +18,6 @@ automation_bp = Blueprint("automation", __name__, url_prefix="/api") -@automation_bp.route('/autofill', methods=['POST']) -def autofill(): - """ - Runs the autofill pass (label upload, PubChem info fill, RDKit image). - JSON body (all optional): id, start, end, force, info, label, image, size. - If "id" is given, only that item is processed; otherwise the most recent - `size` items with id >= start are, matching the old main.py cron behavior. - """ - try: - rmn = rm() - except ValueError as e: - return jsonify({"status": "error", "error": str(e)}), 401 - data = request.get_json(silent=True) or {} - kwargs = dict( - force=bool(data.get("force", False)), - info=bool(data.get("info", True)), - label=bool(data.get("label", True)), - image=bool(data.get("image", True)), - ) - try: - if "id" in data: - automations.autofill.autofill_item(rmn, int(data["id"]), **kwargs) - else: - automations.autofill.autofill( - rmn, - start=int(data.get("start", 0)), - end=int(data["end"]) if data.get("end") is not None else None, - size=int(data.get("size", 5)), - **kwargs, - ) - except Exception as e: - # report failures to the bot channel like the old cron job did; - # repr keeps the exception type (a bare KeyError str()s to just the key) - tb = "".join(traceback.format_exception(e)).strip() - slackbot.send_message( - f"Error in autofill: {e!r}\n```{tb[-2500:]}```", - channel=slackbot.ERROR_CHANNEL, - ) - return jsonify({"status": "error", "error": repr(e)}), 500 - return jsonify({"status": "ok"}) - - @automation_bp.route('/check_peroxides', methods=['POST']) def peroxide_check(): """ diff --git a/web/interface.py b/web/interface.py index 114865c..c42a8ee 100644 --- a/web/interface.py +++ b/web/interface.py @@ -1,15 +1,12 @@ import json -import threading from datetime import datetime from flask import Blueprint, current_app, jsonify, request, send_from_directory from flask_cors import cross_origin -import automations.autofill as autofill import eln_common.compound_import as compound_import import eln_common.config as config from eln_common.fill_info import check_if_cas -from eln_common.resourcemanage import Resource_Manager import web.label_creating as label_creating import web.print_handling as print_handling import web.search_process as search_process @@ -469,16 +466,6 @@ def add_option(): return jsonify({"status": "error", "error": str(e)}), 400 -def _autofill_in_background(rmn: Resource_Manager, item_id: int): - """Runs the autofill steps on a newly created item without blocking the response.""" - def target(): - try: - autofill.autofill_item(rmn, item_id) - except Exception as e: - print(f"Error autofilling new item {item_id}:", e) - threading.Thread(target=target, daemon=True).start() - - @interface_bp.route('/add_resource', methods=['POST']) @cross_origin(origins="http://localhost:8000") def add_resource(): @@ -491,8 +478,6 @@ def add_resource(): try: rmn = rm() item_id = rmn.create_item(data['category'], resource) - # kick off autofill (label, info, image) for the new resource immediately - _autofill_in_background(rmn, item_id) return jsonify({"status": "ok", "received": data, "id": item_id}) except Exception as e: print("Error Initializing Resource Manager:", e) From a187838d4d68c1ba1c19a7ef0554b383b094a7c7 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 13:37:33 -0400 Subject: [PATCH 04/26] Read compounds and their hazard pictograms straight from PubChem eLabFTW's PubChem lookup by PubChem ID leaves out the hazard flags, so compounds added through the app had no hazard pictograms. The app now asks PubChem's REST API directly for the details, CAS and GHS pictograms, and eLabFTW only stores the compound. Each pictogram (GHS01-09) turns on its eLabFTW hazard flag; POST /compounds reports the pictograms found, or null when PubChem has no GHS classification (check the supplier's SDS). Co-Authored-By: Claude Opus 5.5 --- eln_common/compound_import.py | 64 +++++++------ eln_common/pubchem.py | 122 +++++++++++++++++++++++++ eln_common/resourcemanage.py | 24 ----- tests/test_routes_offline.py | 165 ++++++++++++++++++++++++++++++---- web/interface.py | 38 ++++---- 5 files changed, 327 insertions(+), 86 deletions(-) create mode 100644 eln_common/pubchem.py diff --git a/eln_common/compound_import.py b/eln_common/compound_import.py index 7eeec65..c8bf74d 100644 --- a/eln_common/compound_import.py +++ b/eln_common/compound_import.py @@ -6,15 +6,33 @@ compound and restores it if deleted, then returns its id. In the lab's ELN this replaced compound #80 (Hexitol) with Mannitol and brought back deleted #8. -So: look the compound up in PubChem first (read only), refuse if it would clash -with a compound we already have, create it with the CAS the user gave, and check -that eLabFTW really made a new compound. +So: look the compound up in PubChem first (read only, see eln_common.pubchem), +refuse if it would clash with a compound we already have, create it with the CAS +the user gave and its hazard flags, and check that eLabFTW really made a new compound. """ from typing import Any from eln_common.resourcemanage import Resource_Manager +# eLabFTW stores hazards as one yes/no field per GHS pictogram +GHS_FLAGS = { + "GHS01": "is_explosive", + "GHS02": "is_flammable", + "GHS03": "is_oxidising", + "GHS04": "is_gas_under_pressure", + "GHS05": "is_corrosive", + "GHS06": "is_toxic", + "GHS07": "is_hazardous2health", + "GHS08": "is_serious_health_hazard", + "GHS09": "is_hazardous2env", +} + +# the PubChem fields eLabFTW accepts when creating a compound (molecular_weight is +# not accepted on create, so it is set right after) +CREATE_FIELDS = ["name", "cas_number", "pubchem_cid", "inchi", "inchi_key", "smiles", + "iupac_name", "molecular_formula"] + class CompoundClash(Exception): """The compound already exists in the ELN (possibly deleted).""" @@ -44,9 +62,9 @@ def find_existing(pubchem: dict[str, Any], cas: str | None, actually saved (the user's, else PubChem's; see build_compound_body). """ keys = [ - ("pubchem_cid", pubchem.get("cid"), "same PubChem ID"), - ("inchi_key", pubchem.get("inChIKey"), "same structure (InChIKey)"), - ("cas_number", cas or pubchem.get("cas"), "same CAS number"), + ("pubchem_cid", pubchem.get("pubchem_cid"), "same PubChem ID"), + ("inchi_key", pubchem.get("inchi_key"), "same structure (InChIKey)"), + ("cas_number", cas or pubchem.get("cas_number"), "same CAS number"), ] for field, value, reason in keys: if not value: @@ -58,26 +76,15 @@ def find_existing(pubchem: dict[str, Any], cas: str | None, def build_compound_body(pubchem: dict[str, Any], cas: str | None) -> dict[str, Any]: - """The fields sent to eLabFTW, from a PubChem result. The user's CAS wins over PubChem's.""" - body = { - "name": pubchem.get("name"), - "cas_number": cas or pubchem.get("cas"), - "pubchem_cid": pubchem.get("cid"), - "inchi": pubchem.get("inChI"), - "inchi_key": pubchem.get("inChIKey"), - "smiles": pubchem.get("smiles"), - "iupac_name": pubchem.get("iupacName"), - "molecular_formula": pubchem.get("molecularFormula"), - "is_corrosive": pubchem.get("isCorrosive"), - "is_explosive": pubchem.get("isExplosive"), - "is_flammable": pubchem.get("isFlammable"), - "is_gas_under_pressure": pubchem.get("isGasUnderPressure"), - "is_hazardous2env": pubchem.get("isHazardous2env"), - "is_hazardous2health": pubchem.get("isHazardous2health"), - "is_serious_health_hazard": pubchem.get("isSeriousHealthHazard"), - "is_oxidising": pubchem.get("isOxidising"), - "is_toxic": pubchem.get("isToxic"), - } + """ + The fields sent to eLabFTW, from a PubChem result (eln_common.pubchem.fetch). + The user's CAS wins over PubChem's; each GHS pictogram turns its hazard flag on. + """ + body = {field: pubchem.get(field) for field in CREATE_FIELDS} + body["cas_number"] = cas or pubchem.get("cas_number") + for pictogram in pubchem.get("pictograms") or []: + if pictogram in GHS_FLAGS: + body[GHS_FLAGS[pictogram]] = 1 # leave out empty fields: an empty unique field can also trigger the upsert return {k: v for k, v in body.items() if v not in (None, "")} @@ -103,7 +110,6 @@ def create_compound_safely(rm: Resource_Manager, pubchem: dict[str, Any], raise RuntimeError( f"eLabFTW saved this into existing compound #{new_id} instead of creating a new one; " "check that compound by hand") - if pubchem.get("molecularWeight"): - # not accepted on create, so it is set right after - rm.patch_compound(new_id, {"molecular_weight": pubchem["molecularWeight"]}) + if pubchem.get("molecular_weight"): + rm.patch_compound(new_id, {"molecular_weight": pubchem["molecular_weight"]}) return rm.get_compound(new_id) diff --git a/eln_common/pubchem.py b/eln_common/pubchem.py new file mode 100644 index 0000000..5c6aa40 --- /dev/null +++ b/eln_common/pubchem.py @@ -0,0 +1,122 @@ +""" +Reads compound details and GHS hazard pictograms straight from PubChem. + +The app used to ask eLabFTW to look compounds up in PubChem, but eLabFTW's lookup +by PubChem ID leaves out the hazard flags, so compounds added through the app had no +hazard pictograms. Everything now comes from PubChem's own REST API, and eLabFTW is +only where the compound is saved (see compound_import). + +Results use eLabFTW's compound field names (cas_number, inchi_key...), so they can +be compared with existing compounds and sent to eLabFTW as they are. +""" + +import re +from typing import Any, Iterator + +import requests + +from eln_common.fill_info import check_if_cas + +PUG = "https://pubchem.ncbi.nlm.nih.gov/rest/pug" +PUG_VIEW = "https://pubchem.ncbi.nlm.nih.gov/rest/pug_view" +PROPERTIES = "Title,IUPACName,MolecularFormula,MolecularWeight,SMILES,InChI,InChIKey" +TIMEOUT = 30 + + +def _json(response: requests.Response) -> dict[str, Any] | None: + """The answer as JSON, or None when PubChem has no such record (404 NotFound).""" + if response.status_code == 404: + return None + response.raise_for_status() + return response.json() + + +def _from_properties(props: dict[str, Any]) -> dict[str, Any]: + """One PubChem property row, renamed to eLabFTW's compound fields.""" + weight = props.get("MolecularWeight") + return { + "pubchem_cid": props["CID"], + "name": props.get("Title"), + "iupac_name": props.get("IUPACName"), + "molecular_formula": props.get("MolecularFormula"), + "molecular_weight": float(weight) if weight else None, + "smiles": props.get("SMILES"), + "inchi": props.get("InChI"), + "inchi_key": props.get("InChIKey"), + } + + +def search(cas: str | None = None, name: str | None = None, limit: int = 5) -> list[dict[str, Any]]: + """ + Finds compounds in PubChem by CAS number or by name. Saves nothing. + :return: Up to `limit` matches (see _from_properties) with their cas_number; + an empty list when PubChem has no match. + """ + query = cas or name + if not query: + raise ValueError("Give a CAS number or a name") + # sent as form data rather than in the URL, so names with "/" or "#" work + data = _json(requests.post(f"{PUG}/compound/name/property/{PROPERTIES}/JSON", + data={"name": query}, timeout=TIMEOUT)) + if data is None: + return [] + found = [_from_properties(p) for p in data["PropertyTable"]["Properties"][:limit]] + for compound in found: + compound["cas_number"] = cas or cas_number(compound["pubchem_cid"]) + return found + + +def fetch(cid: int) -> dict[str, Any] | None: + """ + Everything the app saves about one PubChem compound: details, CAS and hazard pictograms. + :return: As search(), plus "pictograms" (see ghs_pictograms); None if PubChem has no such CID. + """ + data = _json(requests.get(f"{PUG}/compound/cid/{cid}/property/{PROPERTIES}/JSON", timeout=TIMEOUT)) + if data is None: + return None + compound = _from_properties(data["PropertyTable"]["Properties"][0]) + compound["cas_number"] = cas_number(cid) + compound["pictograms"] = ghs_pictograms(cid) + return compound + + +def cas_number(cid: int) -> str | None: + """The first CAS number among the compound's PubChem synonyms (PubChem lists the main one first).""" + data = _json(requests.get(f"{PUG}/compound/cid/{cid}/synonyms/JSON", timeout=TIMEOUT)) + if data is None: + return None + synonyms = data["InformationList"]["Information"][0].get("Synonym", []) + return next((s for s in synonyms if check_if_cas(s)), None) + + +def ghs_pictograms(cid: int) -> list[str] | None: + """ + The GHS hazard pictograms PubChem lists for a compound, e.g. ["GHS02", "GHS07"]. + PubChem gives one classification per source (ECHA, suppliers...); a pictogram is + included if any source gives it, as the more cautious choice. + :return: The sorted pictogram codes; [] when PubChem classifies the compound + with no pictograms; None when PubChem has no GHS classification at all + (hazards unknown: check the supplier's SDS). + """ + data = _json(requests.get(f"{PUG_VIEW}/data/compound/{cid}/JSON", + params={"heading": "GHS Classification"}, timeout=TIMEOUT)) + if data is None: + return None + found = set() + for info in _information(data["Record"]): + if info.get("Name") != "Pictogram(s)": + continue + for text in info.get("Value", {}).get("StringWithMarkup", []): + for markup in text.get("Markup", []): + # the icon's URL names the pictogram: .../images/ghs/GHS02.svg + match = re.search(r"(GHS\d\d)\.svg$", markup.get("URL", "")) + if match: + found.add(match.group(1)) + return sorted(found) + + +def _information(section: dict[str, Any]) -> Iterator[dict[str, Any]]: + """Every Information entry in a PubChem record, at any depth of its sections.""" + yield from section.get("Information", []) + for child in section.get("Section", []): + yield from _information(child) diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index dcd6258..f5629e3 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -147,30 +147,6 @@ def get_all_compounds(self) -> list[dict[str, Any]]: response.raise_for_status() return response.json() - def pubchem_lookup(self, cas: str | None = None, name: str | None = None, - cid: int | None = None) -> list[dict[str, Any]]: - """ - Looks a compound up in PubChem through eLabFTW, without saving anything. - :return: Matching PubChem compounds as dictionaries with {cid, cas, name, - inChI, inChIKey, smiles, iupacName, molecularFormula, molecularWeight, - isFlammable, ...}; an empty list when PubChem has no match. - """ - if cid is not None: - query = {"search_pubchem_cid": cid} - elif cas: - query = {"search_pubchem_cas": cas} - elif name: - query = {"search_pubchem_name": name} - else: - raise ValueError("Give a CAS number, a name or a PubChem CID") - response = requests.get(config.URL + "/compounds", headers=self.header, params=query) - # eLabFTW answers 500 with PubChem's "PUGREST.NotFound" when there is no match - if response.status_code == 500 and "NotFound" in response.text: - return [] - response.raise_for_status() - found = response.json() - return found if isinstance(found, list) else [found] - def create_compound(self, body: dict[str, Any]) -> int: """ Creates a compound from the given fields. Prefer compound_import.create_compound_safely(): diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index cb60364..85f4fbb 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -9,6 +9,7 @@ import pytest import eln_common.compound_import as compound_import +import eln_common.pubchem as pubchem import web.interface as interface import web.search_process as search_process from automations.labels.generate_label import LabelGenerator @@ -394,18 +395,14 @@ def test_lists_need_an_api_key(self, client, path): class FakeCompoundRM: """Stand-in for the compound calls of Resource_Manager.""" - def __init__(self, compounds, pubchem=None, create_returns=200): + def __init__(self, compounds, create_returns=200): self.compounds = compounds - self.pubchem = pubchem or [] self.create_returns = create_returns self.created, self.patched = [], [] def get_all_compounds(self): return self.compounds - def pubchem_lookup(self, cas=None, name=None, cid=None): - return self.pubchem - def create_compound(self, body): self.created.append(body) return self.create_returns @@ -419,8 +416,13 @@ def get_compound(self, id): "molecular_formula": body.get("molecular_formula"), "state": 1} -MANNITOL = {"cid": 6251, "cas": "87-78-5", "name": "Mannitol", "inChIKey": "FBPFZTCFMRRESA-KVTDHHQDSA-N", - "molecularFormula": "C6H14O6", "molecularWeight": 182.17, "smiles": "C(...)O", "isFlammable": False} +# what eln_common.pubchem.fetch returns +MANNITOL = {"pubchem_cid": 6251, "cas_number": "87-78-5", "name": "Mannitol", + "inchi_key": "FBPFZTCFMRRESA-KVTDHHQDSA-N", "molecular_formula": "C6H14O6", + "molecular_weight": 182.17, "smiles": "C(...)O", "iupac_name": None, "pictograms": []} +THF = {"pubchem_cid": 8028, "cas_number": "109-99-9", "name": "Tetrahydrofuran", + "inchi_key": "WYURNTSHIVDZCO-UHFFFAOYSA-N", "molecular_formula": "C4H8O", + "molecular_weight": 72.11, "smiles": "C1CCOC1", "pictograms": ["GHS02", "GHS07", "GHS08"]} HEXITOL_80 = {"id": 80, "name": "Hexitol", "cas_number": "87-78-5", "pubchem_cid": 453, "inchi_key": "FBPFZTCFMRRESA-UHFFFAOYSA-N", "state": 1} @@ -436,8 +438,23 @@ def test_users_cas_is_kept_and_pubchems_cas_does_not_clash(self): assert created["id"] == 126 and created["cas_number"] == "69-65-8" assert rm.created[0]["pubchem_cid"] == 6251 assert rm.patched == [(126, {"molecular_weight": 182.17})] - # empty/None fields are left out (False flags are kept) - assert "iupac_name" not in rm.created[0] and rm.created[0]["is_flammable"] is False + # empty fields are left out, and no pictograms means no hazard flags + assert "iupac_name" not in rm.created[0] + assert not any(k.startswith("is_") for k in rm.created[0]) + + def test_pictograms_turn_on_hazard_flags(self): + rm = FakeCompoundRM([], create_returns=127) + compound_import.create_compound_safely(rm, THF, None) + flags = {k: v for k, v in rm.created[0].items() if k.startswith("is_")} + assert flags == {"is_flammable": 1, "is_hazardous2health": 1, "is_serious_health_hazard": 1} + assert rm.created[0]["cas_number"] == "109-99-9" + assert "pictograms" not in rm.created[0] + + def test_unknown_hazards_set_no_flags(self): + # pictograms None: PubChem has no GHS classification + rm = FakeCompoundRM([], create_returns=128) + compound_import.create_compound_safely(rm, {**THF, "pictograms": None}, None) + assert not any(k.startswith("is_") for k in rm.created[0]) def test_same_pubchem_id_is_refused(self): existing = {**HEXITOL_80, "pubchem_cid": 6251} @@ -450,7 +467,8 @@ def test_same_pubchem_id_is_refused(self): def test_deleted_compound_with_same_cas_is_refused(self): # like #8 Phenylboronic acid: deleted, but it still holds its CAS deleted = {"id": 8, "name": "Phenylboronic acid", "cas_number": "98-80-6", "state": 3} - pc = {"cid": 66827, "cas": "98-80-6", "name": "Phenylboronic Acid", "inChIKey": "HXIT-X"} + pc = {"pubchem_cid": 66827, "cas_number": "98-80-6", "name": "Phenylboronic Acid", + "inchi_key": "HXIT-X"} rm = FakeCompoundRM([deleted]) with pytest.raises(compound_import.CompoundClash) as e: compound_import.create_compound_safely(rm, pc, "98-80-6") @@ -464,40 +482,153 @@ def test_existing_id_returned_by_elabftw_stops(self): assert rm.patched == [] +class FakePubChem: + """Stand-in for requests in eln_common.pubchem: answers by URL, 404 for anything unknown.""" + + def __init__(self, answers): + self.answers = answers + self.calls = [] + + def _answer(self, url, **kwargs): + self.calls.append((url, kwargs)) + for part, body in self.answers.items(): + if part in url: + return SimpleNamespace(status_code=200, json=lambda body=body: body, + raise_for_status=lambda: None) + return SimpleNamespace(status_code=404) + + get = post = _answer + + +def ghs_answer(*sources): + """A PubChem GHS Classification record; each source is a list of pictogram codes.""" + info = [] + for codes in sources: + info.append({"Name": "Pictogram(s)", "Value": {"StringWithMarkup": [{"String": "", "Markup": [ + {"URL": f"https://pubchem.ncbi.nlm.nih.gov/images/ghs/{c}.svg", "Type": "Icon"} + for c in codes]}]}}) + info.append({"Name": "Signal", "Value": {"StringWithMarkup": [{"String": "Danger"}]}}) + section = {"TOCHeading": "GHS Classification", "Information": info} + return {"Record": {"Section": [{"TOCHeading": "Safety and Hazards", "Section": [ + {"TOCHeading": "Hazards Identification", "Section": [section]}]}]}} + + +THF_PROPERTIES = {"PropertyTable": {"Properties": [{ + "CID": 8028, "Title": "Tetrahydrofuran", "IUPACName": "oxolane", "MolecularFormula": "C4H8O", + "MolecularWeight": "72.11", "SMILES": "C1CCOC1", "InChI": "InChI=1S/C4H8O/c1-2-4-5-3-1/h1-4H2", + "InChIKey": "WYURNTSHIVDZCO-UHFFFAOYSA-N"}]}} +THF_SYNONYMS = {"InformationList": {"Information": [ + {"CID": 8028, "Synonym": ["tetrahydrofuran", "THF", "109-99-9", "oxolane", "1-23-4567"]}]}} + + +class TestPubChem: + """eln_common.pubchem with PubChem's answers faked (shapes copied from real ones).""" + + def test_pictograms_from_all_sources_are_combined(self, monkeypatch): + fake = FakePubChem({"/pug_view/data/compound/8028/": ghs_answer(["GHS02", "GHS07", "GHS08"], ["GHS07"])}) + monkeypatch.setattr(pubchem, "requests", fake) + assert pubchem.ghs_pictograms(8028) == ["GHS02", "GHS07", "GHS08"] + assert fake.calls[0][1]["params"] == {"heading": "GHS Classification"} + + def test_classified_without_pictograms_is_empty_list(self, monkeypatch): + # like glucose: PubChem has a GHS section but no source gives a pictogram + monkeypatch.setattr(pubchem, "requests", FakePubChem({"/pug_view/": ghs_answer()})) + assert pubchem.ghs_pictograms(5793) == [] + + def test_no_classification_is_none(self, monkeypatch): + monkeypatch.setattr(pubchem, "requests", FakePubChem({})) + assert pubchem.ghs_pictograms(24857) is None + + def test_fetch_uses_elabftw_field_names(self, monkeypatch): + monkeypatch.setattr(pubchem, "requests", FakePubChem({ + "/cid/8028/property/": THF_PROPERTIES, + "/cid/8028/synonyms/": THF_SYNONYMS, + "/pug_view/data/compound/8028/": ghs_answer(["GHS02", "GHS07", "GHS08"]), + })) + assert pubchem.fetch(8028) == { + "pubchem_cid": 8028, "name": "Tetrahydrofuran", "iupac_name": "oxolane", + "molecular_formula": "C4H8O", "molecular_weight": 72.11, "smiles": "C1CCOC1", + "inchi": "InChI=1S/C4H8O/c1-2-4-5-3-1/h1-4H2", "inchi_key": "WYURNTSHIVDZCO-UHFFFAOYSA-N", + "cas_number": "109-99-9", "pictograms": ["GHS02", "GHS07", "GHS08"], + } + + def test_fetch_unknown_cid_is_none(self, monkeypatch): + monkeypatch.setattr(pubchem, "requests", FakePubChem({})) + assert pubchem.fetch(999999999) is None + + def test_search_by_cas_keeps_the_cas_and_sends_it_as_form_data(self, monkeypatch): + fake = FakePubChem({"/compound/name/property/": THF_PROPERTIES}) + monkeypatch.setattr(pubchem, "requests", fake) + found = pubchem.search(cas="109-99-9") + assert [(c["pubchem_cid"], c["cas_number"]) for c in found] == [(8028, "109-99-9")] + assert fake.calls[0][1]["data"] == {"name": "109-99-9"} + assert len(fake.calls) == 1 # no synonyms call needed + + def test_search_by_name_finds_the_cas_in_synonyms(self, monkeypatch): + monkeypatch.setattr(pubchem, "requests", FakePubChem({ + "/compound/name/property/": THF_PROPERTIES, "/cid/8028/synonyms/": THF_SYNONYMS})) + assert pubchem.search(name="THF")[0]["cas_number"] == "109-99-9" + + def test_search_without_match_is_empty(self, monkeypatch): + monkeypatch.setattr(pubchem, "requests", FakePubChem({})) + assert pubchem.search(cas="1599466-85-9") == [] + + class TestCompoundRoutes: def test_preview_marks_existing_compound(self, client, monkeypatch): - acetone = {"cid": 180, "cas": "67-64-1", "name": "Acetone", "molecularFormula": "C3H6O"} + acetone = {"pubchem_cid": 180, "cas_number": "67-64-1", "name": "Acetone", "molecular_formula": "C3H6O"} rm = FakeCompoundRM([{"id": 72, "name": "Acetone", "cas_number": "67-64-1", "pubchem_cid": 180, - "molecular_formula": "C3H6O", "state": 1}], pubchem=[acetone]) + "molecular_formula": "C3H6O", "state": 1}]) monkeypatch.setattr(interface, "rm", lambda: rm) + monkeypatch.setattr(pubchem, "search", lambda cas=None, name=None: [acetone]) resp = client.get("/compounds/pubchem?cas=67-64-1") assert resp.status_code == 200 candidate = resp.get_json()["candidates"][0] - assert candidate["pubchem"]["name"] == "Acetone" + assert candidate["pubchem"]["name"] == "Acetone" and candidate["pubchem"]["cid"] == 180 assert candidate["existing"]["id"] == 72 and candidate["reason"] == "same PubChem ID" def test_preview_not_in_pubchem_is_404(self, client, monkeypatch): - monkeypatch.setattr(interface, "rm", lambda: FakeCompoundRM([], pubchem=[])) + monkeypatch.setattr(interface, "rm", lambda: FakeCompoundRM([])) + monkeypatch.setattr(pubchem, "search", lambda cas=None, name=None: []) assert client.get("/compounds/pubchem?cas=1599466-85-9").status_code == 404 def test_preview_needs_cas_or_name(self, client): assert client.get("/compounds/pubchem").status_code == 400 def test_create_returns_201_with_new_compound(self, client, monkeypatch): - rm = FakeCompoundRM([HEXITOL_80], pubchem=[MANNITOL], create_returns=126) + rm = FakeCompoundRM([HEXITOL_80], create_returns=126) monkeypatch.setattr(interface, "rm", lambda: rm) + monkeypatch.setattr(pubchem, "fetch", lambda cid: MANNITOL) resp = client.post("/compounds", json={"cid": 6251, "cas": "69-65-8"}) assert resp.status_code == 201 assert resp.get_json() == {"id": 126, "name": "Mannitol", "cas": "69-65-8", - "formula": "C6H14O6", "deleted": False} + "formula": "C6H14O6", "deleted": False, "pictograms": []} + + def test_create_reports_pictograms(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeCompoundRM([], create_returns=127)) + monkeypatch.setattr(pubchem, "fetch", lambda cid: THF) + resp = client.post("/compounds", json={"cid": 8028}) + assert resp.status_code == 201 + assert resp.get_json()["pictograms"] == ["GHS02", "GHS07", "GHS08"] + + def test_create_unknown_hazards_is_null(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeCompoundRM([], create_returns=128)) + monkeypatch.setattr(pubchem, "fetch", lambda cid: {**THF, "pictograms": None}) + assert client.post("/compounds", json={"cid": 8028}).get_json()["pictograms"] is None def test_create_clash_is_409_with_existing(self, client, monkeypatch): - rm = FakeCompoundRM([{**HEXITOL_80, "pubchem_cid": 6251}], pubchem=[MANNITOL]) + rm = FakeCompoundRM([{**HEXITOL_80, "pubchem_cid": 6251}]) monkeypatch.setattr(interface, "rm", lambda: rm) + monkeypatch.setattr(pubchem, "fetch", lambda cid: MANNITOL) resp = client.post("/compounds", json={"cid": 6251, "cas": "69-65-8"}) assert resp.status_code == 409 assert resp.get_json()["existing"]["id"] == 80 + def test_create_cid_not_in_pubchem_is_404(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeCompoundRM([])) + monkeypatch.setattr(pubchem, "fetch", lambda cid: None) + assert client.post("/compounds", json={"cid": 999999999}).status_code == 404 + @pytest.mark.parametrize("body", [{}, {"cid": "abc"}, {"cid": 6251, "cas": "not-a-cas"}]) def test_create_rejects_bad_input(self, client, body): assert client.post("/compounds", json=body).status_code == 400 diff --git a/web/interface.py b/web/interface.py index c42a8ee..50397e3 100644 --- a/web/interface.py +++ b/web/interface.py @@ -6,6 +6,7 @@ import eln_common.compound_import as compound_import import eln_common.config as config +import eln_common.pubchem as pubchem from eln_common.fill_info import check_if_cas import web.label_creating as label_creating import web.print_handling as print_handling @@ -104,11 +105,11 @@ def compounds_list(): def _pubchem_preview(pc: dict) -> dict: """The PubChem fields the page shows before the user confirms an import.""" return { - "cid": pc.get("cid"), + "cid": pc.get("pubchem_cid"), "name": pc.get("name") or "", - "cas": pc.get("cas") or "", - "formula": pc.get("molecularFormula") or "", - "molecular_weight": pc.get("molecularWeight"), + "cas": pc.get("cas_number") or "", + "formula": pc.get("molecular_formula") or "", + "molecular_weight": pc.get("molecular_weight"), "smiles": pc.get("smiles") or "", } @@ -124,7 +125,10 @@ def compounds_pubchem(): return jsonify({"status": "error", "error": "Give a CAS number or a name"}), 400 try: rmn = rm() - found = rmn.pubchem_lookup(cas=cas or None, name=None if cas else name)[:5] + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 401 + try: + found = pubchem.search(cas=cas or None, name=None if cas else name) if not found: return jsonify({"status": "error", "error": "No match in PubChem"}), 404 compounds = rmn.get_all_compounds() @@ -137,18 +141,16 @@ def compounds_pubchem(): "reason": match[1] if match else "", }) return jsonify({"candidates": candidates}) - except ValueError as e: - return jsonify({"status": "error", "error": str(e)}), 401 except Exception as e: - return jsonify({"status": "error", "error": str(e)}), 400 + return jsonify({"status": "error", "error": str(e)}), 502 @interface_bp.route('/compounds', methods=['POST']) @cross_origin(origins="http://localhost:8000") def create_compound(): - """Creates a compound from PubChem: {cid, cas}. The details are fetched again here - by PubChem ID rather than trusted from the page. 409 with the existing compound - if the ELN already has it (even deleted).""" + """Creates a compound from PubChem: {cid, cas}. The details and hazard pictograms + are fetched again here from PubChem rather than trusted from the page. 409 with + the existing compound if the ELN already has it (even deleted).""" data = request.get_json(force=True, silent=True) or {} try: cid = int(data.get('cid')) @@ -159,18 +161,22 @@ def create_compound(): return jsonify({"status": "error", "error": f"'{cas}' is not a valid CAS number"}), 400 try: rmn = rm() - found = rmn.pubchem_lookup(cid=cid) + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 401 + try: + found = pubchem.fetch(cid) if not found: return jsonify({"status": "error", "error": f"PubChem has no compound {cid}"}), 404 - created = compound_import.create_compound_safely(rmn, found[0], cas) - return jsonify(compound_import.summary(created)), 201 + created = compound_import.create_compound_safely(rmn, found, cas) except compound_import.CompoundClash as e: return jsonify({"status": "error", "error": f"Already in the ELN ({e.reason})", "existing": compound_import.summary(e.existing)}), 409 - except ValueError as e: - return jsonify({"status": "error", "error": str(e)}), 401 except Exception as e: return jsonify({"status": "error", "error": str(e)}), 500 + result = compound_import.summary(created) + # None: PubChem has no hazard classification, so the page asks the user to check the SDS + result["pictograms"] = found["pictograms"] + return jsonify(result), 201 @interface_bp.route('/storage_tree', methods=['GET']) From d2f35623c2c84a8e7da20812398ff66c0033e9cb Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 13:48:52 -0400 Subject: [PATCH 05/26] Offer to restore a deleted compound instead of refusing it When the only compound sharing a new one's PubChem ID, InChIKey or CAS is a deleted one, POST /compounds answers 409 with can_restore, and sending {cid, cas, restore: } brings that compound back with fresh details from PubChem, the user's CAS and its hazard flags. A live match is still refused, since creating would overwrite it and the bottles linked to it. Co-Authored-By: Claude Opus 5.5 --- eln_common/compound_import.py | 60 ++++++++++++++++++++++------ tests/test_routes_offline.py | 74 +++++++++++++++++++++++++++++------ web/interface.py | 23 +++++++---- 3 files changed, 125 insertions(+), 32 deletions(-) diff --git a/eln_common/compound_import.py b/eln_common/compound_import.py index c8bf74d..49d63fe 100644 --- a/eln_common/compound_import.py +++ b/eln_common/compound_import.py @@ -37,9 +37,11 @@ class CompoundClash(Exception): """The compound already exists in the ELN (possibly deleted).""" - def __init__(self, existing: dict[str, Any], reason: str): + def __init__(self, existing: dict[str, Any], reason: str, can_restore: bool = False): self.existing = existing self.reason = reason + # True when the only match is one deleted compound, which can be restored instead + self.can_restore = can_restore super().__init__(reason) @@ -54,25 +56,33 @@ def summary(compound: dict[str, Any]) -> dict[str, Any]: } -def find_existing(pubchem: dict[str, Any], cas: str | None, - compounds: list[dict[str, Any]]) -> tuple[dict[str, Any], str] | None: +def find_clash(pubchem: dict[str, Any], cas: str | None, + compounds: list[dict[str, Any]]) -> CompoundClash | None: """ - The ELN compound (any state) that a PubChem result would collide with, and why. - Checks the unique fields that would be sent: PubChem ID, InChIKey, and the CAS - actually saved (the user's, else PubChem's; see build_compound_body). + Why a PubChem result can't simply be created: an ELN compound (any state) shares + its PubChem ID, InChIKey, or the CAS actually saved (the user's, else PubChem's; + see build_compound_body). A live match is reported before a deleted one, since + creating would overwrite it and the bottles linked to it. """ keys = [ ("pubchem_cid", pubchem.get("pubchem_cid"), "same PubChem ID"), ("inchi_key", pubchem.get("inchi_key"), "same structure (InChIKey)"), ("cas_number", cas or pubchem.get("cas_number"), "same CAS number"), ] + matches: dict[int, tuple[dict[str, Any], str]] = {} for field, value, reason in keys: if not value: continue for compound in compounds: if compound.get(field) is not None and str(compound.get(field)) == str(value): - return compound, reason - return None + matches.setdefault(compound["id"], (compound, reason)) + if not matches: + return None + live = [m for m in matches.values() if not summary(m[0])["deleted"]] + if live: + return CompoundClash(*live[0]) + first = next(iter(matches.values())) + return CompoundClash(*first, can_restore=len(matches) == 1) def build_compound_body(pubchem: dict[str, Any], cas: str | None) -> dict[str, Any]: @@ -90,18 +100,21 @@ def build_compound_body(pubchem: dict[str, Any], cas: str | None) -> dict[str, A def create_compound_safely(rm: Resource_Manager, pubchem: dict[str, Any], - cas: str | None) -> dict[str, Any]: + cas: str | None, restore_id: int | None = None) -> dict[str, Any]: """ Creates the compound described by a PubChem result, unless it would touch an - existing one. + existing one. If the only match is a deleted compound and the user agreed to + restore it (restore_id), that compound is restored and refreshed instead. :raises CompoundClash: the ELN already has this compound (maybe deleted). :raises RuntimeError: eLabFTW returned an existing compound's id anyway. - :return: The new compound as eLabFTW stores it. + :return: The new or restored compound as eLabFTW stores it. """ compounds = rm.get_all_compounds() - clash = find_existing(pubchem, cas, compounds) + clash = find_clash(pubchem, cas, compounds) if clash: - raise CompoundClash(*clash) + if clash.can_restore and clash.existing["id"] == restore_id: + return restore_compound(rm, restore_id, pubchem, cas) + raise clash known_ids = {c["id"] for c in compounds} new_id = rm.create_compound(build_compound_body(pubchem, cas)) @@ -113,3 +126,24 @@ def create_compound_safely(rm: Resource_Manager, pubchem: dict[str, Any], if pubchem.get("molecular_weight"): rm.patch_compound(new_id, {"molecular_weight": pubchem["molecular_weight"]}) return rm.get_compound(new_id) + + +def restore_compound(rm: Resource_Manager, compound_id: int, pubchem: dict[str, Any], + cas: str | None) -> dict[str, Any]: + """ + Brings a deleted compound back with fresh details from PubChem and the user's CAS. + (eLabFTW would do this silently on create; here it happens only when asked.) + :return: The restored compound as eLabFTW stores it. + """ + body: dict[str, Any] = {k: v for k, v in build_compound_body(pubchem, cas).items() + if not k.startswith("is_")} + if pubchem.get("pictograms") is not None: + # unlike create, eLabFTW's PATCH counts a flag as set only when it is "on" + for pictogram, flag in GHS_FLAGS.items(): + body[flag] = "on" if pictogram in pubchem["pictograms"] else "off" + if pubchem.get("molecular_weight"): + body["molecular_weight"] = pubchem["molecular_weight"] + # last, so the compound stays deleted if eLabFTW refuses one of the changes above + body["state"] = 1 + rm.patch_compound(compound_id, body) + return rm.get_compound(compound_id) diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 85f4fbb..aec68e6 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -411,7 +411,8 @@ def patch_compound(self, id, body): self.patched.append((id, body)) def get_compound(self, id): - body = self.created[-1] + patches = [b for i, b in self.patched if i == id and "name" in b] + body = patches[-1] if patches else self.created[-1] return {"id": id, "name": body.get("name"), "cas_number": body.get("cas_number"), "molecular_formula": body.get("molecular_formula"), "state": 1} @@ -423,6 +424,10 @@ def get_compound(self, id): THF = {"pubchem_cid": 8028, "cas_number": "109-99-9", "name": "Tetrahydrofuran", "inchi_key": "WYURNTSHIVDZCO-UHFFFAOYSA-N", "molecular_formula": "C4H8O", "molecular_weight": 72.11, "smiles": "C1CCOC1", "pictograms": ["GHS02", "GHS07", "GHS08"]} +PHENYLBORONIC = {"pubchem_cid": 66827, "cas_number": "98-80-6", "name": "Phenylboronic Acid", + "inchi_key": "HXITXNWTGFUOAU-UHFFFAOYSA-N", "molecular_formula": "C6H7BO2", + "molecular_weight": 121.93, "pictograms": ["GHS07"]} +PHENYLBORONIC_8_DELETED = {"id": 8, "name": "Phenylboronic acid", "cas_number": "98-80-6", "state": 3} HEXITOL_80 = {"id": 80, "name": "Hexitol", "cas_number": "87-78-5", "pubchem_cid": 453, "inchi_key": "FBPFZTCFMRRESA-UHFFFAOYSA-N", "state": 1} @@ -464,15 +469,50 @@ def test_same_pubchem_id_is_refused(self): assert e.value.existing["id"] == 80 and "PubChem ID" in e.value.reason assert rm.created == [] - def test_deleted_compound_with_same_cas_is_refused(self): + def test_deleted_compound_with_same_cas_is_refused_but_can_be_restored(self): # like #8 Phenylboronic acid: deleted, but it still holds its CAS - deleted = {"id": 8, "name": "Phenylboronic acid", "cas_number": "98-80-6", "state": 3} - pc = {"pubchem_cid": 66827, "cas_number": "98-80-6", "name": "Phenylboronic Acid", - "inchi_key": "HXIT-X"} - rm = FakeCompoundRM([deleted]) + rm = FakeCompoundRM([PHENYLBORONIC_8_DELETED]) with pytest.raises(compound_import.CompoundClash) as e: - compound_import.create_compound_safely(rm, pc, "98-80-6") + compound_import.create_compound_safely(rm, PHENYLBORONIC, "98-80-6") assert e.value.existing["id"] == 8 and compound_import.summary(e.value.existing)["deleted"] + assert e.value.can_restore and rm.created == [] and rm.patched == [] + + def test_restore_refreshes_the_deleted_compound(self): + rm = FakeCompoundRM([PHENYLBORONIC_8_DELETED]) + restored = compound_import.create_compound_safely(rm, PHENYLBORONIC, "98-80-6", restore_id=8) + assert restored["id"] == 8 and rm.created == [] + [(patched_id, body)] = rm.patched + assert patched_id == 8 + assert body["name"] == "Phenylboronic Acid" and body["cas_number"] == "98-80-6" + assert body["molecular_weight"] == 121.93 + # every hazard flag is written, as "on"/"off" (what eLabFTW's PATCH reads) + assert body["is_hazardous2health"] == "on" and body["is_flammable"] == "off" + assert len([k for k in body if k.startswith("is_")]) == 9 + assert list(body)[-1] == "state" and body["state"] == 1 + + def test_restore_keeps_flags_when_hazards_are_unknown(self): + rm = FakeCompoundRM([PHENYLBORONIC_8_DELETED]) + compound_import.create_compound_safely(rm, {**PHENYLBORONIC, "pictograms": None}, "98-80-6", restore_id=8) + assert not any(k.startswith("is_") for k in rm.patched[0][1]) + + def test_restore_needs_the_matching_id(self): + rm = FakeCompoundRM([PHENYLBORONIC_8_DELETED]) + with pytest.raises(compound_import.CompoundClash): + compound_import.create_compound_safely(rm, PHENYLBORONIC, "98-80-6", restore_id=9) + assert rm.patched == [] + + def test_live_match_wins_over_deleted_and_cannot_be_restored(self): + live = {"id": 30, "name": "Phenylboronic acid (2)", "pubchem_cid": 66827, "state": 1} + rm = FakeCompoundRM([PHENYLBORONIC_8_DELETED, live]) + with pytest.raises(compound_import.CompoundClash) as e: + compound_import.create_compound_safely(rm, PHENYLBORONIC, "98-80-6", restore_id=8) + assert e.value.existing["id"] == 30 and not e.value.can_restore + assert rm.patched == [] + + def test_two_deleted_matches_cannot_be_restored(self): + other = {"id": 9, "name": "old copy", "pubchem_cid": 66827, "state": 3} + clash = compound_import.find_clash(PHENYLBORONIC, "98-80-6", [PHENYLBORONIC_8_DELETED, other]) + assert clash and not clash.can_restore def test_existing_id_returned_by_elabftw_stops(self): # if eLabFTW still overwrote something, never treat that compound as new @@ -586,6 +626,7 @@ def test_preview_marks_existing_compound(self, client, monkeypatch): candidate = resp.get_json()["candidates"][0] assert candidate["pubchem"]["name"] == "Acetone" and candidate["pubchem"]["cid"] == 180 assert candidate["existing"]["id"] == 72 and candidate["reason"] == "same PubChem ID" + assert candidate["can_restore"] is False def test_preview_not_in_pubchem_is_404(self, client, monkeypatch): monkeypatch.setattr(interface, "rm", lambda: FakeCompoundRM([])) @@ -601,8 +642,8 @@ def test_create_returns_201_with_new_compound(self, client, monkeypatch): monkeypatch.setattr(pubchem, "fetch", lambda cid: MANNITOL) resp = client.post("/compounds", json={"cid": 6251, "cas": "69-65-8"}) assert resp.status_code == 201 - assert resp.get_json() == {"id": 126, "name": "Mannitol", "cas": "69-65-8", - "formula": "C6H14O6", "deleted": False, "pictograms": []} + assert resp.get_json() == {"id": 126, "name": "Mannitol", "cas": "69-65-8", "formula": "C6H14O6", + "deleted": False, "restored": False, "pictograms": []} def test_create_reports_pictograms(self, client, monkeypatch): monkeypatch.setattr(interface, "rm", lambda: FakeCompoundRM([], create_returns=127)) @@ -622,14 +663,25 @@ def test_create_clash_is_409_with_existing(self, client, monkeypatch): monkeypatch.setattr(pubchem, "fetch", lambda cid: MANNITOL) resp = client.post("/compounds", json={"cid": 6251, "cas": "69-65-8"}) assert resp.status_code == 409 - assert resp.get_json()["existing"]["id"] == 80 + assert resp.get_json()["existing"]["id"] == 80 and resp.get_json()["can_restore"] is False + + def test_create_deleted_match_offers_restore_then_restores(self, client, monkeypatch): + rm = FakeCompoundRM([PHENYLBORONIC_8_DELETED]) + monkeypatch.setattr(interface, "rm", lambda: rm) + monkeypatch.setattr(pubchem, "fetch", lambda cid: PHENYLBORONIC) + resp = client.post("/compounds", json={"cid": 66827, "cas": "98-80-6"}) + assert resp.status_code == 409 and resp.get_json()["can_restore"] is True + resp = client.post("/compounds", json={"cid": 66827, "cas": "98-80-6", "restore": 8}) + assert resp.status_code == 200 + assert resp.get_json()["id"] == 8 and resp.get_json()["restored"] is True def test_create_cid_not_in_pubchem_is_404(self, client, monkeypatch): monkeypatch.setattr(interface, "rm", lambda: FakeCompoundRM([])) monkeypatch.setattr(pubchem, "fetch", lambda cid: None) assert client.post("/compounds", json={"cid": 999999999}).status_code == 404 - @pytest.mark.parametrize("body", [{}, {"cid": "abc"}, {"cid": 6251, "cas": "not-a-cas"}]) + @pytest.mark.parametrize("body", [{}, {"cid": "abc"}, {"cid": 6251, "cas": "not-a-cas"}, + {"cid": 6251, "restore": "8"}, {"cid": 6251, "restore": True}]) def test_create_rejects_bad_input(self, client, body): assert client.post("/compounds", json=body).status_code == 400 diff --git a/web/interface.py b/web/interface.py index 50397e3..349c222 100644 --- a/web/interface.py +++ b/web/interface.py @@ -134,11 +134,12 @@ def compounds_pubchem(): compounds = rmn.get_all_compounds() candidates = [] for pc in found: - match = compound_import.find_existing(pc, cas or None, compounds) + clash = compound_import.find_clash(pc, cas or None, compounds) candidates.append({ "pubchem": _pubchem_preview(pc), - "existing": compound_import.summary(match[0]) if match else None, - "reason": match[1] if match else "", + "existing": compound_import.summary(clash.existing) if clash else None, + "reason": clash.reason if clash else "", + "can_restore": clash.can_restore if clash else False, }) return jsonify({"candidates": candidates}) except Exception as e: @@ -150,7 +151,8 @@ def compounds_pubchem(): def create_compound(): """Creates a compound from PubChem: {cid, cas}. The details and hazard pictograms are fetched again here from PubChem rather than trusted from the page. 409 with - the existing compound if the ELN already has it (even deleted).""" + the existing compound if the ELN already has it; when that is a deleted compound + (can_restore), sending {cid, cas, restore: } restores and refreshes it (200).""" data = request.get_json(force=True, silent=True) or {} try: cid = int(data.get('cid')) @@ -159,6 +161,9 @@ def create_compound(): cas = (data.get('cas') or '').strip() or None if cas and not check_if_cas(cas): return jsonify({"status": "error", "error": f"'{cas}' is not a valid CAS number"}), 400 + restore_id = data.get('restore') + if restore_id is not None and (not isinstance(restore_id, int) or isinstance(restore_id, bool)): + return jsonify({"status": "error", "error": "restore must be a compound id"}), 400 try: rmn = rm() except ValueError as e: @@ -167,16 +172,18 @@ def create_compound(): found = pubchem.fetch(cid) if not found: return jsonify({"status": "error", "error": f"PubChem has no compound {cid}"}), 404 - created = compound_import.create_compound_safely(rmn, found, cas) + saved = compound_import.create_compound_safely(rmn, found, cas, restore_id) except compound_import.CompoundClash as e: return jsonify({"status": "error", "error": f"Already in the ELN ({e.reason})", - "existing": compound_import.summary(e.existing)}), 409 + "existing": compound_import.summary(e.existing), + "can_restore": e.can_restore}), 409 except Exception as e: return jsonify({"status": "error", "error": str(e)}), 500 - result = compound_import.summary(created) + result = compound_import.summary(saved) + result["restored"] = saved["id"] == restore_id # None: PubChem has no hazard classification, so the page asks the user to check the SDS result["pictograms"] = found["pictograms"] - return jsonify(result), 201 + return jsonify(result), 200 if result["restored"] else 201 @interface_bp.route('/storage_tree', methods=['GET']) From cd9b6485ff04459c12ef5f6b166e804981aae938 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 13:59:53 -0400 Subject: [PATCH 06/26] Add storage places from the app without near-duplicates POST /storage_units {name, parent_id, confirm} adds a place inside an existing room or place. eLabFTW accepts any name, so the app first compares it with the places in the same parent: the same name (ignoring case and spaces) is refused with the existing place, and a similar one ("Flamable cabinet") needs confirm: true. Names that differ in their numbers ("Shelf 1", "Shelf 2") are not similar. New rooms are left to admins in eLabFTW. Co-Authored-By: Claude Opus 5.5 --- eln_common/resourcemanage.py | 11 ++++ eln_common/storage_places.py | 86 ++++++++++++++++++++++++++++ tests/test_routes_offline.py | 108 +++++++++++++++++++++++++++++++++++ web/interface.py | 60 +++++++++++++++---- 4 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 eln_common/storage_places.py diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index f5629e3..a86e66d 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -177,6 +177,17 @@ def get_storage_units(self) -> list[dict[str, Any]]: response = self.get_url("/storage_units?hierarchy=true") response.raise_for_status() return response.json() + + def create_storage_unit(self, name: str, parent_id: int) -> int: + """ + Creates a storage place inside another one. Prefer storage_places.create_place_safely(), + which refuses duplicate names (eLabFTW accepts any name). + :return: The new place's id. + """ + response = self.post_url("/storage_units", json={"name": name, "parent_id": parent_id}) + response.raise_for_status() + return int(str(response.headers["Location"]).rstrip("/").split("/").pop()) + def add_tag(self, item_id: int, tag: str): """ Adds a tag to an item in the ELN with the given item ID and tag. Take care to use correct capitalization/whitespace diff --git a/eln_common/storage_places.py b/eln_common/storage_places.py new file mode 100644 index 0000000..f1e21cb --- /dev/null +++ b/eln_common/storage_places.py @@ -0,0 +1,86 @@ +""" +Adds storage places (cabinets, shelves...) without making near-duplicates. + +eLabFTW saves any name, so "Flammable cabinet", "flammable cabinet" and +"Flamable cabinet" would become three places and split the bottles between them. +A new place is therefore compared with the places already in the same room or +place: the same name is refused (use the existing one), a similar name needs the +user's confirmation. Places are only added inside an existing one; new rooms are +added in eLabFTW by an admin. +""" + +import difflib +import re +from typing import Any + +from eln_common.resourcemanage import Resource_Manager + +MAX_NAME_LENGTH = 255 # eLabFTW's storage_units.name column +SIMILAR_RATIO = 0.8 # difflib ratio from which two names count as "did you mean" + + +class PlaceClash(Exception): + """A place with the same (exact) or a similar name exists in the same parent.""" + + def __init__(self, existing: dict[str, Any], exact: bool): + self.existing = existing + self.exact = exact + super().__init__(f"Already exists: {existing['name']}" if exact + else f"Did you mean {existing['name']}?") + + +class UnknownParent(Exception): + """The room or place to add into does not exist.""" + + +def clean_name(name: str) -> str: + """The name with surrounding and repeated spaces removed.""" + return " ".join(name.split()) + + +def _key(name: str) -> str: + return clean_name(name).casefold() + + +def find_clash(name: str, parent_id: int, units: list[dict[str, Any]]) -> PlaceClash | None: + """The place in the same parent with the same name (ignoring case and spaces), or failing + that the most similar one. Names with different numbers ("Shelf 1", "Shelf 2") are not similar.""" + siblings = [u for u in units if u.get("parent_id") == parent_id] + for unit in siblings: + if _key(unit["name"]) == _key(name): + return PlaceClash(unit, exact=True) + best, best_ratio = None, SIMILAR_RATIO + for unit in siblings: + if re.findall(r"\d+", unit["name"]) != re.findall(r"\d+", name): + continue + ratio = difflib.SequenceMatcher(None, _key(unit["name"]), _key(name)).ratio() + if ratio >= best_ratio: + best, best_ratio = unit, ratio + return PlaceClash(best, exact=False) if best else None + + +def create_place_safely(rm: Resource_Manager, name: str, parent_id: int, + confirm_similar: bool = False) -> dict[str, Any]: + """ + Creates a storage place inside parent_id, unless one with the same name is there + already, or a similar one is there and the user has not confirmed. + :raises ValueError: the name is empty or too long. + :raises UnknownParent: no storage place has id parent_id. + :raises PlaceClash: a place with the same or a similar name exists there. + :return: The new place as {id, name, parent_id, full_path}. + """ + name = clean_name(name) + if not name: + raise ValueError("The place needs a name") + if len(name) > MAX_NAME_LENGTH: + raise ValueError(f"The name is longer than {MAX_NAME_LENGTH} characters") + units = rm.get_storage_units() + parent = next((u for u in units if u["id"] == parent_id), None) + if parent is None: + raise UnknownParent(f"No storage place #{parent_id}") + clash = find_clash(name, parent_id, units) + if clash and (clash.exact or not confirm_similar): + raise clash + new_id = rm.create_storage_unit(name, parent_id) + return {"id": new_id, "name": name, "parent_id": parent_id, + "full_path": f"{parent.get('full_path') or parent['name']} > {name}"} diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index aec68e6..ec1fd9b 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -10,6 +10,7 @@ import eln_common.compound_import as compound_import import eln_common.pubchem as pubchem +import eln_common.storage_places as storage_places import web.interface as interface import web.search_process as search_process from automations.labels.generate_label import LabelGenerator @@ -686,6 +687,113 @@ def test_create_rejects_bad_input(self, client, body): assert client.post("/compounds", json=body).status_code == 400 +STORAGE = [ + {"id": 4, "name": "Room 3057", "parent_id": None, "full_path": "Room 3057"}, + {"id": 5, "name": "Front hood", "parent_id": 4, "full_path": "Room 3057 > Front hood"}, + {"id": 6, "name": "Flammable cabinet", "parent_id": 5, "full_path": "Room 3057 > Front hood > Flammable cabinet"}, + {"id": 7, "name": "Corrosive cabinet", "parent_id": 5, "full_path": "Room 3057 > Front hood > Corrosive cabinet"}, + {"id": 16, "name": "Room 3053", "parent_id": None, "full_path": "Room 3053"}, + {"id": 21, "name": "Shelf 1", "parent_id": 16, "full_path": "Room 3053 > Shelf 1"}, +] + + +class FakeStorageRM: + """Stand-in for the storage calls of Resource_Manager.""" + + def __init__(self, units=STORAGE, create_returns=30): + self.units = units + self.create_returns = create_returns + self.created = [] + + def get_storage_units(self): + return self.units + + def create_storage_unit(self, name, parent_id): + self.created.append((name, parent_id)) + return self.create_returns + + +class TestStoragePlaces: + """eLabFTW accepts any name, so near-duplicates are caught before creating.""" + + def test_new_name_is_created_with_its_full_path(self): + rm = FakeStorageRM() + place = storage_places.create_place_safely(rm, " Shelf 2 ", 16) + assert rm.created == [("Shelf 2", 16)] + assert place == {"id": 30, "name": "Shelf 2", "parent_id": 16, "full_path": "Room 3053 > Shelf 2"} + + @pytest.mark.parametrize("name", ["Flammable cabinet", "flammable CABINET", " Flammable cabinet "]) + def test_same_name_in_same_parent_is_refused_even_if_confirmed(self, name): + rm = FakeStorageRM() + with pytest.raises(storage_places.PlaceClash) as e: + storage_places.create_place_safely(rm, name, 5, confirm_similar=True) + assert e.value.exact and e.value.existing["id"] == 6 + assert rm.created == [] + + def test_similar_name_needs_confirmation(self): + rm = FakeStorageRM() + with pytest.raises(storage_places.PlaceClash) as e: + storage_places.create_place_safely(rm, "Flamable cabinet", 5) + assert not e.value.exact and e.value.existing["id"] == 6 + assert rm.created == [] + storage_places.create_place_safely(rm, "Flamable cabinet", 5, confirm_similar=True) + assert rm.created == [("Flamable cabinet", 5)] + + def test_same_name_in_another_parent_is_fine(self): + rm = FakeStorageRM() + storage_places.create_place_safely(rm, "Flammable cabinet", 16) + assert rm.created == [("Flammable cabinet", 16)] + + @pytest.mark.parametrize("name", ["Corrosive shelf", "Shelf 2", "Base cabinet"]) + def test_different_names_are_not_similar(self, name): + assert storage_places.find_clash(name, 16, STORAGE + [ + {"id": 22, "name": "Acid cabinet", "parent_id": 16}]) is None + + def test_unknown_parent(self): + with pytest.raises(storage_places.UnknownParent): + storage_places.create_place_safely(FakeStorageRM(), "Shelf 2", 999) + + @pytest.mark.parametrize("name", ["", " ", "x" * 256]) + def test_bad_names(self, name): + with pytest.raises(ValueError): + storage_places.create_place_safely(FakeStorageRM(), name, 16) + + +class TestStorageRoutes: + def test_create_returns_201(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeStorageRM()) + resp = client.post("/storage_units", json={"name": "Shelf 2", "parent_id": 16}) + assert resp.status_code == 201 and resp.get_json()["full_path"] == "Room 3053 > Shelf 2" + + def test_exact_clash_is_409_with_existing(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeStorageRM()) + resp = client.post("/storage_units", json={"name": "flammable cabinet", "parent_id": 5, "confirm": True}) + assert resp.status_code == 409 + assert resp.get_json()["exact"] is True and resp.get_json()["existing"]["id"] == 6 + + def test_similar_is_409_then_confirm_creates(self, client, monkeypatch): + rm = FakeStorageRM() + monkeypatch.setattr(interface, "rm", lambda: rm) + resp = client.post("/storage_units", json={"name": "Flamable cabinet", "parent_id": 5}) + assert resp.status_code == 409 and resp.get_json()["exact"] is False + resp = client.post("/storage_units", json={"name": "Flamable cabinet", "parent_id": 5, "confirm": True}) + assert resp.status_code == 201 + + def test_unknown_parent_is_404(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeStorageRM()) + assert client.post("/storage_units", json={"name": "Shelf 2", "parent_id": 999}).status_code == 404 + + @pytest.mark.parametrize("body", [{}, {"name": "Shelf 2"}, {"name": "Shelf 2", "parent_id": "16"}, + {"name": "Shelf 2", "parent_id": True}, {"parent_id": 16}, + {"name": " ", "parent_id": 16}]) + def test_bad_input_is_400(self, client, monkeypatch, body): + monkeypatch.setattr(interface, "rm", lambda: FakeStorageRM()) + assert client.post("/storage_units", json=body).status_code == 400 + + def test_needs_an_api_key(self, client): + assert client.post("/storage_units", json={"name": "Shelf 2", "parent_id": 16}).status_code == 401 + + class TestCreateItemFromTemplate: """New resources start from their template, so they get its category and default status (items created from a bare category had no status).""" diff --git a/web/interface.py b/web/interface.py index 349c222..5c79b02 100644 --- a/web/interface.py +++ b/web/interface.py @@ -3,10 +3,12 @@ from flask import Blueprint, current_app, jsonify, request, send_from_directory from flask_cors import cross_origin +from requests import HTTPError import eln_common.compound_import as compound_import import eln_common.config as config import eln_common.pubchem as pubchem +import eln_common.storage_places as storage_places from eln_common.fill_info import check_if_cas import web.label_creating as label_creating import web.print_handling as print_handling @@ -186,28 +188,66 @@ def create_compound(): return jsonify(result), 200 if result["restored"] else 201 +def _place(unit: dict) -> dict: + """A storage place as the page uses it.""" + return { + "id": unit["id"], + "name": unit["name"], + "parent_id": unit.get("parent_id"), + "full_path": unit.get("full_path") or unit["name"], + } + + @interface_bp.route('/storage_tree', methods=['GET']) @cross_origin(origins="http://localhost:8000") def storage_tree(): """Storage places as [{id, name, parent_id, full_path}], for the Room -> Place dropdowns (rooms have parent_id null).""" try: - units = rm().get_storage_units() - return jsonify([ - { - "id": u["id"], - "name": u["name"], - "parent_id": u.get("parent_id"), - "full_path": u.get("full_path") or u["name"], - } - for u in units - ]) + return jsonify([_place(u) for u in rm().get_storage_units()]) except ValueError as e: return jsonify({"status": "error", "error": str(e)}), 401 except Exception as e: return jsonify({"status": "error", "error": str(e)}), 400 +@interface_bp.route('/storage_units', methods=['POST']) +@cross_origin(origins="http://localhost:8000") +def create_storage_unit(): + """Adds a storage place inside an existing one: {name, parent_id, confirm}. + 201 with the new place; 409 with the existing place when one in the same parent + has the same name (exact: true) or a similar one (exact: false; send again with + confirm: true to add it anyway). New rooms are added in eLabFTW, not here.""" + data = request.get_json(force=True, silent=True) or {} + name = data.get('name') + parent_id = data.get('parent_id') + if not isinstance(name, str): + return jsonify({"status": "error", "error": "The place needs a name"}), 400 + if not isinstance(parent_id, int) or isinstance(parent_id, bool): + return jsonify({"status": "error", + "error": "Choose the room or place to add it in (parent_id)"}), 400 + try: + rmn = rm() + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 401 + try: + place = storage_places.create_place_safely(rmn, name, parent_id, data.get('confirm') is True) + except storage_places.PlaceClash as e: + return jsonify({"status": "error", "error": str(e), "exact": e.exact, + "existing": _place(e.existing)}), 409 + except storage_places.UnknownParent as e: + return jsonify({"status": "error", "error": str(e)}), 404 + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 400 + except HTTPError as e: + # e.g. 403 when eLabFTW does not let this user manage storage places + status = e.response.status_code if e.response is not None else 500 + return jsonify({"status": "error", "error": f"eLabFTW refused: {e}"}), status + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 500 + return jsonify(place), 201 + + # the team-ID settings that the web settings page may change, with their coercers SETTINGS_SCHEMA = { "status_open": int, From d0f4d24dc8e6f269d3bb2facb46e8b0beb1f52c2 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 14:12:23 -0400 Subject: [PATCH 07/26] Add bottles linked to their compound and storage place POST /resources {category, title, compounds, storage: {place_id, amount, unit}, fields} creates the bottle from its template, keeps only the fields about the bottle itself (place and amount go to Storage, chemical details to the linked compounds; polymers keep theirs), links the compounds, adds the storage container and the structure image. The request is checked first so a bad one creates nothing; once the bottle exists, a later step that fails is reported in "problems" instead of deleting it. Co-Authored-By: Claude Opus 5.5 --- eln_common/add_bottle.py | 162 +++++++++++++++++++++++++++++++++++ eln_common/resourcemanage.py | 29 ++++++- tests/test_routes_offline.py | 161 ++++++++++++++++++++++++++++++++++ web/interface.py | 25 ++++++ 4 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 eln_common/add_bottle.py diff --git a/eln_common/add_bottle.py b/eln_common/add_bottle.py new file mode 100644 index 0000000..db2f0db --- /dev/null +++ b/eln_common/add_bottle.py @@ -0,0 +1,162 @@ +""" +Adds a bottle (an eLabFTW resource) the structured way. + +Instead of typing every detail into the template's extra fields, a bottle now +points to things that are stored once: + - its compound(s), which hold the name, structure and hazards; + - a storage place, with the amount and unit (eLabFTW "containers"). +Only the facts about this one bottle (supplier, lot, purity, dates...) stay as +extra fields. + +create_bottle() does it in order: create from the template -> title and fields -> +link compounds -> put it in its place -> structure image. Once the bottle exists +it is never deleted: a later step that fails is reported back as a problem, so +the user can finish it in eLabFTW. +""" + +import json +from typing import Any + +from automations.image_generator import generate_image +from eln_common.resourcemanage import Resource_Manager + +# eLabFTW's fixed list of storage units (μ is the Greek letter mu, U+03BC) +UNITS = ["•", "μL", "mL", "L", "μg", "mg", "g", "kg", "bar", "m", "e6 cells"] +# what the form offers for each State; any unit above is still accepted +UNITS_BY_STATE = { + "Liquid": ["μL", "mL", "L"], + "Solid": ["μg", "mg", "g", "kg"], + "Gas": ["bar", "L", "g"], +} + +# template fields that now live in Storage (place, amount and unit)... +STORAGE_FIELDS = {"Room", "Location", "Quantity"} +# ...and in the linked compounds. Bottles without compounds (polymers) keep these. +COMPOUND_FIELDS = {"Full name", "SMILES", "Molecular Weight", "Hazards Link", "Pubchem Link", + "Solvent SMILES"} + + +class InvalidBottle(Exception): + """The request can't make a bottle (missing or unknown value); nothing was created.""" + + +def bottle_fields(extra_fields: dict[str, Any], has_compounds: bool) -> dict[str, Any]: + """The template's extra fields that stay on the bottle itself.""" + moved = STORAGE_FIELDS | (COMPOUND_FIELDS if has_compounds else set()) + return {name: field for name, field in extra_fields.items() if name not in moved} + + +def clean_unit(unit: Any) -> str: + """The unit as eLabFTW spells it ("µL" typed with the micro sign becomes "μL").""" + unit = str(unit or "").strip().replace("µ", "μ") + if unit not in UNITS: + raise InvalidBottle(f"Unknown unit '{unit}'; use one of {', '.join(UNITS)}") + return unit + + +def check_request(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: + """ + Checks everything the bottle needs before anything is created, so a bad request + leaves no half-made bottle behind. + :raises InvalidBottle: with a message for the user. + :return: The cleaned request plus the template's metadata to start from. + """ + category = data.get("category") + if not isinstance(category, int) or isinstance(category, bool): + raise InvalidBottle("Choose a category") + try: + template = rm.get_items_type(category) + except Exception: + raise InvalidBottle(f"There is no category #{category}") + + title = data.get("title") + if not isinstance(title, str) or not title.strip(): + raise InvalidBottle("The bottle needs a name") + + compounds = data.get("compounds") or [] + if not isinstance(compounds, list) or not all( + isinstance(c, int) and not isinstance(c, bool) for c in compounds): + raise InvalidBottle("compounds must be a list of compound ids") + live_compounds = {c["id"]: c for c in rm.get_compounds()} + for compound_id in compounds: + if compound_id not in live_compounds: + raise InvalidBottle(f"There is no compound #{compound_id}") + + storage = data.get("storage") + if not isinstance(storage, dict): + raise InvalidBottle("Choose where the bottle is kept, with its amount") + place_id = storage.get("place_id") + if not isinstance(place_id, int) or isinstance(place_id, bool): + raise InvalidBottle("Choose where the bottle is kept") + if place_id not in {u["id"] for u in rm.get_storage_units()}: + raise InvalidBottle(f"There is no storage place #{place_id}") + amount = storage.get("amount") + if not isinstance(amount, (int, float)) or isinstance(amount, bool) or amount < 0: + raise InvalidBottle("The amount must be a number, 0 or more") + unit = clean_unit(storage.get("unit")) + + metadata = json.loads(template.get("metadata") or "{}") + allowed = bottle_fields(metadata.get("extra_fields", {}), bool(compounds)) + fields = data.get("fields") or {} + if not isinstance(fields, dict): + raise InvalidBottle("fields must be an object of field name: value") + for name, value in fields.items(): + if name not in allowed: + raise InvalidBottle(f"'{name}' is not a field of this category's bottles") + if value is not None and not isinstance(value, (str, int, float)): + raise InvalidBottle(f"'{name}' must be text or a number") + + return {"category": category, "title": title.strip(), "compounds": compounds, + "place_id": place_id, "amount": amount, "unit": unit, "fields": fields, + "metadata": metadata, "smiles": live_compounds[compounds[0]].get("smiles") if compounds else None} + + +def bottle_metadata(metadata: dict[str, Any], fields: dict[str, Any], has_compounds: bool) -> str: + """The template's metadata with only the bottle's own fields, filled with the user's values.""" + kept = bottle_fields(metadata.get("extra_fields", {}), has_compounds) + for name, value in fields.items(): + kept[name] = {**kept[name], "value": "" if value is None else str(value)} + return json.dumps({**metadata, "extra_fields": kept}) + + +def create_bottle(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: + """ + Creates a bottle from a request like + {"category": 2, "title": "Tetrahydrofuran", "compounds": [77], + "storage": {"place_id": 6, "amount": 500, "unit": "mL"}, + "fields": {"Manufacturer": "Sigma-Aldrich", "Lot number": "SHBM1234"}} + :raises InvalidBottle: nothing was created. + :return: {"id": new bottle id, "problems": [steps that failed after it was created]} + """ + request = check_request(rm, data) + has_compounds = bool(request["compounds"]) + + item_id = rm.create_item_from_template(request["category"]) + problems = [] + + try: + rm.change_item(item_id, { + "title": request["title"], + "metadata": bottle_metadata(request["metadata"], request["fields"], has_compounds), + }) + except Exception as e: + problems.append(f"Saving the name and details failed: {e}") + + for compound_id in request["compounds"]: + try: + rm.link_compound(item_id, compound_id) + except Exception as e: + problems.append(f"Linking compound #{compound_id} failed: {e}") + + try: + rm.add_to_storage(item_id, request["place_id"], request["amount"], request["unit"]) + except Exception as e: + problems.append(f"Putting it in its storage place failed: {e}") + + if request["smiles"]: + try: + rm.upload_file(item_id, generate_image(request["smiles"])) + except Exception as e: + problems.append(f"Adding the structure image failed: {e}") + + return {"id": item_id, "problems": problems} diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index a86e66d..4bf1238 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -54,6 +54,17 @@ def create_item(self, template: int, body_dict: dict[str, Any]) -> int: :param dict body_dict: The body of the item to be created. :return: The ID of the newly created item. """ + item_id = self.create_item_from_template(template) + # the template already set the right category; callers pass the template ID there + self.change_item(item_id, {k: v for k, v in body_dict.items() if k != "category"}) + return item_id + + def create_item_from_template(self, template: int) -> int: + """ + Creates an item from the given resource template, with the template's category, + default status, body and extra fields, and nothing else. + :return: The ID of the newly created item. + """ response = self.itemsapi.post_item_with_http_info( # type: ignore body={ "template": template, @@ -61,10 +72,7 @@ def create_item(self, template: int, body_dict: dict[str, Any]) -> int: ) locationHeaderInResponse: str = str(response[2].get("Location")) #type: ignore print(f"The newly created item is here: {locationHeaderInResponse}") - item_id:int = int(locationHeaderInResponse.split("/").pop()) - # the template already set the right category; callers pass the template ID there - self.change_item(item_id, {k: v for k, v in body_dict.items() if k != "category"}) - return item_id + return int(locationHeaderInResponse.split("/").pop()) def change_item(self, id: int, body_dict: dict[str, Any]) -> None: """ @@ -178,6 +186,19 @@ def get_storage_units(self) -> list[dict[str, Any]]: response.raise_for_status() return response.json() + def link_compound(self, item_id: int, compound_id: int) -> None: + """Links a compound to an item (it then shows under the item's Compounds).""" + # eLabFTW needs a JSON body here, even an empty one + self.post_url(f"/items/{item_id}/compounds_links/{compound_id}", json={}).raise_for_status() + + def add_to_storage(self, item_id: int, storage_id: int, amount: float, unit: str) -> None: + """ + Puts an item in a storage place with an amount, as a container (Storage section + of the item). eLabFTW keeps 2 decimals and only accepts its own units (μL, mL, g...). + """ + self.post_url(f"/items/{item_id}/containers/{storage_id}", + json={"qty_stored": amount, "qty_unit": unit}).raise_for_status() + def create_storage_unit(self, name: str, parent_id: int) -> int: """ Creates a storage place inside another one. Prefer storage_places.create_place_safely(), diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index ec1fd9b..bbeac4e 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -8,6 +8,7 @@ import pytest +import eln_common.add_bottle as add_bottle import eln_common.compound_import as compound_import import eln_common.pubchem as pubchem import eln_common.storage_places as storage_places @@ -794,6 +795,166 @@ def test_needs_an_api_key(self, client): assert client.post("/storage_units", json={"name": "Shelf 2", "parent_id": 16}).status_code == 401 +def _field(position, type_="text", **extra): + return {"type": type_, "value": "", "position": position, **extra} + + +CHEMICAL_TEMPLATE = {"id": 2, "title": "Chemical Compound", "metadata": json.dumps({ + "elabftw": {"display_main_text": True}, + "extra_fields": { + "CAS": _field(1), "Location": _field(2, "select", options=["Front hood"]), + "Quantity": _field(3, "number"), "Received": _field(4, "date"), "Room": _field(5, "select"), + "State": _field(6, "select", options=["Solid", "Liquid", "Gas"]), "Purity": _field(7, "number", unit="%"), + "Lot number": _field(8), "Full name": _field(9), "SMILES": _field(10), "Manufacturer": _field(11, "select"), + "Molecular Weight": _field(12, "number"), "Hazards Link": _field(13), "Pubchem Link": _field(14), + }})} +POLYMER_TEMPLATE = {"id": 3, "title": "Polymer", "metadata": json.dumps({"extra_fields": { + "Mw": _field(1, "number"), "Full name": _field(2), "SMILES": _field(3), "Location": _field(4), + "Quantity": _field(5, "number"), "Room": _field(6)}})} + + +class FakeBottleRM: + """Stand-in for the calls create_bottle makes; records them in order.""" + + def __init__(self, fail=()): + self.calls = [] + self.fail = set(fail) + + def _record(self, name, *args): + self.calls.append((name, *args)) + if name in self.fail: + raise RuntimeError(f"{name} refused") + + def get_items_type(self, id): + templates = {2: CHEMICAL_TEMPLATE, 3: POLYMER_TEMPLATE} + if id not in templates: + raise RuntimeError("404") + return templates[id] + + def get_compounds(self): + return [{"id": 77, "name": "Tetrahydrofuran", "smiles": "C1CCOC1"}, {"id": 72, "name": "Acetone"}] + + def get_storage_units(self): + return STORAGE + + def create_item_from_template(self, template): + self._record("create", template) + return 640 + + def change_item(self, id, body): + self._record("change", id, body) + + def link_compound(self, item_id, compound_id): + self._record("link", item_id, compound_id) + + def add_to_storage(self, item_id, storage_id, amount, unit): + self._record("storage", item_id, storage_id, amount, unit) + + def upload_file(self, id, path): + self._record("upload", id, path) + + +THF_BOTTLE = {"category": 2, "title": " Tetrahydrofuran ", "compounds": [77], + "storage": {"place_id": 6, "amount": 500, "unit": "mL"}, + "fields": {"Manufacturer": "Sigma-Aldrich", "Lot number": "SHBM1234", "Purity": 99.9, + "State": "Liquid"}} + + +@pytest.fixture +def fake_image(monkeypatch): + monkeypatch.setattr(add_bottle, "generate_image", lambda smiles: f"/tmp/{smiles}.png") + + +class TestAddBottle: + def test_creates_links_places_and_draws_in_order(self, fake_image): + rm = FakeBottleRM() + assert add_bottle.create_bottle(rm, THF_BOTTLE) == {"id": 640, "problems": []} + assert [c[0] for c in rm.calls] == ["create", "change", "link", "storage", "upload"] + assert rm.calls[0] == ("create", 2) + assert rm.calls[2] == ("link", 640, 77) + assert rm.calls[3] == ("storage", 640, 6, 500, "mL") + assert rm.calls[4] == ("upload", 640, "/tmp/C1CCOC1.png") + + def test_bottle_keeps_only_its_own_fields(self, fake_image): + rm = FakeBottleRM() + add_bottle.create_bottle(rm, THF_BOTTLE) + body = rm.calls[1][2] + assert body["title"] == "Tetrahydrofuran" + metadata = json.loads(body["metadata"]) + assert sorted(metadata["extra_fields"]) == ["CAS", "Lot number", "Manufacturer", "Purity", "Received", "State"] + assert metadata["extra_fields"]["Purity"] == {**_field(7, "number", unit="%"), "value": "99.9"} + assert metadata["extra_fields"]["CAS"]["value"] == "" + assert metadata["elabftw"] == {"display_main_text": True} + + def test_polymer_without_compounds_keeps_its_chemistry_fields(self, fake_image): + rm = FakeBottleRM() + add_bottle.create_bottle(rm, {"category": 3, "title": "P3HT", "fields": {"Mw": 50000, "SMILES": "*c1ccsc1*"}, + "storage": {"place_id": 21, "amount": 2, "unit": "g"}}) + metadata = json.loads(rm.calls[1][2]["metadata"]) + assert sorted(metadata["extra_fields"]) == ["Full name", "Mw", "SMILES"] + assert [c[0] for c in rm.calls] == ["create", "change", "storage"] # no link, no image + + def test_micro_sign_becomes_greek_mu(self, fake_image): + rm = FakeBottleRM() + add_bottle.create_bottle(rm, {**THF_BOTTLE, "storage": {"place_id": 6, "amount": 250, "unit": "\u00b5L"}}) + assert rm.calls[3][-1] == "\u03bcL" + + @pytest.mark.parametrize("change, message", [ + ({"category": None}, "Choose a category"), + ({"category": 99}, "no category #99"), + ({"title": " "}, "needs a name"), + ({"compounds": [999]}, "no compound #999"), + ({"compounds": "77"}, "list of compound ids"), + ({"storage": None}, "where the bottle is kept"), + ({"storage": {"place_id": 999, "amount": 1, "unit": "g"}}, "no storage place #999"), + ({"storage": {"place_id": 6, "amount": -1, "unit": "g"}}, "0 or more"), + ({"storage": {"place_id": 6, "amount": "500", "unit": "mL"}}, "must be a number"), + ({"storage": {"place_id": 6, "amount": 5, "unit": "gallons"}}, "Unknown unit"), + ({"fields": {"Room": "3057"}}, "not a field"), + ({"fields": {"SMILES": "CCO"}}, "not a field"), # lives in the compound now + ({"fields": {"Purity": [99]}}, "text or a number"), + ]) + def test_bad_request_creates_nothing(self, fake_image, change, message): + rm = FakeBottleRM() + with pytest.raises(add_bottle.InvalidBottle, match=message): + add_bottle.create_bottle(rm, {**THF_BOTTLE, **change}) + assert rm.calls == [] + + def test_later_failures_are_reported_and_the_rest_still_runs(self, fake_image): + rm = FakeBottleRM(fail={"link", "storage"}) + result = add_bottle.create_bottle(rm, THF_BOTTLE) + assert result["id"] == 640 + assert [p.split(" failed")[0] for p in result["problems"]] == [ + "Linking compound #77", "Putting it in its storage place"] + assert [c[0] for c in rm.calls] == ["create", "change", "link", "storage", "upload"] + + def test_units_offered_per_state_are_elabftw_units(self): + for units in add_bottle.UNITS_BY_STATE.values(): + assert set(units) <= set(add_bottle.UNITS) + + +class TestCreateResourceRoute: + def test_returns_201_with_id_url_and_problems(self, client, monkeypatch, fake_image): + monkeypatch.setattr(interface, "rm", lambda: FakeBottleRM()) + resp = client.post("/resources", json=THF_BOTTLE) + assert resp.status_code == 201 + assert resp.get_json()["id"] == 640 and resp.get_json()["problems"] == [] + assert resp.get_json()["url"].endswith("640") + + def test_bad_request_is_400(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeBottleRM()) + resp = client.post("/resources", json={**THF_BOTTLE, "title": ""}) + assert resp.status_code == 400 and "needs a name" in resp.get_json()["error"] + + def test_failed_create_is_500(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeBottleRM(fail={"create"})) + resp = client.post("/resources", json=THF_BOTTLE) + assert resp.status_code == 500 and "not created" in resp.get_json()["error"] + + def test_needs_an_api_key(self, client): + assert client.post("/resources", json=THF_BOTTLE).status_code == 401 + + class TestCreateItemFromTemplate: """New resources start from their template, so they get its category and default status (items created from a bare category had no status).""" diff --git a/web/interface.py b/web/interface.py index 5c79b02..a577e9e 100644 --- a/web/interface.py +++ b/web/interface.py @@ -5,6 +5,7 @@ from flask_cors import cross_origin from requests import HTTPError +import eln_common.add_bottle as add_bottle import eln_common.compound_import as compound_import import eln_common.config as config import eln_common.pubchem as pubchem @@ -519,6 +520,30 @@ def add_option(): return jsonify({"status": "error", "error": str(e)}), 400 +@interface_bp.route('/resources', methods=['POST']) +@cross_origin(origins="http://localhost:8000") +def create_resource(): + """Adds a bottle linked to its compound(s) and put in a storage place: + {category, title, compounds: [ids], storage: {place_id, amount, unit}, fields: {name: value}}. + 400 if the request is incomplete (nothing created). 201 with {id, url, problems} + once the bottle exists; problems lists any later step that failed.""" + data = request.get_json(force=True, silent=True) + if not isinstance(data, dict): + return jsonify({"status": "error", "error": "Expected a JSON object"}), 400 + try: + rmn = rm() + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 401 + try: + result = add_bottle.create_bottle(rmn, data) + except add_bottle.InvalidBottle as e: + return jsonify({"status": "error", "error": str(e)}), 400 + except Exception as e: + return jsonify({"status": "error", "error": f"The bottle was not created: {e}"}), 500 + result["url"] = config.item_web_url(result["id"]) + return jsonify(result), 201 + + @interface_bp.route('/add_resource', methods=['POST']) @cross_origin(origins="http://localhost:8000") def add_resource(): From a0ee26105fff1d71a2fc9d52cf795ce1ac033a1a Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 14:24:27 -0400 Subject: [PATCH 08/26] Tag new bottles with their compounds' hazards and peroxide class Right after the compounds are linked, the bottle gets one tag per GHS hazard flag set on them (Flammable, Corrosive, Health hazard...) and, when a compound's CAS or InChIKey is on the EPA CompTox peroxide-former lists in automations/peroxides, "Peroxide former: A-D". add_tag now raises on failure so a tag that could not be added is reported in "problems". Co-Authored-By: Claude Opus 5.5 --- eln_common/add_bottle.py | 23 +++++++++--- eln_common/bottle_tags.py | 73 ++++++++++++++++++++++++++++++++++++ eln_common/resourcemanage.py | 3 +- tests/test_routes_offline.py | 63 +++++++++++++++++++++++++------ 4 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 eln_common/bottle_tags.py diff --git a/eln_common/add_bottle.py b/eln_common/add_bottle.py index db2f0db..df49fcc 100644 --- a/eln_common/add_bottle.py +++ b/eln_common/add_bottle.py @@ -9,7 +9,7 @@ extra fields. create_bottle() does it in order: create from the template -> title and fields -> -link compounds -> put it in its place -> structure image. Once the bottle exists +link compounds -> hazard and peroxide tags -> put it in its place -> structure image. Once the bottle exists it is never deleted: a later step that fails is reported back as a problem, so the user can finish it in eLabFTW. """ @@ -18,6 +18,7 @@ from typing import Any from automations.image_generator import generate_image +from eln_common.bottle_tags import tags_for from eln_common.resourcemanage import Resource_Manager # eLabFTW's fixed list of storage units (μ is the Greek letter mu, U+03BC) @@ -108,7 +109,7 @@ def check_request(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: return {"category": category, "title": title.strip(), "compounds": compounds, "place_id": place_id, "amount": amount, "unit": unit, "fields": fields, - "metadata": metadata, "smiles": live_compounds[compounds[0]].get("smiles") if compounds else None} + "metadata": metadata, "compound_data": [live_compounds[c] for c in compounds]} def bottle_metadata(metadata: dict[str, Any], fields: dict[str, Any], has_compounds: bool) -> str: @@ -126,7 +127,8 @@ def create_bottle(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: "storage": {"place_id": 6, "amount": 500, "unit": "mL"}, "fields": {"Manufacturer": "Sigma-Aldrich", "Lot number": "SHBM1234"}} :raises InvalidBottle: nothing was created. - :return: {"id": new bottle id, "problems": [steps that failed after it was created]} + :return: {"id": new bottle id, "tags": tags added, "problems": [steps that failed + after it was created]} """ request = check_request(rm, data) has_compounds = bool(request["compounds"]) @@ -148,15 +150,24 @@ def create_bottle(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: except Exception as e: problems.append(f"Linking compound #{compound_id} failed: {e}") + tags = [] + for tag in tags_for(request["compound_data"]): + try: + rm.add_tag(item_id, tag) + tags.append(tag) + except Exception as e: + problems.append(f"Adding the tag '{tag}' failed: {e}") + try: rm.add_to_storage(item_id, request["place_id"], request["amount"], request["unit"]) except Exception as e: problems.append(f"Putting it in its storage place failed: {e}") - if request["smiles"]: + smiles = request["compound_data"][0].get("smiles") if request["compound_data"] else None + if smiles: try: - rm.upload_file(item_id, generate_image(request["smiles"])) + rm.upload_file(item_id, generate_image(smiles)) except Exception as e: problems.append(f"Adding the structure image failed: {e}") - return {"id": item_id, "problems": problems} + return {"id": item_id, "tags": tags, "problems": problems} diff --git a/eln_common/bottle_tags.py b/eln_common/bottle_tags.py new file mode 100644 index 0000000..b28739c --- /dev/null +++ b/eln_common/bottle_tags.py @@ -0,0 +1,73 @@ +""" +Tags a new bottle with the hazards and peroxide class of its compound(s), so they +show on the bottle and can be searched ("every bottle tagged Peroxide former: B"). + + - Hazard tags come from the compound's GHS flags (is_flammable...). + - The peroxide tag comes from the EPA CompTox peroxide-former lists A-D kept in + automations/peroxides (the files the peroxide reminder uses), matched by the + compound's CAS number or InChIKey. + +Tags are added when the bottle is created; bottles made earlier are not updated. +""" + +import csv +import functools +import re +from pathlib import Path +from typing import Any + +PEROXIDE_DIR = Path(__file__).resolve().parent.parent / "automations" / "peroxides" + +# eLabFTW compound flag -> tag on the bottle +HAZARD_TAGS = { + "is_explosive": "Explosive", + "is_flammable": "Flammable", + "is_oxidising": "Oxidiser", + "is_gas_under_pressure": "Gas under pressure", + "is_corrosive": "Corrosive", + "is_toxic": "Toxic", + "is_hazardous2health": "Health hazard", + "is_serious_health_hazard": "Serious health hazard", + "is_hazardous2env": "Environmental hazard", +} + + +@functools.cache +def peroxide_classes() -> dict[str, str]: + """CAS number and InChIKey -> peroxide-former class (A-D), read once from the EPA lists.""" + classes: dict[str, str] = {} + for path in sorted(PEROXIDE_DIR.glob("Chemical List PEROXIDES*.csv")): + match = re.search(r"PEROXIDES([A-D])-", path.name) + if not match: + continue + # utf-8-sig: the EPA exports start with a byte-order mark + with path.open(encoding="utf-8-sig", newline="") as f: + for row in csv.DictReader(f): + for key in (row.get("CASRN"), row.get("INCHIKEY")): + if key and key.strip(): + # lists are read A to D, so a chemical on two lists keeps the stricter class + classes.setdefault(key.strip(), match.group(1)) + return classes + + +def peroxide_class(compound: dict[str, Any]) -> str | None: + """The compound's peroxide-former class, or None if it is on no list.""" + classes = peroxide_classes() + for key in (compound.get("cas_number"), compound.get("inchi_key")): + if key and key in classes: + return classes[key] + return None + + +def tags_for(compounds: list[dict[str, Any]]) -> list[str]: + """The tags for a bottle holding these compounds, without repeats.""" + tags: list[str] = [] + for compound in compounds: + for flag, tag in HAZARD_TAGS.items(): + if compound.get(flag) and tag not in tags: + tags.append(tag) + for compound in compounds: + clss = peroxide_class(compound) + if clss and f"Peroxide former: {clss}" not in tags: + tags.append(f"Peroxide former: {clss}") + return tags diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index 4bf1238..0c28bf0 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -214,8 +214,7 @@ def add_tag(self, item_id: int, tag: str): Adds a tag to an item in the ELN with the given item ID and tag. Take care to use correct capitalization/whitespace :param int item_id: The ID of the item to be tagged. :param str tag: The tag to be added to the item.""" - url = config.URL + "/items/" + str(item_id) + "/tags/" - requests.post(url, headers=self.header, json={"tag": tag}) + self.post_url(f"/items/{item_id}/tags", json={"tag": tag}).raise_for_status() def delete_upload( self, id:int, upload_id:int, resource_type:str="items" diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index bbeac4e..3c57525 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -9,6 +9,7 @@ import pytest import eln_common.add_bottle as add_bottle +import eln_common.bottle_tags as bottle_tags import eln_common.compound_import as compound_import import eln_common.pubchem as pubchem import eln_common.storage_places as storage_places @@ -832,7 +833,7 @@ def get_items_type(self, id): return templates[id] def get_compounds(self): - return [{"id": 77, "name": "Tetrahydrofuran", "smiles": "C1CCOC1"}, {"id": 72, "name": "Acetone"}] + return [THF_COMPOUND, {"id": 72, "name": "Acetone", "cas_number": "67-64-1", "is_flammable": 1}] def get_storage_units(self): return STORAGE @@ -853,7 +854,14 @@ def add_to_storage(self, item_id, storage_id, amount, unit): def upload_file(self, id, path): self._record("upload", id, path) + def add_tag(self, item_id, tag): + self._record("tag", item_id, tag) + +THF_COMPOUND = {"id": 77, "name": "Tetrahydrofuran", "smiles": "C1CCOC1", "cas_number": "109-99-9", + "inchi_key": "WYURNTSHIVDZCO-UHFFFAOYSA-N", "is_flammable": 1, "is_hazardous2health": 1, + "is_serious_health_hazard": 1, "is_toxic": 0} +THF_TAGS = ["Flammable", "Health hazard", "Serious health hazard", "Peroxide former: B"] THF_BOTTLE = {"category": 2, "title": " Tetrahydrofuran ", "compounds": [77], "storage": {"place_id": 6, "amount": 500, "unit": "mL"}, "fields": {"Manufacturer": "Sigma-Aldrich", "Lot number": "SHBM1234", "Purity": 99.9, @@ -868,12 +876,13 @@ def fake_image(monkeypatch): class TestAddBottle: def test_creates_links_places_and_draws_in_order(self, fake_image): rm = FakeBottleRM() - assert add_bottle.create_bottle(rm, THF_BOTTLE) == {"id": 640, "problems": []} - assert [c[0] for c in rm.calls] == ["create", "change", "link", "storage", "upload"] + assert add_bottle.create_bottle(rm, THF_BOTTLE) == {"id": 640, "tags": THF_TAGS, "problems": []} + assert [c[0] for c in rm.calls] == ["create", "change", "link"] + ["tag"] * 4 + ["storage", "upload"] assert rm.calls[0] == ("create", 2) assert rm.calls[2] == ("link", 640, 77) - assert rm.calls[3] == ("storage", 640, 6, 500, "mL") - assert rm.calls[4] == ("upload", 640, "/tmp/C1CCOC1.png") + assert [c[2] for c in rm.calls if c[0] == "tag"] == THF_TAGS + assert rm.calls[-2] == ("storage", 640, 6, 500, "mL") + assert rm.calls[-1] == ("upload", 640, "/tmp/C1CCOC1.png") def test_bottle_keeps_only_its_own_fields(self, fake_image): rm = FakeBottleRM() @@ -892,12 +901,12 @@ def test_polymer_without_compounds_keeps_its_chemistry_fields(self, fake_image): "storage": {"place_id": 21, "amount": 2, "unit": "g"}}) metadata = json.loads(rm.calls[1][2]["metadata"]) assert sorted(metadata["extra_fields"]) == ["Full name", "Mw", "SMILES"] - assert [c[0] for c in rm.calls] == ["create", "change", "storage"] # no link, no image + assert [c[0] for c in rm.calls] == ["create", "change", "storage"] # no link, tags or image def test_micro_sign_becomes_greek_mu(self, fake_image): rm = FakeBottleRM() add_bottle.create_bottle(rm, {**THF_BOTTLE, "storage": {"place_id": 6, "amount": 250, "unit": "\u00b5L"}}) - assert rm.calls[3][-1] == "\u03bcL" + assert [c for c in rm.calls if c[0] == "storage"][0][-1] == "\u03bcL" @pytest.mark.parametrize("change, message", [ ({"category": None}, "Choose a category"), @@ -921,24 +930,56 @@ def test_bad_request_creates_nothing(self, fake_image, change, message): assert rm.calls == [] def test_later_failures_are_reported_and_the_rest_still_runs(self, fake_image): - rm = FakeBottleRM(fail={"link", "storage"}) + rm = FakeBottleRM(fail={"link", "tag", "storage"}) result = add_bottle.create_bottle(rm, THF_BOTTLE) - assert result["id"] == 640 + assert result["id"] == 640 and result["tags"] == [] assert [p.split(" failed")[0] for p in result["problems"]] == [ - "Linking compound #77", "Putting it in its storage place"] - assert [c[0] for c in rm.calls] == ["create", "change", "link", "storage", "upload"] + "Linking compound #77"] + [f"Adding the tag '{t}'" for t in THF_TAGS] + [ + "Putting it in its storage place"] + assert [c[0] for c in rm.calls][-2:] == ["storage", "upload"] + + def test_solution_gets_tags_of_both_compounds_once(self, fake_image): + rm = FakeBottleRM() + result = add_bottle.create_bottle(rm, {**THF_BOTTLE, "compounds": [72, 77]}) + assert result["tags"] == THF_TAGS # Flammable from both, listed once + assert [c for c in rm.calls if c[0] == "link"] == [("link", 640, 72), ("link", 640, 77)] def test_units_offered_per_state_are_elabftw_units(self): for units in add_bottle.UNITS_BY_STATE.values(): assert set(units) <= set(add_bottle.UNITS) +class TestBottleTags: + """Hazard tags from the compound's flags; peroxide class from the real EPA lists.""" + + def test_all_four_lists_are_read(self): + assert set(bottle_tags.peroxide_classes().values()) == {"A", "B", "C", "D"} + + @pytest.mark.parametrize("compound, expected", [ + ({"cas_number": "109-99-9"}, "B"), # THF + ({"cas_number": "60-29-7"}, "B"), # diethyl ether + ({"cas_number": "80-62-6"}, "C"), # methyl methacrylate + ({"cas_number": "0-00-0", "inchi_key": "WYURNTSHIVDZCO-UHFFFAOYSA-N"}, "B"), # THF by structure + ({"cas_number": "67-64-1"}, None), # acetone + ({}, None), + ]) + def test_peroxide_class(self, compound, expected): + assert bottle_tags.peroxide_class(compound) == expected + + def test_tags_for_thf(self): + assert bottle_tags.tags_for([THF_COMPOUND]) == THF_TAGS + + def test_no_compounds_no_tags(self): + assert bottle_tags.tags_for([]) == [] + + class TestCreateResourceRoute: def test_returns_201_with_id_url_and_problems(self, client, monkeypatch, fake_image): monkeypatch.setattr(interface, "rm", lambda: FakeBottleRM()) resp = client.post("/resources", json=THF_BOTTLE) assert resp.status_code == 201 assert resp.get_json()["id"] == 640 and resp.get_json()["problems"] == [] + assert resp.get_json()["tags"] == THF_TAGS assert resp.get_json()["url"].endswith("640") def test_bad_request_is_400(self, client, monkeypatch): From 2dcf1551197b072cac3276c767e19f6ed276df84 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 14:27:23 -0400 Subject: [PATCH 09/26] Warn before adding a bottle that is already in the ELN Before creating, POST /resources looks for current bottles with the same lot number and manufacturer (ignoring case) holding the same chemical: a shared linked compound, or the same CAS field for older, unlinked bottles. If any match, it answers 409 with links to them and creates nothing; confirm_duplicate: true adds the bottle anyway. No lot number, no check. Co-Authored-By: Claude Opus 5.5 --- eln_common/add_bottle.py | 55 ++++++++++++++++++++++++++++++- eln_common/resourcemanage.py | 12 +++++++ tests/test_routes_offline.py | 63 +++++++++++++++++++++++++++++++++++- web/interface.py | 9 ++++-- 4 files changed, 135 insertions(+), 4 deletions(-) diff --git a/eln_common/add_bottle.py b/eln_common/add_bottle.py index df49fcc..0f30eff 100644 --- a/eln_common/add_bottle.py +++ b/eln_common/add_bottle.py @@ -8,7 +8,8 @@ Only the facts about this one bottle (supplier, lot, purity, dates...) stay as extra fields. -create_bottle() does it in order: create from the template -> title and fields -> +create_bottle() first warns about a likely duplicate (same lot number, supplier and +chemical as a current bottle). Then, in order: create from the template -> title and fields -> link compounds -> hazard and peroxide tags -> put it in its place -> structure image. Once the bottle exists it is never deleted: a later step that fails is reported back as a problem, so the user can finish it in eLabFTW. @@ -41,6 +42,15 @@ class InvalidBottle(Exception): """The request can't make a bottle (missing or unknown value); nothing was created.""" +class DuplicateBottle(Exception): + """Current bottles look like the same physical bottle; nothing was created.""" + + def __init__(self, bottles: list[dict[str, Any]]): + self.bottles = bottles + super().__init__("Looks like a bottle that is already in the ELN: " + + ", ".join(f"#{b['id']} {b['title']}" for b in bottles)) + + def bottle_fields(extra_fields: dict[str, Any], has_compounds: bool) -> dict[str, Any]: """The template's extra fields that stay on the bottle itself.""" moved = STORAGE_FIELDS | (COMPOUND_FIELDS if has_compounds else set()) @@ -112,6 +122,43 @@ def check_request(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: "metadata": metadata, "compound_data": [live_compounds[c] for c in compounds]} +def find_duplicates(rm: Resource_Manager, request: dict[str, Any]) -> list[dict[str, Any]]: + """ + Current bottles that are probably this one entered again: the same lot number and + manufacturer (when both are filled in) holding the same chemical (a shared linked + compound, or the same CAS field, for older bottles that are not linked). + No lot number, no check: different bottles of the same chemical are normal. + :return: [{id, title}] of the matching bottles. + """ + fields = request["fields"] + lot = str(fields.get("Lot number") or "").strip() + if not lot: + return [] + manufacturer = str(fields.get("Manufacturer") or "").strip().casefold() + cas_numbers = {str(fields.get("CAS") or "").strip()} + cas_numbers |= {c.get("cas_number") for c in request["compound_data"]} + cas_numbers -= {"", None} + + found = [] + for item in rm.search_items_by_field("Lot number", lot): + extra = json.loads(item.get("metadata") or "{}").get("extra_fields", {}) + + def value(name: str) -> str: + return str(extra.get(name, {}).get("value") or "").strip() + + if value("Lot number").casefold() != lot.casefold(): + continue + if manufacturer and value("Manufacturer") and value("Manufacturer").casefold() != manufacturer: + continue + same_chemical = value("CAS") in cas_numbers + if not same_chemical and request["compounds"]: + linked = rm.get_item(item["id"]).get("compounds_links") or [] + same_chemical = any(c["id"] in request["compounds"] for c in linked) + if same_chemical: + found.append({"id": item["id"], "title": item["title"]}) + return found + + def bottle_metadata(metadata: dict[str, Any], fields: dict[str, Any], has_compounds: bool) -> str: """The template's metadata with only the bottle's own fields, filled with the user's values.""" kept = bottle_fields(metadata.get("extra_fields", {}), has_compounds) @@ -126,11 +173,17 @@ def create_bottle(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: {"category": 2, "title": "Tetrahydrofuran", "compounds": [77], "storage": {"place_id": 6, "amount": 500, "unit": "mL"}, "fields": {"Manufacturer": "Sigma-Aldrich", "Lot number": "SHBM1234"}} + With "confirm_duplicate": true it is created even if it looks like an existing bottle. :raises InvalidBottle: nothing was created. + :raises DuplicateBottle: nothing was created; the user can confirm and send again. :return: {"id": new bottle id, "tags": tags added, "problems": [steps that failed after it was created]} """ request = check_request(rm, data) + if data.get("confirm_duplicate") is not True: + duplicates = find_duplicates(rm, request) + if duplicates: + raise DuplicateBottle(duplicates) has_compounds = bool(request["compounds"]) item_id = rm.create_item_from_template(request["category"]) diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index 0c28bf0..3bf7da1 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -348,6 +348,18 @@ def search_items(self, query: str = "") -> list[dict[str, Any]]: response.raise_for_status() return response.json() + def search_items_by_field(self, field: str, value: str) -> list[dict[str, Any]]: + """ + Current resources (not archived or deleted) whose extra field contains the value, + ignoring case. Matches are "contains", so callers compare the value exactly. + """ + # double quotes would end the search term early + query = f'extrafield:"{field}":"{value.replace(chr(34), "")}"' + response = requests.get(config.URL + "/items", headers=self.header, + params={"q": query, "limit": 100}) + response.raise_for_status() + return response.json() + def get_uploaded_files(self, id:int, resource_type:str="items") -> list: """ Gets a list of uploaded files in the ELN with the given ID. diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 3c57525..04d8a1d 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -817,9 +817,11 @@ def _field(position, type_="text", **extra): class FakeBottleRM: """Stand-in for the calls create_bottle makes; records them in order.""" - def __init__(self, fail=()): + def __init__(self, fail=(), existing=()): self.calls = [] self.fail = set(fail) + self.existing = list(existing) # current bottles, as the search returns them + self.searches = [] def _record(self, name, *args): self.calls.append((name, *args)) @@ -857,6 +859,21 @@ def upload_file(self, id, path): def add_tag(self, item_id, tag): self._record("tag", item_id, tag) + def search_items_by_field(self, field, value): + self.searches.append((field, value)) + return [b for b in self.existing + if value.lower() in json.loads(b["metadata"])["extra_fields"].get(field, {}).get("value", "").lower()] + + def get_item(self, id): + return next(b for b in self.existing if b["id"] == id) + + +def existing_bottle(id, title, links=(), **values): + """A current bottle as eLabFTW's search returns it (get_item also gives its compound links).""" + fields = {name.replace("_", " "): {"value": v} for name, v in values.items()} + return {"id": id, "title": title, "metadata": json.dumps({"extra_fields": fields}), + "compounds_links": [{"id": c} for c in links]} + THF_COMPOUND = {"id": 77, "name": "Tetrahydrofuran", "smiles": "C1CCOC1", "cas_number": "109-99-9", "inchi_key": "WYURNTSHIVDZCO-UHFFFAOYSA-N", "is_flammable": 1, "is_hazardous2health": 1, @@ -949,6 +966,50 @@ def test_units_offered_per_state_are_elabftw_units(self): assert set(units) <= set(add_bottle.UNITS) +class TestDuplicateBottles: + """Same lot + manufacturer + chemical as a current bottle: warn before creating.""" + + def test_same_lot_supplier_and_cas_is_refused(self, fake_image): + old = existing_bottle(522, "THF (old)", Lot_number="shbm1234", Manufacturer="sigma-aldrich", CAS="109-99-9") + rm = FakeBottleRM(existing=[old]) + with pytest.raises(add_bottle.DuplicateBottle) as e: + add_bottle.create_bottle(rm, THF_BOTTLE) + assert e.value.bottles == [{"id": 522, "title": "THF (old)"}] + assert rm.calls == [] and rm.searches == [("Lot number", "SHBM1234")] + + def test_linked_compound_counts_as_same_chemical(self, fake_image): + old = existing_bottle(600, "Tetrahydrofuran", links=[77], Lot_number="SHBM1234") + with pytest.raises(add_bottle.DuplicateBottle): + add_bottle.create_bottle(FakeBottleRM(existing=[old]), THF_BOTTLE) + + def test_confirmed_duplicate_is_created(self, fake_image): + old = existing_bottle(522, "THF (old)", Lot_number="SHBM1234", CAS="109-99-9") + rm = FakeBottleRM(existing=[old]) + assert add_bottle.create_bottle(rm, {**THF_BOTTLE, "confirm_duplicate": True})["id"] == 640 + + @pytest.mark.parametrize("old", [ + existing_bottle(1, "THF other supplier", Lot_number="SHBM1234", Manufacturer="Fisher", CAS="109-99-9"), + existing_bottle(2, "Other chemical", Lot_number="SHBM1234", CAS="67-64-1"), + existing_bottle(3, "Longer lot", Lot_number="SHBM12345", CAS="109-99-9"), + ]) + def test_not_the_same_bottle(self, fake_image, old): + assert add_bottle.create_bottle(FakeBottleRM(existing=[old]), THF_BOTTLE)["id"] == 640 + + def test_no_lot_number_no_check(self, fake_image): + rm = FakeBottleRM() + fields = {k: v for k, v in THF_BOTTLE["fields"].items() if k != "Lot number"} + add_bottle.create_bottle(rm, {**THF_BOTTLE, "fields": fields}) + assert rm.searches == [] + + def test_route_answers_409_with_links(self, client, monkeypatch, fake_image): + old = existing_bottle(522, "THF (old)", Lot_number="SHBM1234", CAS="109-99-9") + monkeypatch.setattr(interface, "rm", lambda: FakeBottleRM(existing=[old])) + resp = client.post("/resources", json=THF_BOTTLE) + assert resp.status_code == 409 + [dup] = resp.get_json()["duplicates"] + assert dup["id"] == 522 and dup["url"].endswith("522") + + class TestBottleTags: """Hazard tags from the compound's flags; peroxide class from the real EPA lists.""" diff --git a/web/interface.py b/web/interface.py index a577e9e..bf62821 100644 --- a/web/interface.py +++ b/web/interface.py @@ -525,8 +525,10 @@ def add_option(): def create_resource(): """Adds a bottle linked to its compound(s) and put in a storage place: {category, title, compounds: [ids], storage: {place_id, amount, unit}, fields: {name: value}}. - 400 if the request is incomplete (nothing created). 201 with {id, url, problems} - once the bottle exists; problems lists any later step that failed.""" + 400 if the request is incomplete (nothing created). 409 with "duplicates" when a + current bottle has the same lot, manufacturer and chemical; send again with + confirm_duplicate: true to add it anyway. 201 with {id, url, tags, problems} once + the bottle exists; problems lists any later step that failed.""" data = request.get_json(force=True, silent=True) if not isinstance(data, dict): return jsonify({"status": "error", "error": "Expected a JSON object"}), 400 @@ -538,6 +540,9 @@ def create_resource(): result = add_bottle.create_bottle(rmn, data) except add_bottle.InvalidBottle as e: return jsonify({"status": "error", "error": str(e)}), 400 + except add_bottle.DuplicateBottle as e: + return jsonify({"status": "error", "error": str(e), "duplicates": [ + {**b, "url": config.item_web_url(b["id"])} for b in e.bottles]}), 409 except Exception as e: return jsonify({"status": "error", "error": f"The bottle was not created: {e}"}), 500 result["url"] = config.item_web_url(result["id"]) From 51b18de2ed438841476dce3036575fcd5f0afc7a Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 14:39:14 -0400 Subject: [PATCH 10/26] Ask about the label instead of blocking same-lot bottles; add several at once Bottles with the same lot, supplier and chemical are often different bottles of one order, so a match is no longer treated as a duplicate: the 409 lists the bottles from that lot and asks whether this bottle already has an ELN label (confirm_same_lot: true adds it). POST /resources also takes "count" (1-20) to add identical bottles of an order in one go, each with its own id; the reply is now {bottles: [{id, url, tags, problems}], problems}. Co-Authored-By: Claude Opus 5.5 --- eln_common/add_bottle.py | 85 ++++++++++++++++++++++++------------ tests/test_routes_offline.py | 78 +++++++++++++++++++++++---------- web/interface.py | 21 ++++----- 3 files changed, 124 insertions(+), 60 deletions(-) diff --git a/eln_common/add_bottle.py b/eln_common/add_bottle.py index 0f30eff..95e0476 100644 --- a/eln_common/add_bottle.py +++ b/eln_common/add_bottle.py @@ -8,11 +8,14 @@ Only the facts about this one bottle (supplier, lot, purity, dates...) stay as extra fields. -create_bottle() first warns about a likely duplicate (same lot number, supplier and -chemical as a current bottle). Then, in order: create from the template -> title and fields -> -link compounds -> hazard and peroxide tags -> put it in its place -> structure image. Once the bottle exists -it is never deleted: a later step that fails is reported back as a problem, so -the user can finish it in eLabFTW. +create_bottles() can add several identical bottles from one order at once; each +gets its own id and label. It first reminds the user when bottles from the same +lot (same lot number, supplier and chemical) are already in the ELN, since an +already-labelled bottle may be entered again by mistake. Then, for each bottle: +create from the template -> title and fields -> link compounds -> hazard and +peroxide tags -> put it in its place -> structure image. Once a bottle exists it +is never deleted: a later step that fails is reported back as a problem, so the +user can finish it in eLabFTW. """ import json @@ -31,6 +34,8 @@ "Gas": ["bar", "L", "g"], } +MAX_BOTTLES = 20 # per request, so a typo like 400 can't flood the ELN + # template fields that now live in Storage (place, amount and unit)... STORAGE_FIELDS = {"Room", "Location", "Quantity"} # ...and in the linked compounds. Bottles without compounds (polymers) keep these. @@ -42,13 +47,17 @@ class InvalidBottle(Exception): """The request can't make a bottle (missing or unknown value); nothing was created.""" -class DuplicateBottle(Exception): - """Current bottles look like the same physical bottle; nothing was created.""" +class SameLotBottles(Exception): + """Bottles from the same lot are already in the ELN; nothing was created yet. + They may be other bottles of the same order, or this bottle entered before.""" + + question = ("Does this bottle already have an ELN label? If yes, it is already in the ELN: " + "don't add it again. If it is another bottle from the same order, add it.") def __init__(self, bottles: list[dict[str, Any]]): self.bottles = bottles - super().__init__("Looks like a bottle that is already in the ELN: " - + ", ".join(f"#{b['id']} {b['title']}" for b in bottles)) + super().__init__(f"{len(bottles)} bottle{'s' if len(bottles) > 1 else ''} from this lot " + "already in the ELN: " + ", ".join(f"#{b['id']} {b['title']}" for b in bottles)) def bottle_fields(extra_fields: dict[str, Any], has_compounds: bool) -> dict[str, Any]: @@ -106,6 +115,10 @@ def check_request(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: raise InvalidBottle("The amount must be a number, 0 or more") unit = clean_unit(storage.get("unit")) + count = data.get("count", 1) + if not isinstance(count, int) or isinstance(count, bool) or not 1 <= count <= MAX_BOTTLES: + raise InvalidBottle(f"The number of bottles must be between 1 and {MAX_BOTTLES}") + metadata = json.loads(template.get("metadata") or "{}") allowed = bottle_fields(metadata.get("extra_fields", {}), bool(compounds)) fields = data.get("fields") or {} @@ -117,17 +130,16 @@ def check_request(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: if value is not None and not isinstance(value, (str, int, float)): raise InvalidBottle(f"'{name}' must be text or a number") - return {"category": category, "title": title.strip(), "compounds": compounds, + return {"category": category, "title": title.strip(), "compounds": compounds, "count": count, "place_id": place_id, "amount": amount, "unit": unit, "fields": fields, "metadata": metadata, "compound_data": [live_compounds[c] for c in compounds]} -def find_duplicates(rm: Resource_Manager, request: dict[str, Any]) -> list[dict[str, Any]]: +def find_same_lot(rm: Resource_Manager, request: dict[str, Any]) -> list[dict[str, Any]]: """ - Current bottles that are probably this one entered again: the same lot number and - manufacturer (when both are filled in) holding the same chemical (a shared linked - compound, or the same CAS field, for older bottles that are not linked). - No lot number, no check: different bottles of the same chemical are normal. + Current bottles from the same lot: the same lot number and manufacturer (when both + are filled in) holding the same chemical (a shared linked compound, or the same CAS + field, for older bottles that are not linked). No lot number, no check. :return: [{id, title}] of the matching bottles. """ fields = request["fields"] @@ -167,25 +179,44 @@ def bottle_metadata(metadata: dict[str, Any], fields: dict[str, Any], has_compou return json.dumps({**metadata, "extra_fields": kept}) -def create_bottle(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: +def create_bottles(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: """ - Creates a bottle from a request like - {"category": 2, "title": "Tetrahydrofuran", "compounds": [77], + Creates one or more identical bottles from a request like + {"category": 2, "title": "Tetrahydrofuran", "compounds": [77], "count": 4, "storage": {"place_id": 6, "amount": 500, "unit": "mL"}, "fields": {"Manufacturer": "Sigma-Aldrich", "Lot number": "SHBM1234"}} - With "confirm_duplicate": true it is created even if it looks like an existing bottle. + With "confirm_same_lot": true it goes ahead even when bottles from that lot exist. :raises InvalidBottle: nothing was created. - :raises DuplicateBottle: nothing was created; the user can confirm and send again. - :return: {"id": new bottle id, "tags": tags added, "problems": [steps that failed - after it was created]} + :raises SameLotBottles: nothing was created; the user can confirm and send again. + :raises Exception: the first bottle could not be created (nothing was created). + :return: {"bottles": [{"id", "tags", "problems"}, ...], "problems": [...]}; the + outer problems say if fewer bottles than asked for could be created. """ request = check_request(rm, data) - if data.get("confirm_duplicate") is not True: - duplicates = find_duplicates(rm, request) - if duplicates: - raise DuplicateBottle(duplicates) - has_compounds = bool(request["compounds"]) + if data.get("confirm_same_lot") is not True: + same_lot = find_same_lot(rm, request) + if same_lot: + raise SameLotBottles(same_lot) + bottles = [] + for _ in range(request["count"]): + try: + bottles.append(create_one(rm, request)) + except Exception as e: + if not bottles: + raise + return {"bottles": bottles, "problems": [ + f"Only {len(bottles)} of {request['count']} bottles were created: {e}"]} + return {"bottles": bottles, "problems": []} + + +def create_one(rm: Resource_Manager, request: dict[str, Any]) -> dict[str, Any]: + """ + Creates one bottle from a checked request (see check_request). + :raises Exception: the bottle itself could not be created. + :return: {"id", "tags": tags added, "problems": [steps that failed after it was created]} + """ + has_compounds = bool(request["compounds"]) item_id = rm.create_item_from_template(request["category"]) problems = [] diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 04d8a1d..9c69c34 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -815,10 +815,13 @@ def _field(position, type_="text", **extra): class FakeBottleRM: - """Stand-in for the calls create_bottle makes; records them in order.""" + """Stand-in for the calls create_bottles makes; records them in order. Bottles get + ids 640, 641...; fail_create_at=n makes the n-th create fail.""" - def __init__(self, fail=(), existing=()): + def __init__(self, fail=(), existing=(), fail_create_at=None): self.calls = [] + self.fail_create_at = fail_create_at + self.next_id = 640 self.fail = set(fail) self.existing = list(existing) # current bottles, as the search returns them self.searches = [] @@ -842,7 +845,10 @@ def get_storage_units(self): def create_item_from_template(self, template): self._record("create", template) - return 640 + if self.next_id - 639 == self.fail_create_at: + raise RuntimeError("eLabFTW is down") + self.next_id += 1 + return self.next_id - 1 def change_item(self, id, body): self._record("change", id, body) @@ -893,7 +899,8 @@ def fake_image(monkeypatch): class TestAddBottle: def test_creates_links_places_and_draws_in_order(self, fake_image): rm = FakeBottleRM() - assert add_bottle.create_bottle(rm, THF_BOTTLE) == {"id": 640, "tags": THF_TAGS, "problems": []} + assert add_bottle.create_bottles(rm, THF_BOTTLE) == { + "bottles": [{"id": 640, "tags": THF_TAGS, "problems": []}], "problems": []} assert [c[0] for c in rm.calls] == ["create", "change", "link"] + ["tag"] * 4 + ["storage", "upload"] assert rm.calls[0] == ("create", 2) assert rm.calls[2] == ("link", 640, 77) @@ -903,7 +910,7 @@ def test_creates_links_places_and_draws_in_order(self, fake_image): def test_bottle_keeps_only_its_own_fields(self, fake_image): rm = FakeBottleRM() - add_bottle.create_bottle(rm, THF_BOTTLE) + add_bottle.create_bottles(rm, THF_BOTTLE) body = rm.calls[1][2] assert body["title"] == "Tetrahydrofuran" metadata = json.loads(body["metadata"]) @@ -914,7 +921,7 @@ def test_bottle_keeps_only_its_own_fields(self, fake_image): def test_polymer_without_compounds_keeps_its_chemistry_fields(self, fake_image): rm = FakeBottleRM() - add_bottle.create_bottle(rm, {"category": 3, "title": "P3HT", "fields": {"Mw": 50000, "SMILES": "*c1ccsc1*"}, + add_bottle.create_bottles(rm, {"category": 3, "title": "P3HT", "fields": {"Mw": 50000, "SMILES": "*c1ccsc1*"}, "storage": {"place_id": 21, "amount": 2, "unit": "g"}}) metadata = json.loads(rm.calls[1][2]["metadata"]) assert sorted(metadata["extra_fields"]) == ["Full name", "Mw", "SMILES"] @@ -922,7 +929,7 @@ def test_polymer_without_compounds_keeps_its_chemistry_fields(self, fake_image): def test_micro_sign_becomes_greek_mu(self, fake_image): rm = FakeBottleRM() - add_bottle.create_bottle(rm, {**THF_BOTTLE, "storage": {"place_id": 6, "amount": 250, "unit": "\u00b5L"}}) + add_bottle.create_bottles(rm, {**THF_BOTTLE, "storage": {"place_id": 6, "amount": 250, "unit": "\u00b5L"}}) assert [c for c in rm.calls if c[0] == "storage"][0][-1] == "\u03bcL" @pytest.mark.parametrize("change, message", [ @@ -939,16 +946,19 @@ def test_micro_sign_becomes_greek_mu(self, fake_image): ({"fields": {"Room": "3057"}}, "not a field"), ({"fields": {"SMILES": "CCO"}}, "not a field"), # lives in the compound now ({"fields": {"Purity": [99]}}, "text or a number"), + ({"count": 0}, "between 1 and 20"), + ({"count": 21}, "between 1 and 20"), + ({"count": "4"}, "between 1 and 20"), ]) def test_bad_request_creates_nothing(self, fake_image, change, message): rm = FakeBottleRM() with pytest.raises(add_bottle.InvalidBottle, match=message): - add_bottle.create_bottle(rm, {**THF_BOTTLE, **change}) + add_bottle.create_bottles(rm, {**THF_BOTTLE, **change}) assert rm.calls == [] def test_later_failures_are_reported_and_the_rest_still_runs(self, fake_image): rm = FakeBottleRM(fail={"link", "tag", "storage"}) - result = add_bottle.create_bottle(rm, THF_BOTTLE) + result = add_bottle.create_bottles(rm, THF_BOTTLE)["bottles"][0] assert result["id"] == 640 and result["tags"] == [] assert [p.split(" failed")[0] for p in result["problems"]] == [ "Linking compound #77"] + [f"Adding the tag '{t}'" for t in THF_TAGS] + [ @@ -957,7 +967,7 @@ def test_later_failures_are_reported_and_the_rest_still_runs(self, fake_image): def test_solution_gets_tags_of_both_compounds_once(self, fake_image): rm = FakeBottleRM() - result = add_bottle.create_bottle(rm, {**THF_BOTTLE, "compounds": [72, 77]}) + result = add_bottle.create_bottles(rm, {**THF_BOTTLE, "compounds": [72, 77]})["bottles"][0] assert result["tags"] == THF_TAGS # Flammable from both, listed once assert [c for c in rm.calls if c[0] == "link"] == [("link", 640, 72), ("link", 640, 77)] @@ -966,26 +976,47 @@ def test_units_offered_per_state_are_elabftw_units(self): assert set(units) <= set(add_bottle.UNITS) -class TestDuplicateBottles: - """Same lot + manufacturer + chemical as a current bottle: warn before creating.""" +class TestSeveralBottles: + def test_count_creates_that_many_bottles_with_their_own_ids(self, fake_image): + old = existing_bottle(522, "THF (old)", Lot_number="OTHER", CAS="109-99-9") + rm = FakeBottleRM(existing=[old]) + result = add_bottle.create_bottles(rm, {**THF_BOTTLE, "count": 3}) + assert [b["id"] for b in result["bottles"]] == [640, 641, 642] + assert [c[1] for c in rm.calls if c[0] == "storage"] == [640, 641, 642] + assert len(rm.searches) == 1 # the same-lot question is asked once per request + + def test_stops_and_reports_when_a_later_bottle_fails(self, fake_image): + rm = FakeBottleRM(fail_create_at=3) + result = add_bottle.create_bottles(rm, {**THF_BOTTLE, "count": 4}) + assert [b["id"] for b in result["bottles"]] == [640, 641] + assert result["problems"] == ["Only 2 of 4 bottles were created: eLabFTW is down"] + + def test_first_bottle_failing_creates_nothing(self, fake_image): + with pytest.raises(RuntimeError): + add_bottle.create_bottles(FakeBottleRM(fail_create_at=1), {**THF_BOTTLE, "count": 2}) + + +class TestSameLot: + """Same lot + manufacturer + chemical as a current bottle: ask before creating, since + it may be another bottle of the order or this bottle entered again.""" def test_same_lot_supplier_and_cas_is_refused(self, fake_image): old = existing_bottle(522, "THF (old)", Lot_number="shbm1234", Manufacturer="sigma-aldrich", CAS="109-99-9") rm = FakeBottleRM(existing=[old]) - with pytest.raises(add_bottle.DuplicateBottle) as e: - add_bottle.create_bottle(rm, THF_BOTTLE) + with pytest.raises(add_bottle.SameLotBottles) as e: + add_bottle.create_bottles(rm, THF_BOTTLE) assert e.value.bottles == [{"id": 522, "title": "THF (old)"}] assert rm.calls == [] and rm.searches == [("Lot number", "SHBM1234")] def test_linked_compound_counts_as_same_chemical(self, fake_image): old = existing_bottle(600, "Tetrahydrofuran", links=[77], Lot_number="SHBM1234") - with pytest.raises(add_bottle.DuplicateBottle): - add_bottle.create_bottle(FakeBottleRM(existing=[old]), THF_BOTTLE) + with pytest.raises(add_bottle.SameLotBottles): + add_bottle.create_bottles(FakeBottleRM(existing=[old]), THF_BOTTLE) def test_confirmed_duplicate_is_created(self, fake_image): old = existing_bottle(522, "THF (old)", Lot_number="SHBM1234", CAS="109-99-9") rm = FakeBottleRM(existing=[old]) - assert add_bottle.create_bottle(rm, {**THF_BOTTLE, "confirm_duplicate": True})["id"] == 640 + assert add_bottle.create_bottles(rm, {**THF_BOTTLE, "confirm_same_lot": True})["bottles"][0]["id"] == 640 @pytest.mark.parametrize("old", [ existing_bottle(1, "THF other supplier", Lot_number="SHBM1234", Manufacturer="Fisher", CAS="109-99-9"), @@ -993,12 +1024,12 @@ def test_confirmed_duplicate_is_created(self, fake_image): existing_bottle(3, "Longer lot", Lot_number="SHBM12345", CAS="109-99-9"), ]) def test_not_the_same_bottle(self, fake_image, old): - assert add_bottle.create_bottle(FakeBottleRM(existing=[old]), THF_BOTTLE)["id"] == 640 + assert add_bottle.create_bottles(FakeBottleRM(existing=[old]), THF_BOTTLE)["bottles"][0]["id"] == 640 def test_no_lot_number_no_check(self, fake_image): rm = FakeBottleRM() fields = {k: v for k, v in THF_BOTTLE["fields"].items() if k != "Lot number"} - add_bottle.create_bottle(rm, {**THF_BOTTLE, "fields": fields}) + add_bottle.create_bottles(rm, {**THF_BOTTLE, "fields": fields}) assert rm.searches == [] def test_route_answers_409_with_links(self, client, monkeypatch, fake_image): @@ -1006,8 +1037,9 @@ def test_route_answers_409_with_links(self, client, monkeypatch, fake_image): monkeypatch.setattr(interface, "rm", lambda: FakeBottleRM(existing=[old])) resp = client.post("/resources", json=THF_BOTTLE) assert resp.status_code == 409 - [dup] = resp.get_json()["duplicates"] + [dup] = resp.get_json()["same_lot"] assert dup["id"] == 522 and dup["url"].endswith("522") + assert "already have an ELN label" in resp.get_json()["question"] class TestBottleTags: @@ -1039,9 +1071,9 @@ def test_returns_201_with_id_url_and_problems(self, client, monkeypatch, fake_im monkeypatch.setattr(interface, "rm", lambda: FakeBottleRM()) resp = client.post("/resources", json=THF_BOTTLE) assert resp.status_code == 201 - assert resp.get_json()["id"] == 640 and resp.get_json()["problems"] == [] - assert resp.get_json()["tags"] == THF_TAGS - assert resp.get_json()["url"].endswith("640") + [bottle] = resp.get_json()["bottles"] + assert bottle["id"] == 640 and bottle["problems"] == [] and bottle["tags"] == THF_TAGS + assert bottle["url"].endswith("640") and resp.get_json()["problems"] == [] def test_bad_request_is_400(self, client, monkeypatch): monkeypatch.setattr(interface, "rm", lambda: FakeBottleRM()) diff --git a/web/interface.py b/web/interface.py index bf62821..8549488 100644 --- a/web/interface.py +++ b/web/interface.py @@ -523,12 +523,12 @@ def add_option(): @interface_bp.route('/resources', methods=['POST']) @cross_origin(origins="http://localhost:8000") def create_resource(): - """Adds a bottle linked to its compound(s) and put in a storage place: - {category, title, compounds: [ids], storage: {place_id, amount, unit}, fields: {name: value}}. - 400 if the request is incomplete (nothing created). 409 with "duplicates" when a - current bottle has the same lot, manufacturer and chemical; send again with - confirm_duplicate: true to add it anyway. 201 with {id, url, tags, problems} once - the bottle exists; problems lists any later step that failed.""" + """Adds one or more identical bottles, linked to their compound(s) and put in a storage + place: {category, title, compounds: [ids], count, storage: {place_id, amount, unit}, + fields: {name: value}}. 400 if the request is incomplete (nothing created). 409 with + "same_lot" and a "question" for the user when bottles from that lot are already in the + ELN; send again with confirm_same_lot: true to add them. 201 with + {bottles: [{id, url, tags, problems}], problems} once at least one bottle exists.""" data = request.get_json(force=True, silent=True) if not isinstance(data, dict): return jsonify({"status": "error", "error": "Expected a JSON object"}), 400 @@ -537,15 +537,16 @@ def create_resource(): except ValueError as e: return jsonify({"status": "error", "error": str(e)}), 401 try: - result = add_bottle.create_bottle(rmn, data) + result = add_bottle.create_bottles(rmn, data) except add_bottle.InvalidBottle as e: return jsonify({"status": "error", "error": str(e)}), 400 - except add_bottle.DuplicateBottle as e: - return jsonify({"status": "error", "error": str(e), "duplicates": [ + except add_bottle.SameLotBottles as e: + return jsonify({"status": "error", "error": str(e), "question": e.question, "same_lot": [ {**b, "url": config.item_web_url(b["id"])} for b in e.bottles]}), 409 except Exception as e: return jsonify({"status": "error", "error": f"The bottle was not created: {e}"}), 500 - result["url"] = config.item_web_url(result["id"]) + for bottle in result["bottles"]: + bottle["url"] = config.item_web_url(bottle["id"]) return jsonify(result), 201 From 5088935c508166e07c6bf6ec2d778092f5bee144 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 14:53:11 -0400 Subject: [PATCH 11/26] New add-bottle page, part 1: category, compounds and the bottle's own fields /add_bottle_interface is a new page next to the old form. GET /bottle_form gives it each bottle category (templates with a State field), the compounds it needs (one chemical; a solution's chemical and solvent; none for polymers) and the fields that stay on the bottle. Picking from the compound dropdown (filter by name or CAS) shows the hazards and peroxide class the bottle will be tagged with (now part of /compounds_list) and fills in the name and CAS. For now the page shows the request "Create" will send. Co-Authored-By: Claude Opus 5.5 --- eln_common/add_bottle.py | 17 +++ static/add_bottle.html | 100 +++++++++++++++ static/add_bottle.js | 234 +++++++++++++++++++++++++++++++++++ tests/test_routes_offline.py | 42 ++++++- web/interface.py | 45 ++++++- 5 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 static/add_bottle.html create mode 100644 static/add_bottle.js diff --git a/eln_common/add_bottle.py b/eln_common/add_bottle.py index 95e0476..8bc3390 100644 --- a/eln_common/add_bottle.py +++ b/eln_common/add_bottle.py @@ -66,6 +66,23 @@ def bottle_fields(extra_fields: dict[str, Any], has_compounds: bool) -> dict[str return {name: field for name, field in extra_fields.items() if name not in moved} +def compound_slots(extra_fields: dict[str, Any]) -> list[str] | None: + """ + Which compounds a category's bottles link to, read from its template's fields: + a "Solvent" field means a solution (the dissolved chemical and the solvent); Mw/Mn + mean a polymer (no compound: same CAS, different batches); otherwise one chemical. + :return: The label of each compound to pick; None if the template has no + "State" field, i.e. it is not for bottles (e.g. Instrument). + """ + if "State" not in extra_fields: + return None + if "Solvent" in extra_fields: + return ["Dissolved chemical", "Solvent"] + if "Mw" in extra_fields or "Mn" in extra_fields: + return [] + return ["Chemical"] + + def clean_unit(unit: Any) -> str: """The unit as eLabFTW spells it ("µL" typed with the micro sign becomes "μL").""" unit = str(unit or "").strip().replace("µ", "μ") diff --git a/static/add_bottle.html b/static/add_bottle.html new file mode 100644 index 0000000..f0f1440 --- /dev/null +++ b/static/add_bottle.html @@ -0,0 +1,100 @@ + + + + + + Add a bottle + + + +
+ ← Back +

Add a bottle

+ + + + +
+
+ + +
+ + +
+ 1. What is in it +
+ +
+ + +
+ This bottle +
+ + +
Filled in from the chemical; you can change it.
+
+
+
+ + +
+ What "Create" will send +
{}
+
+
+
+ + + + diff --git a/static/add_bottle.js b/static/add_bottle.js new file mode 100644 index 0000000..5ee460b --- /dev/null +++ b/static/add_bottle.js @@ -0,0 +1,234 @@ +// Add-a-bottle page. Runs in the browser and talks to our server with fetch(): +// GET /bottle_form -> bottle categories, the compounds each needs, the bottle's own fields +// GET /compounds_list -> existing compounds (with their hazards) for the dropdown +// The user's API key travels by itself in the apiKey cookie. + +// what the page knows, filled once when it opens +let formInfo = { categories: [], units: [], units_by_state: {} }; +let compounds = []; // [{id, name, cas, formula, hazards, peroxide_class}] +let picked = []; // the compound picked in each slot (null until picked) +let titleTouched = false; // stop filling the name in once the user types their own + +const OTHER = "__other__"; // select value meaning "typed in the Other box" + +// ---------- talking to the server ---------- + +// GET a route and return its JSON; a 401 shows the "not logged in" banner +async function getJSON(url) { + const response = await fetch(url); + if (response.status === 401) { + document.getElementById("login-banner").hidden = false; + throw new Error("not logged in"); + } + if (!response.ok) throw new Error(`${url} failed: ${response.status}`); + return response.json(); +} + +// ---------- category ---------- + +function currentCategory() { + const id = parseInt(document.getElementById("category").value, 10); + return formInfo.categories.find((c) => c.id === id); +} + +function fillCategories() { + const select = document.getElementById("category"); + select.innerHTML = ""; + for (const c of formInfo.categories) { + select.add(new Option(c.title, c.id)); + } +} + +// redraw everything that depends on the category +function onCategoryChange() { + drawCompoundPickers(); + drawBottleFields(); + updateTitle(); + updatePreview(); +} + +// ---------- 1. compounds ---------- + +function compoundLabel(c) { + // what the dropdown shows; typing part of the name or the CAS filters it + return `${c.name || "(no name)"} · ${c.cas || "no CAS"} · #${c.id}`; +} + +function fillCompoundOptions() { + const list = document.getElementById("compound-options"); + list.innerHTML = ""; + for (const c of compounds) list.append(new Option(compoundLabel(c))); +} + +function drawCompoundPickers() { + const slots = currentCategory()?.compound_slots || []; + const box = document.getElementById("compound-pickers"); + box.innerHTML = ""; + picked = slots.map(() => null); + document.getElementById("what-section").hidden = slots.length === 0; + + slots.forEach((slotName, i) => { + const field = document.createElement("div"); + field.className = "field"; + field.innerHTML = ` + + +
Not in the list? Searching PubChem comes next (D4e).
+ `; + box.append(field); + field.querySelector("input").addEventListener("input", (e) => onCompoundPicked(i, e.target.value)); + }); +} + +function onCompoundPicked(slot, text) { + // only an exact dropdown entry counts as picked + picked[slot] = compounds.find((c) => compoundLabel(c) === text) || null; + showCompoundCard(slot); + fillFromCompounds(); + updateTitle(); + updatePreview(); +} + +function showCompoundCard(slot) { + const card = document.getElementById(`compound-card-${slot}`); + const c = picked[slot]; + card.hidden = !c; + if (!c) return; + // built with textContent, never innerHTML, since names come from the ELN + const chip = (text, kind) => Object.assign(document.createElement("span"), { className: `chip ${kind}`, textContent: text }); + const chips = c.hazards.map((h) => chip(h, "hazard")); + if (c.peroxide_class) chips.push(chip(`Peroxide former: ${c.peroxide_class}`, "peroxide")); + if (chips.length === 0) chips.push(chip("No hazard data: check the SDS", "none")); + const name = Object.assign(document.createElement("strong"), { textContent: c.name }); + card.replaceChildren(name, ` · CAS ${c.cas || "—"} · ${c.formula || ""}`, document.createElement("br"), ...chips); +} + +// copy the picked compounds' CAS (and the solvent's name) into the bottle's fields, +// unless the user already typed something there +function fillFromCompounds() { + const [first, second] = picked; + setIfUntouched("CAS", first?.cas); + setIfUntouched("Solvent", second?.name); + setIfUntouched("Solvent CAS", second?.cas); +} + +function setIfUntouched(fieldName, value) { + const input = document.querySelector(`[data-field="${CSS.escape(fieldName)}"]`); + if (input && !input.dataset.touched) input.value = value || ""; +} + +// ---------- the bottle's name ---------- + +function updateTitle() { + if (titleTouched) return; + const [first, second] = picked; + let title = first?.name || ""; + if (first && second) title = `${first.name} in ${second.name}`; + document.getElementById("title").value = title; +} + +// ---------- the bottle's own fields (from the template) ---------- + +function drawBottleFields() { + const box = document.getElementById("bottle-fields"); + box.innerHTML = ""; + for (const f of currentCategory()?.fields || []) box.append(fieldElement(f)); +} + +// one form field for a template field: select, date, number (with unit) or text +function fieldElement(f) { + const wrapper = document.createElement("div"); + wrapper.className = "field"; + const id = `field-${f.name.replace(/\W+/g, "-")}`; + wrapper.innerHTML = ``; + wrapper.querySelector("label").textContent = f.name; + + let input; + if (f.type === "select") { + input = document.createElement("select"); + input.add(new Option("— choose —", "")); + for (const option of f.options || []) input.add(new Option(option, option)); + input.add(new Option("Other…", OTHER)); + } else { + input = document.createElement("input"); + input.type = { date: "date", number: "number" }[f.type] || "text"; + if (f.type === "number") input.step = "any"; + } + input.id = id; + input.dataset.field = f.name; + input.addEventListener("input", () => { input.dataset.touched = "1"; updatePreview(); }); + + if (f.type === "number" && f.unit) { + const row = document.createElement("div"); + row.className = "with-unit"; + row.append(input, Object.assign(document.createElement("span"), { textContent: f.unit })); + wrapper.append(row); + } else { + wrapper.append(input); + } + + if (f.type === "select") { + // "Other…" shows a box to type a value that isn't in the list + const other = Object.assign(document.createElement("input"), { + type: "text", placeholder: `Type the ${f.name.toLowerCase()}`, hidden: true, + }); + other.dataset.otherFor = f.name; + other.addEventListener("input", updatePreview); + input.addEventListener("change", () => { other.hidden = input.value !== OTHER; updatePreview(); }); + wrapper.append(other); + } + if (f.description) { + wrapper.append(Object.assign(document.createElement("div"), { className: "description", textContent: f.description })); + } + return wrapper; +} + +// the value of every bottle field the user filled in: {name: value} +function bottleFieldValues() { + const values = {}; + for (const input of document.querySelectorAll("#bottle-fields [data-field]")) { + let value = input.value.trim(); + if (value === OTHER) { + value = document.querySelector(`[data-other-for="${CSS.escape(input.dataset.field)}"]`).value.trim(); + } + if (value === "") continue; + values[input.dataset.field] = input.type === "number" ? Number(value) : value; + } + return values; +} + +// ---------- what Create will send ---------- + +function buildRequest() { + return { + category: currentCategory()?.id ?? null, + title: document.getElementById("title").value.trim(), + compounds: picked.filter(Boolean).map((c) => c.id), + fields: bottleFieldValues(), + // storage (D4b, D4c) and count (D4c) come next + }; +} + +function updatePreview() { + document.getElementById("preview").textContent = JSON.stringify(buildRequest(), null, 2); +} + +// ---------- start ---------- + +async function start() { + document.getElementById("category").addEventListener("change", onCategoryChange); + document.getElementById("title").addEventListener("input", () => { titleTouched = true; updatePreview(); }); + try { + // both lists load at the same time + [formInfo, compounds] = await Promise.all([getJSON("/bottle_form"), getJSON("/compounds_list")]); + } catch (e) { + console.error(e); + return; + } + fillCategories(); + fillCompoundOptions(); + onCategoryChange(); +} + +start(); diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 9c69c34..8f78259 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -363,7 +363,8 @@ class TestLookupLists: def test_compounds_list_is_trimmed_and_sorted(self, client, monkeypatch): fake_rm = SimpleNamespace(get_compounds=lambda: [ - {"id": 72, "name": "acetone", "cas_number": "67-64-1", "molecular_formula": "C3H6O", "smiles": "CC(C)=O"}, + {"id": 72, "name": "acetone", "cas_number": "67-64-1", "molecular_formula": "C3H6O", "smiles": "CC(C)=O", + "is_flammable": 1}, {"id": 111, "name": "Chlorobenzene", "cas_number": "108-90-7", "molecular_formula": None}, {"id": 196, "name": None, "cas_number": None, "molecular_formula": None}, ]) @@ -371,9 +372,11 @@ def test_compounds_list_is_trimmed_and_sorted(self, client, monkeypatch): resp = client.get("/compounds_list") assert resp.status_code == 200 assert resp.get_json() == [ - {"id": 196, "name": "", "cas": "", "formula": ""}, - {"id": 72, "name": "acetone", "cas": "67-64-1", "formula": "C3H6O"}, - {"id": 111, "name": "Chlorobenzene", "cas": "108-90-7", "formula": ""}, + {"id": 196, "name": "", "cas": "", "formula": "", "hazards": [], "peroxide_class": None}, + {"id": 72, "name": "acetone", "cas": "67-64-1", "formula": "C3H6O", + "hazards": ["Flammable"], "peroxide_class": None}, + {"id": 111, "name": "Chlorobenzene", "cas": "108-90-7", "formula": "", + "hazards": [], "peroxide_class": None}, ] def test_storage_tree_keeps_hierarchy(self, client, monkeypatch): @@ -389,7 +392,7 @@ def test_storage_tree_keeps_hierarchy(self, client, monkeypatch): {"id": 5, "name": "Front hood", "parent_id": 4, "full_path": "Room 3057 > Front hood"}, ] - @pytest.mark.parametrize("path", ["/compounds_list", "/storage_tree"]) + @pytest.mark.parametrize("path", ["/compounds_list", "/storage_tree", "/bottle_form"]) def test_lists_need_an_api_key(self, client, path): resp = client.get(path) assert resp.status_code == 401 @@ -1066,6 +1069,35 @@ def test_no_compounds_no_tags(self): assert bottle_tags.tags_for([]) == [] +class TestBottleForm: + @pytest.mark.parametrize("names, slots", [ + ({"State", "CAS"}, ["Chemical"]), + ({"State", "Solvent", "Solvent CAS"}, ["Dissolved chemical", "Solvent"]), + ({"State", "Mw", "Mn"}, []), + ({"Room"}, None), # Instrument: not a bottle + ]) + def test_compound_slots(self, names, slots): + assert add_bottle.compound_slots({n: {} for n in names}) == slots + + def test_route_lists_bottle_categories_with_their_own_fields(self, client, monkeypatch): + instrument = {"id": 1, "title": "Instrument", "metadata": json.dumps({"extra_fields": {"Room": _field(1)}})} + templates = {1: instrument, 2: CHEMICAL_TEMPLATE, 3: POLYMER_TEMPLATE} + fake_rm = SimpleNamespace(get_items_types=lambda: [{"id": i, "title": t["title"]} for i, t in templates.items()], + get_items_type=lambda id: templates[id]) + monkeypatch.setattr(interface, "rm", lambda: fake_rm) + resp = client.get("/bottle_form") + assert resp.status_code == 200 + categories = resp.get_json()["categories"] + assert [(c["id"], c["compound_slots"]) for c in categories] == [(2, ["Chemical"])] # polymer has no State + names = [f["name"] for f in categories[0]["fields"]] + assert names == ["CAS", "Received", "State", "Purity", "Lot number", "Manufacturer"] # template order + assert resp.get_json()["units_by_state"]["Liquid"] == ["\u03bcL", "mL", "L"] + + def test_page_is_served(self, client): + resp = client.get("/add_bottle_interface") + assert resp.status_code == 200 and b"add_bottle.js" in resp.data + + class TestCreateResourceRoute: def test_returns_201_with_id_url_and_problems(self, client, monkeypatch, fake_image): monkeypatch.setattr(interface, "rm", lambda: FakeBottleRM()) diff --git a/web/interface.py b/web/interface.py index 8549488..2d9bd33 100644 --- a/web/interface.py +++ b/web/interface.py @@ -7,6 +7,7 @@ import eln_common.add_bottle as add_bottle import eln_common.compound_import as compound_import +import eln_common.bottle_tags as bottle_tags import eln_common.config as config import eln_common.pubchem as pubchem import eln_common.storage_places as storage_places @@ -46,6 +47,11 @@ def add_resource_interface(): return send_from_directory(current_app.static_folder, "add_resource.html") # type: ignore +@interface_bp.route("/add_bottle_interface") +def add_bottle_interface(): + return send_from_directory(current_app.static_folder, "add_bottle.html") # type: ignore + + @interface_bp.route("/label_gen_interface") def label_gen_interface(): return send_from_directory(current_app.static_folder, "label_gen.html") # type: ignore @@ -85,8 +91,9 @@ def get_statuses(): @interface_bp.route('/compounds_list', methods=['GET']) @cross_origin(origins="http://localhost:8000") def compounds_list(): - """Existing compounds as [{id, name, cas, formula}], sorted by name, for the - add-resource compound dropdown.""" + """Existing compounds as [{id, name, cas, formula, hazards, peroxide_class}], sorted + by name, for the add-bottle compound dropdown. hazards and peroxide_class are what + the bottle's tags will be (see bottle_tags).""" try: compounds = rm().get_compounds() listing = [ @@ -95,6 +102,8 @@ def compounds_list(): "name": c.get("name") or "", "cas": c.get("cas_number") or "", "formula": c.get("molecular_formula") or "", + "hazards": [tag for flag, tag in bottle_tags.HAZARD_TAGS.items() if c.get(flag)], + "peroxide_class": bottle_tags.peroxide_class(c), } for c in compounds ] @@ -105,6 +114,38 @@ def compounds_list(): return jsonify({"status": "error", "error": str(e)}), 400 +@interface_bp.route('/bottle_form', methods=['GET']) +@cross_origin(origins="http://localhost:8000") +def bottle_form(): + """What the add-bottle page needs to draw its form: each bottle category with the + compounds to pick (compound_slots) and the extra fields that stay on the bottle + (in template order), plus eLabFTW's units and the units offered per State.""" + try: + rmn = rm() + except ValueError as e: + return jsonify({"status": "error", "error": str(e)}), 401 + try: + categories = [] + for t in rmn.get_items_types(): + metadata = json.loads(rmn.get_items_type(t["id"]).get("metadata") or "{}") + extra_fields = metadata.get("extra_fields", {}) + slots = add_bottle.compound_slots(extra_fields) + if slots is None: + continue + fields = add_bottle.bottle_fields(extra_fields, bool(slots)) + categories.append({ + "id": t["id"], + "title": t["title"], + "compound_slots": slots, + "fields": [{"name": name, **field} for name, field in + sorted(fields.items(), key=lambda kv: kv[1].get("position", 0))], + }) + return jsonify({"categories": categories, "units": add_bottle.UNITS, + "units_by_state": add_bottle.UNITS_BY_STATE}) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}), 400 + + def _pubchem_preview(pc: dict) -> dict: """The PubChem fields the page shows before the user confirms an import.""" return { From 7b71ec00bc36827bf05d7c192af469f01b5211b1 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 14:56:55 -0400 Subject: [PATCH 12/26] Add-bottle page, part 2: room and place dropdowns, add a new place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Room dropdown lists the top of the storage tree; Place then lists only that room's places (at any depth, without the room in the name) and ends with "+ Add a new place…". That opens a small form (inside the room or one of its places) that calls POST /storage_units: a new place is added and selected, the same name selects the existing place, and a similar name asks "Did you mean …?" with "Use it" or "Add anyway". Co-Authored-By: Claude Opus 5.5 --- static/add_bottle.html | 43 +++++++++++ static/add_bottle.js | 171 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 3 deletions(-) diff --git a/static/add_bottle.html b/static/add_bottle.html index f0f1440..a1682bd 100644 --- a/static/add_bottle.html +++ b/static/add_bottle.html @@ -51,6 +51,18 @@ pre { background: #f4f4f4; border: 2px solid #ccc; padding: 0.8em; overflow-x: auto; font-size: 0.8em; } .back { display: inline-block; margin-bottom: 1em; color: #111; } + + /* small forms inside a section, like "add a new place" */ + .subform { border: 2px dashed #999; padding: 0.8em 1em; margin-top: 0.5em; } + .buttons { display: flex; gap: 0.5em; flex-wrap: wrap; } + button { + padding: 0.5em 1em; border: 2px solid #000; background: #000; color: #fff; + font-weight: 700; font-size: 0.95em; cursor: pointer; border-radius: 0; + } + button.secondary { background: #fff; color: #000; } + .message { margin: 0.5em 0; padding: 0.5em 0.8em; border: 2px solid #999; background: #f7f7f7; } + .message.error { border-color: #b00; background: #fff0f0; } + .message.ok { border-color: #070; background: #effaef; } @@ -76,6 +88,37 @@

Add a bottle

+ +
+ 2. Where it is kept +
+ + +
+
+ + +
+
+ + + +
+
This bottle diff --git a/static/add_bottle.js b/static/add_bottle.js index 5ee460b..1a7efb7 100644 --- a/static/add_bottle.js +++ b/static/add_bottle.js @@ -1,15 +1,19 @@ // Add-a-bottle page. Runs in the browser and talks to our server with fetch(): // GET /bottle_form -> bottle categories, the compounds each needs, the bottle's own fields // GET /compounds_list -> existing compounds (with their hazards) for the dropdown +// GET /storage_tree -> rooms and the places inside them +// POST /storage_units -> add a new place (the server refuses near-duplicate names) // The user's API key travels by itself in the apiKey cookie. // what the page knows, filled once when it opens let formInfo = { categories: [], units: [], units_by_state: {} }; let compounds = []; // [{id, name, cas, formula, hazards, peroxide_class}] +let storage = []; // [{id, name, parent_id, full_path}]; rooms have parent_id null let picked = []; // the compound picked in each slot (null until picked) let titleTouched = false; // stop filling the name in once the user types their own const OTHER = "__other__"; // select value meaning "typed in the Other box" +const ADD_PLACE = "__add_place__"; // place value meaning "+ Add a new place…" // ---------- talking to the server ---------- @@ -24,6 +28,20 @@ async function getJSON(url) { return response.json(); } +// POST JSON to a route; returns {status, data} so the caller can react to 201/409/... +async function postJSON(url, body) { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (response.status === 401) { + document.getElementById("login-banner").hidden = false; + throw new Error("not logged in"); + } + return { status: response.status, data: await response.json() }; +} + // ---------- category ---------- function currentCategory() { @@ -118,6 +136,140 @@ function setIfUntouched(fieldName, value) { if (input && !input.dataset.touched) input.value = value || ""; } +// ---------- 2. where it is kept ---------- + +// every place below a room (or place), at any depth, sorted by path +function placesIn(parentId) { + const below = []; + const walk = (id) => { + for (const unit of storage.filter((u) => u.parent_id === id)) { + below.push(unit); + walk(unit.id); + } + }; + walk(parentId); + return below.sort((a, b) => a.full_path.localeCompare(b.full_path)); +} + +// "Front hood › Flammable cabinet": a place's path without its room +function placeLabel(place) { + return place.full_path.split(" > ").slice(1).join(" › "); +} + +function fillRooms() { + const select = document.getElementById("room"); + select.innerHTML = ""; + select.add(new Option("— choose —", "")); + for (const room of storage.filter((u) => u.parent_id === null)) select.add(new Option(room.name, room.id)); +} + +// the Place dropdown lists only the chosen room's places, then "+ Add a new place…" +function fillPlaces(selectedId = "") { + const roomId = parseInt(document.getElementById("room").value, 10); + const select = document.getElementById("place"); + select.innerHTML = ""; + select.add(new Option(roomId ? "— choose —" : "— choose a room first —", "")); + if (roomId) { + for (const place of placesIn(roomId)) select.add(new Option(placeLabel(place), place.id)); + select.add(new Option("+ Add a new place…", ADD_PLACE)); + } + select.value = String(selectedId); +} + +function onRoomChange() { + fillPlaces(); + hideAddPlace(); + setPlaceNote(""); + updatePreview(); +} + +function onPlaceChange() { + setPlaceNote(""); + if (document.getElementById("place").value === ADD_PLACE) showAddPlace(); + else hideAddPlace(); + updatePreview(); +} + +function setPlaceNote(text) { + document.getElementById("place-note").textContent = text; +} + +// ---------- 2b. + Add a new place ---------- + +function showAddPlace() { + const roomId = parseInt(document.getElementById("room").value, 10); + const room = storage.find((u) => u.id === roomId); + // the new place can go in the room itself or inside one of its places + const parent = document.getElementById("new-place-parent"); + parent.innerHTML = ""; + parent.add(new Option(`${room.name} (the room itself)`, room.id)); + for (const place of placesIn(roomId)) parent.add(new Option(placeLabel(place), place.id)); + document.getElementById("new-place-name").value = ""; + showAddPlaceMessage(null); + document.getElementById("add-place").hidden = false; + document.getElementById("new-place-name").focus(); +} + +function hideAddPlace() { + document.getElementById("add-place").hidden = true; +} + +// a message inside the add-place box, optionally with buttons [{text, onClick, secondary}] +function showAddPlaceMessage(text, kind = "", buttons = []) { + const box = document.getElementById("add-place-message"); + box.hidden = !text; + box.className = `message ${kind}`; + box.replaceChildren(text || ""); + if (buttons.length) { + const row = Object.assign(document.createElement("div"), { className: "buttons" }); + row.style.marginTop = "0.5em"; + for (const b of buttons) { + const button = Object.assign(document.createElement("button"), { + type: "button", textContent: b.text, className: b.secondary ? "secondary" : "", + }); + button.addEventListener("click", b.onClick); + row.append(button); + } + box.append(row); + } +} + +// pick a place in the Place dropdown (after adding it, or when it already existed) +function choosePlace(place, note) { + hideAddPlace(); + fillPlaces(place.id); + setPlaceNote(note); + updatePreview(); +} + +// ask the server to add the place; confirm=true means "add it even though a similar name exists" +async function addPlace(confirm = false) { + const name = document.getElementById("new-place-name").value.trim(); + const parentId = parseInt(document.getElementById("new-place-parent").value, 10); + if (!name) { + showAddPlaceMessage("Type a name for the new place.", "error"); + return; + } + const { status, data } = await postJSON("/storage_units", { name, parent_id: parentId, confirm }); + + if (status === 201) { + storage.push(data); + choosePlace(data, `✔ Added ${placeLabel(data)}`); + } else if (status === 409 && data.exact) { + // the same name is already there: just use that place + choosePlace(data.existing, `This place already exists, so it's selected: ${placeLabel(data.existing)}`); + } else if (status === 409) { + showAddPlaceMessage(`Did you mean "${placeLabel(data.existing)}"?`, "", [ + { text: `Use "${data.existing.name}"`, onClick: () => choosePlace(data.existing, "") }, + { text: `Add "${name}" anyway`, onClick: () => addPlace(true), secondary: true }, + ]); + } else if (status === 403) { + showAddPlaceMessage("eLabFTW doesn't let you add storage places. Ask a lab admin to add it.", "error"); + } else { + showAddPlaceMessage(data.error || `Adding the place failed (${status}).`, "error"); + } +} + // ---------- the bottle's name ---------- function updateTitle() { @@ -205,8 +357,9 @@ function buildRequest() { category: currentCategory()?.id ?? null, title: document.getElementById("title").value.trim(), compounds: picked.filter(Boolean).map((c) => c.id), + storage: { place_id: parseInt(document.getElementById("place").value, 10) || null }, fields: bottleFieldValues(), - // storage (D4b, D4c) and count (D4c) come next + // amount, unit (D4c) and count (D4c) come next }; } @@ -219,15 +372,27 @@ function updatePreview() { async function start() { document.getElementById("category").addEventListener("change", onCategoryChange); document.getElementById("title").addEventListener("input", () => { titleTouched = true; updatePreview(); }); + document.getElementById("room").addEventListener("change", onRoomChange); + document.getElementById("place").addEventListener("change", onPlaceChange); + document.getElementById("add-place-button").addEventListener("click", () => addPlace(false)); + document.getElementById("add-place-cancel").addEventListener("click", () => { + hideAddPlace(); + document.getElementById("place").value = ""; + updatePreview(); + }); try { - // both lists load at the same time - [formInfo, compounds] = await Promise.all([getJSON("/bottle_form"), getJSON("/compounds_list")]); + // the three lists load at the same time + [formInfo, compounds, storage] = await Promise.all([ + getJSON("/bottle_form"), getJSON("/compounds_list"), getJSON("/storage_tree"), + ]); } catch (e) { console.error(e); return; } fillCategories(); fillCompoundOptions(); + fillRooms(); + fillPlaces(); onCategoryChange(); } From 7fb119f2bcef0be782c2d997e2ff1cd7d522cef4 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 14:58:13 -0400 Subject: [PATCH 13/26] Add-bottle page, part 3: amount, unit and number of bottles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "3. How much" holds the State field (moved up from the bottle's fields), the amount with a unit dropdown, and the number of identical bottles (1-20). The State's usual units come first (Liquid: μL mL L, Solid: μg mg g kg, Gas: bar L g), then eLabFTW's other units; changing the State switches to its usual unit unless the chosen one still fits. Nothing is converted. Co-Authored-By: Claude Opus 5.5 --- static/add_bottle.html | 19 ++++++++++++++ static/add_bottle.js | 57 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/static/add_bottle.html b/static/add_bottle.html index a1682bd..4c2dcd1 100644 --- a/static/add_bottle.html +++ b/static/add_bottle.html @@ -119,6 +119,25 @@

Add a bottle

+ +
+ 3. How much +
+
+ +
+ + +
+
eLabFTW keeps 2 decimals. Units are not converted.
+
+
+ + +
Identical bottles from one order; each gets its own number and label.
+
+
+
This bottle diff --git a/static/add_bottle.js b/static/add_bottle.js index 1a7efb7..4e0338d 100644 --- a/static/add_bottle.js +++ b/static/add_bottle.js @@ -270,6 +270,34 @@ async function addPlace(confirm = false) { } } +// ---------- 3. how much ---------- + +// the State the user chose ("Liquid", "Solid", "Gas"), or "" if none yet +function currentState() { + return document.querySelector('[data-field="State"]')?.value || ""; +} + +// units for the chosen State first, then eLabFTW's other units (for unusual cases) +function fillUnits() { + const select = document.getElementById("unit"); + const previous = select.value; + const suggested = formInfo.units_by_state[currentState()] || []; + select.innerHTML = ""; + select.add(new Option("unit", "")); + for (const unit of suggested) select.add(new Option(unit, unit)); + const others = formInfo.units.filter((u) => !suggested.includes(u)); + if (suggested.length) { + const divider = new Option("— other units —", ""); + divider.disabled = true; + select.add(divider); + } + for (const unit of others) select.add(new Option(unit, unit)); + // keep the unit if it suits the State (or no State is chosen); else the State's usual one + const usual = { Liquid: "mL", Solid: "g", Gas: "bar" }[currentState()] || ""; + const keep = previous && (suggested.length === 0 || suggested.includes(previous)); + select.value = keep ? previous : usual; +} + // ---------- the bottle's name ---------- function updateTitle() { @@ -284,8 +312,14 @@ function updateTitle() { function drawBottleFields() { const box = document.getElementById("bottle-fields"); + const stateSlot = document.getElementById("state-slot"); box.innerHTML = ""; - for (const f of currentCategory()?.fields || []) box.append(fieldElement(f)); + stateSlot.innerHTML = ""; + for (const f of currentCategory()?.fields || []) { + // State decides the units, so it sits in "3. How much" next to the amount + (f.name === "State" ? stateSlot : box).append(fieldElement(f)); + } + fillUnits(); } // one form field for a template field: select, date, number (with unit) or text @@ -339,7 +373,7 @@ function fieldElement(f) { // the value of every bottle field the user filled in: {name: value} function bottleFieldValues() { const values = {}; - for (const input of document.querySelectorAll("#bottle-fields [data-field]")) { + for (const input of document.querySelectorAll("#bottle-form [data-field]")) { let value = input.value.trim(); if (value === OTHER) { value = document.querySelector(`[data-other-for="${CSS.escape(input.dataset.field)}"]`).value.trim(); @@ -357,12 +391,22 @@ function buildRequest() { category: currentCategory()?.id ?? null, title: document.getElementById("title").value.trim(), compounds: picked.filter(Boolean).map((c) => c.id), - storage: { place_id: parseInt(document.getElementById("place").value, 10) || null }, + storage: { + place_id: parseInt(document.getElementById("place").value, 10) || null, + amount: amountValue(), + unit: document.getElementById("unit").value, + }, + count: parseInt(document.getElementById("count").value, 10) || 1, fields: bottleFieldValues(), - // amount, unit (D4c) and count (D4c) come next }; } +// the amount as a number, or null if the box is empty +function amountValue() { + const text = document.getElementById("amount").value.trim(); + return text === "" ? null : Number(text); +} + function updatePreview() { document.getElementById("preview").textContent = JSON.stringify(buildRequest(), null, 2); } @@ -373,6 +417,11 @@ async function start() { document.getElementById("category").addEventListener("change", onCategoryChange); document.getElementById("title").addEventListener("input", () => { titleTouched = true; updatePreview(); }); document.getElementById("room").addEventListener("change", onRoomChange); + for (const id of ["amount", "unit", "count"]) { + document.getElementById(id).addEventListener("input", updatePreview); + } + // choosing a State changes the units on offer + document.getElementById("state-slot").addEventListener("change", () => { fillUnits(); updatePreview(); }); document.getElementById("place").addEventListener("change", onPlaceChange); document.getElementById("add-place-button").addEventListener("click", () => addPlace(false)); document.getElementById("add-place-cancel").addEventListener("click", () => { From 5605e8492043ea15f26e26351d14208d3c385f9d Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 15:04:29 -0400 Subject: [PATCH 14/26] Add-bottle page, part 4: the Create button Create checks the form first (pick from the lists, a room and place, amount, unit, name and the template's required fields), outlines what is missing and sends nothing until it is complete. The button is disabled while POST /resources runs, so a double click can't make two bottles. 201 shows each new bottle's number (linked to eLabFTW) with its tags and any step to finish by hand; 409 lists the bottles from the same lot and asks whether this bottle already has a label, with "It's another bottle: add it". Co-Authored-By: Claude Opus 5.5 --- static/add_bottle.html | 22 +++++-- static/add_bottle.js | 127 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/static/add_bottle.html b/static/add_bottle.html index 4c2dcd1..a6d930e 100644 --- a/static/add_bottle.html +++ b/static/add_bottle.html @@ -63,6 +63,16 @@ .message { margin: 0.5em 0; padding: 0.5em 0.8em; border: 2px solid #999; background: #f7f7f7; } .message.error { border-color: #b00; background: #fff0f0; } .message.ok { border-color: #070; background: #effaef; } + .message ul { margin: 0.4em 0 0; padding-left: 1.2em; } + .message a { color: inherit; font-weight: 700; } + + button.create { display: block; width: 100%; padding: 0.9em; font-size: 1.05em; margin: 1em 0; } + button:disabled { opacity: 0.5; cursor: wait; } + .missing { border-color: #b00 !important; box-shadow: 2px 2px 0 #f3b5b5 !important; } + details { margin-top: 1em; font-size: 0.9em; } + + /* after creating: the form is done, only the result stays */ + form.done > .field, form.done fieldset, form.done #create-button, form.done details { display: none; } @@ -149,11 +159,15 @@

Add a bottle

- -
- What "Create" will send + + + + + +
+ Show what "Create" sends
{}
-
+ diff --git a/static/add_bottle.js b/static/add_bottle.js index 4e0338d..dfd2343 100644 --- a/static/add_bottle.js +++ b/static/add_bottle.js @@ -3,6 +3,7 @@ // GET /compounds_list -> existing compounds (with their hazards) for the dropdown // GET /storage_tree -> rooms and the places inside them // POST /storage_units -> add a new place (the server refuses near-duplicate names) +// POST /resources -> create the bottle(s) // The user's API key travels by itself in the apiKey cookie. // what the page knows, filled once when it opens @@ -411,11 +412,133 @@ function updatePreview() { document.getElementById("preview").textContent = JSON.stringify(buildRequest(), null, 2); } +// ---------- Create ---------- + +// what's missing before we can send, as [{element, text}]; nothing is sent until it's empty +function missingAnswers(request) { + const missing = []; + const need = (ok, element, text) => { if (!ok) missing.push({ element, text }); }; + const slots = currentCategory()?.compound_slots || []; + slots.forEach((slotName, i) => need(picked[i], document.getElementById(`compound-${i}`), + `${slotName}: pick it from the list`)); + need(request.storage.place_id, document.getElementById("room").value ? document.getElementById("place") + : document.getElementById("room"), "Where it is kept: choose a room and a place"); + need(request.storage.amount !== null && request.storage.amount >= 0, document.getElementById("amount"), + "Amount: a number, 0 or more"); + need(request.storage.unit, document.getElementById("unit"), "Unit"); + need(request.count >= 1 && request.count <= 20, document.getElementById("count"), "Number of bottles: 1 to 20"); + need(request.title, document.getElementById("title"), "Name"); + // the template marks some fields as required (Lot number, Received...) + for (const f of currentCategory()?.fields || []) { + if (f.required && !(f.name in request.fields)) { + need(false, document.querySelector(`[data-field="${CSS.escape(f.name)}"]`), f.name); + } + } + return missing; +} + +// a message above the Create button: text, an optional list, links and buttons +function showCreateMessage({ text, kind = "", items = [], links = [], buttons = [] }) { + const box = document.getElementById("create-message"); + box.hidden = !text; + box.className = `message ${kind}`; + box.replaceChildren(text || ""); + if (items.length || links.length) { + const list = document.createElement("ul"); + for (const item of items) list.append(Object.assign(document.createElement("li"), { textContent: item })); + for (const link of links) { + const li = document.createElement("li"); + li.append(Object.assign(document.createElement("a"), { + href: link.url, target: "_blank", rel: "noopener", textContent: link.text, + })); + if (link.after) li.append(link.after); + list.append(li); + } + box.append(list); + } + if (buttons.length) { + const row = Object.assign(document.createElement("div"), { className: "buttons" }); + row.style.marginTop = "0.6em"; + for (const b of buttons) { + const button = Object.assign(document.createElement("button"), { + type: "button", textContent: b.text, className: b.secondary ? "secondary" : "", + }); + button.addEventListener("click", b.onClick); + row.append(button); + } + box.append(row); + } + box.scrollIntoView({ block: "nearest", behavior: "smooth" }); +} + +async function onCreate(event, confirmSameLot = false) { + event?.preventDefault(); // stop the browser's own form submit (it would reload the page) + document.querySelectorAll(".missing").forEach((el) => el.classList.remove("missing")); + + const request = buildRequest(); + const missing = missingAnswers(request); + if (missing.length) { + missing.forEach((m) => m.element?.classList.add("missing")); + showCreateMessage({ text: "Please fill in:", kind: "error", items: missing.map((m) => m.text) }); + missing[0].element?.focus(); + return; + } + if (confirmSameLot) request.confirm_same_lot = true; + + // one click = one request: the button stays disabled until the answer is back + const button = document.getElementById("create-button"); + button.disabled = true; + button.textContent = "Creating…"; + try { + const { status, data } = await postJSON("/resources", request); + if (status === 201) showCreated(data); + else if (status === 409) showSameLot(data); + else showCreateMessage({ text: data.error || `Creating failed (${status}).`, kind: "error" }); + } catch (e) { + showCreateMessage({ text: `Could not reach the server: ${e.message}`, kind: "error" }); + } finally { + button.disabled = false; + button.textContent = "Create"; + } +} + +// 409: bottles from this lot are already in the ELN; ask about the label +function showSameLot(data) { + showCreateMessage({ + text: `${data.error}. ${data.question}`, + links: data.same_lot.map((b) => ({ url: b.url, text: `#${b.id} ${b.title}` })), + buttons: [ + { text: "It's another bottle: add it", onClick: () => onCreate(null, true) }, + { text: "Cancel", secondary: true, onClick: () => showCreateMessage({}) }, + ], + }); +} + +// 201: the bottle(s) exist. Show their numbers, tags, and any step to finish by hand. +function showCreated(data) { + document.getElementById("bottle-form").classList.add("done"); + const bottles = data.bottles; + const links = bottles.map((b) => ({ + url: b.url, + text: `#${b.id} ${document.getElementById("title").value}`, + after: b.tags.length ? ` · ${b.tags.join(", ")}` : "", + })); + const problems = [...data.problems, ...bottles.flatMap((b) => b.problems.map((p) => `#${b.id}: ${p}`))]; + showCreateMessage({ + text: `✔ Created ${bottles.length === 1 ? "bottle" : `${bottles.length} bottles`}. Write the number on the bottle, or print its label:`, + kind: problems.length ? "" : "ok", + links, + items: problems.map((p) => `⚠ ${p} (finish this in eLabFTW)`), + buttons: [{ text: "Add another bottle", onClick: () => window.location.reload() }], + }); +} + // ---------- start ---------- async function start() { document.getElementById("category").addEventListener("change", onCategoryChange); document.getElementById("title").addEventListener("input", () => { titleTouched = true; updatePreview(); }); + document.getElementById("bottle-form").addEventListener("submit", onCreate); document.getElementById("room").addEventListener("change", onRoomChange); for (const id of ["amount", "unit", "count"]) { document.getElementById(id).addEventListener("input", updatePreview); @@ -424,6 +547,10 @@ async function start() { document.getElementById("state-slot").addEventListener("change", () => { fillUnits(); updatePreview(); }); document.getElementById("place").addEventListener("change", onPlaceChange); document.getElementById("add-place-button").addEventListener("click", () => addPlace(false)); + // Enter in the new place's name adds the place (instead of submitting the whole form) + document.getElementById("new-place-name").addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); addPlace(false); } + }); document.getElementById("add-place-cancel").addEventListener("click", () => { hideAddPlace(); document.getElementById("place").value = ""; From 4b5b1ee29c4e882707b9a2b1ce077ab2a8115232 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 15:07:39 -0400 Subject: [PATCH 15/26] Add-bottle page, part 5: find a missing compound in PubChem Under each compound picker, "Not in the list? Search PubChem" looks the CAS number (or name) up with GET /compounds/pubchem. Each match says what can be done: a compound already in the ELN (maybe under another name or CAS) is offered with "Use #id"; a deleted one with "Restore #id"; a new one with "Add this compound to the ELN" (POST /compounds). The added or restored compound is then picked for the bottle, with a note when PubChem has no hazard data. Co-Authored-By: Claude Opus 5.5 --- static/add_bottle.html | 7 +++ static/add_bottle.js | 124 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/static/add_bottle.html b/static/add_bottle.html index a6d930e..243588a 100644 --- a/static/add_bottle.html +++ b/static/add_bottle.html @@ -71,6 +71,13 @@ .missing { border-color: #b00 !important; box-shadow: 2px 2px 0 #f3b5b5 !important; } details { margin-top: 1em; font-size: 0.9em; } + /* a button that looks like a link ("Not in the list? Search PubChem") */ + button.link { background: none; border: none; color: #0645ad; padding: 0; font-weight: 400; + font-size: 0.85em; text-decoration: underline; margin-top: 0.3em; } + .candidate { border-top: 1px solid #ccc; padding: 0.6em 0; font-size: 0.9em; } + .candidate:first-child { border-top: none; } + .candidate .buttons { margin-top: 0.4em; } + /* after creating: the form is done, only the result stays */ form.done > .field, form.done fieldset, form.done #create-button, form.done details { display: none; } diff --git a/static/add_bottle.js b/static/add_bottle.js index dfd2343..3188197 100644 --- a/static/add_bottle.js +++ b/static/add_bottle.js @@ -4,6 +4,7 @@ // GET /storage_tree -> rooms and the places inside them // POST /storage_units -> add a new place (the server refuses near-duplicate names) // POST /resources -> create the bottle(s) +// GET /compounds/pubchem, POST /compounds -> find a missing compound in PubChem and add it // The user's API key travels by itself in the apiKey cookie. // what the page knows, filled once when it opens @@ -93,10 +94,22 @@ function drawCompoundPickers() { -
Not in the list? Searching PubChem comes next (D4e).
+ + `; box.append(field); - field.querySelector("input").addEventListener("input", (e) => onCompoundPicked(i, e.target.value)); + field.querySelector(`#compound-${i}`).addEventListener("input", (e) => onCompoundPicked(i, e.target.value)); + field.querySelector(`#pubchem-toggle-${i}`).addEventListener("click", () => togglePubChem(i)); + field.querySelector(`#pubchem-search-${i}`).addEventListener("click", () => searchPubChem(i)); + field.querySelector(`#pubchem-query-${i}`).addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); searchPubChem(i); } + }); }); } @@ -123,6 +136,113 @@ function showCompoundCard(slot) { card.replaceChildren(name, ` · CAS ${c.cas || "—"} · ${c.formula || ""}`, document.createElement("br"), ...chips); } +// ---------- 1b. a compound that isn't in the list: PubChem ---------- + +function togglePubChem(slot) { + const box = document.getElementById(`pubchem-${slot}`); + box.hidden = !box.hidden; + if (!box.hidden) { + // start from what was typed in the compound box, if it looks useful + const typed = document.getElementById(`compound-${slot}`).value.trim(); + const query = document.getElementById(`pubchem-query-${slot}`); + if (typed && !query.value) query.value = typed; + query.focus(); + } +} + +const looksLikeCas = (text) => /^\d{2,7}-\d{2}-\d$/.test(text); + +// a short message (and optional buttons) in a slot's PubChem box +function pubchemMessage(slot, text, kind = "") { + const results = document.getElementById(`pubchem-results-${slot}`); + results.replaceChildren(Object.assign(document.createElement("div"), { className: `message ${kind}`, textContent: text })); +} + +// GET /compounds/pubchem: read-only lookup; nothing is saved yet +async function searchPubChem(slot) { + const query = document.getElementById(`pubchem-query-${slot}`).value.trim(); + if (!query) return pubchemMessage(slot, "Type a CAS number or a name.", "error"); + const cas = looksLikeCas(query) ? query : ""; + pubchemMessage(slot, "Searching PubChem…"); + const response = await fetch(`/compounds/pubchem?${new URLSearchParams(cas ? { cas } : { name: query })}`); + const data = await response.json(); + if (response.status === 404) return pubchemMessage(slot, `PubChem found nothing for "${query}". Check the CAS number on the bottle, or try again in a minute (PubChem is sometimes busy).`, "error"); + if (!response.ok) return pubchemMessage(slot, data.error || `Search failed (${response.status}). Try again.`, "error"); + showCandidates(slot, data.candidates, cas); +} + +// one row per PubChem match, with what the user can do with it +function showCandidates(slot, candidates, cas) { + const results = document.getElementById(`pubchem-results-${slot}`); + results.replaceChildren(); + for (const { pubchem, existing, reason, can_restore } of candidates) { + const row = Object.assign(document.createElement("div"), { className: "candidate" }); + const title = Object.assign(document.createElement("strong"), { textContent: pubchem.name }); + row.append(title, ` · CAS ${pubchem.cas || "—"} · ${pubchem.formula || ""} · PubChem ${pubchem.cid}`); + + const buttons = Object.assign(document.createElement("div"), { className: "buttons" }); + const addButton = (text, onClick, secondary = false) => { + const b = Object.assign(document.createElement("button"), { type: "button", textContent: text, className: secondary ? "secondary" : "" }); + b.addEventListener("click", onClick); + buttons.append(b); + }; + const note = (text) => row.append(Object.assign(document.createElement("div"), { className: "hint", textContent: text })); + + if (existing && !existing.deleted) { + note(`Already in the ELN as #${existing.id} ${existing.name} (${reason}).`); + addButton(`Use #${existing.id} ${existing.name}`, () => pickCompoundById(slot, existing.id)); + } else if (existing && can_restore) { + note(`This compound was deleted earlier as #${existing.id} ${existing.name} (${reason}). It can be brought back with fresh details from PubChem.`); + addButton(`Restore #${existing.id}`, () => addCompound(slot, pubchem.cid, cas, existing.id)); + } else if (existing) { + note(`It matches several old, deleted compounds (e.g. #${existing.id}). Ask a lab admin to sort them out.`); + } else { + addButton("Add this compound to the ELN", () => addCompound(slot, pubchem.cid, cas)); + } + row.append(buttons); + results.append(row); + } +} + +// POST /compounds: create it (or restore the deleted one), then pick it for this bottle +async function addCompound(slot, cid, cas, restoreId = null) { + pubchemMessage(slot, restoreId ? "Restoring…" : "Adding to the ELN…"); + const body = { cid, cas: cas || null }; + if (restoreId) body.restore = restoreId; + const { status, data } = await postJSON("/compounds", body); + if (status === 201 || status === 200) { + await pickCompoundById(slot, data.id); + const what = data.restored ? "Restored" : "Added to the ELN"; + const hazards = data.pictograms === null ? " PubChem has no hazard data for it: check the SDS." : ""; + setCompoundNote(slot, `✔ ${what} as #${data.id} ${data.name}.${hazards}`); + } else if (status === 409) { + pubchemMessage(slot, `${data.error}: #${data.existing.id} ${data.existing.name}.`, "error"); + } else { + pubchemMessage(slot, data.error || `Adding failed (${status}).`, "error"); + } +} + +// reload the compound list (a new one was added) and pick that compound in this slot +async function pickCompoundById(slot, id) { + compounds = await getJSON("/compounds_list"); + fillCompoundOptions(); + const compound = compounds.find((c) => c.id === id); + if (!compound) return; + const input = document.getElementById(`compound-${slot}`); + input.value = compoundLabel(compound); + onCompoundPicked(slot, input.value); + document.getElementById(`pubchem-${slot}`).hidden = true; + document.getElementById(`pubchem-results-${slot}`).replaceChildren(); + setCompoundNote(slot, ""); +} + +// a line under the compound card ("✔ Added to the ELN as #162 …") +function setCompoundNote(slot, text) { + const card = document.getElementById(`compound-card-${slot}`); + card.querySelector(".hint")?.remove(); + if (text) card.append(Object.assign(document.createElement("div"), { className: "hint", textContent: text })); +} + // copy the picked compounds' CAS (and the solvent's name) into the bottle's fields, // unless the user already typed something there function fillFromCompounds() { From 6cb44a07e2fd01fafee9e7f65c04a7421b47ec59 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 15:16:17 -0400 Subject: [PATCH 16/26] Choose hazard pictograms the way an SDS would, not from any source Taking a pictogram from any PubChem source over-warned: water got "Health hazard" from a small ECHA group that is really a polymer, and hydrogen peroxide got Japan's pictograms for concentrated grades. Now the EU's official classification (Regulation 1272/2008) is used when there is one; otherwise ECHA's main summary (most company reports), with no pictograms if most reports say not hazardous; otherwise the other sources combined. Co-Authored-By: Claude Opus 5.5 --- eln_common/pubchem.py | 71 +++++++++++++++++++++++++++++------- tests/test_routes_offline.py | 56 +++++++++++++++++++++++----- 2 files changed, 103 insertions(+), 24 deletions(-) diff --git a/eln_common/pubchem.py b/eln_common/pubchem.py index 5c6aa40..8ee1c06 100644 --- a/eln_common/pubchem.py +++ b/eln_common/pubchem.py @@ -89,11 +89,19 @@ def cas_number(cid: int) -> str | None: return next((s for s in synonyms if check_if_cas(s)), None) +OFFICIAL_SOURCE = "Regulation (EC) No 1272/2008" # the EU's legal (harmonised) classification +ECHA_SOURCE = "European Chemicals Agency (ECHA)" # company notifications, summarised by ECHA + + def ghs_pictograms(cid: int) -> list[str] | None: """ - The GHS hazard pictograms PubChem lists for a compound, e.g. ["GHS02", "GHS07"]. - PubChem gives one classification per source (ECHA, suppliers...); a pictogram is - included if any source gives it, as the more cautious choice. + The GHS hazard pictograms for a compound, e.g. ["GHS02", "GHS07"], chosen the way a + supplier's SDS would be (PubChem lists one classification per source, and some are + for other grades or even other substances): + 1. the EU's official classification, when there is one; + 2. otherwise ECHA's main summary (the one with the most company reports); none if + most of those reports say the compound is not hazardous; + 3. otherwise every other source's pictograms together. :return: The sorted pictogram codes; [] when PubChem classifies the compound with no pictograms; None when PubChem has no GHS classification at all (hazards unknown: check the supplier's SDS). @@ -102,17 +110,52 @@ def ghs_pictograms(cid: int) -> list[str] | None: params={"heading": "GHS Classification"}, timeout=TIMEOUT)) if data is None: return None - found = set() - for info in _information(data["Record"]): - if info.get("Name") != "Pictogram(s)": - continue - for text in info.get("Value", {}).get("StringWithMarkup", []): - for markup in text.get("Markup", []): - # the icon's URL names the pictogram: .../images/ghs/GHS02.svg - match = re.search(r"(GHS\d\d)\.svg$", markup.get("URL", "")) - if match: - found.add(match.group(1)) - return sorted(found) + sources = _ghs_sources(data["Record"]) + + official = [s for s in sources.values() if s["source"].startswith(OFFICIAL_SOURCE)] + if official: + return sorted(set().union(*(s["pictograms"] for s in official))) + + echa = [s for s in sources.values() if s["source"] == ECHA_SOURCE and s["reports"]] + if echa: + main = max(echa, key=lambda s: s["reports"]) + if main["not_hazardous_percent"] >= 50: + return [] + if main["pictograms"]: + return sorted(main["pictograms"]) + + return sorted(set().union(set(), *(s["pictograms"] for s in sources.values()))) + + +def _ghs_sources(record: dict[str, Any]) -> dict[int, dict[str, Any]]: + """ + PubChem's GHS classification, one entry per source (reference number): + {source name, pictograms, reports (ECHA's company report count, else 0), + not_hazardous_percent (from ECHA's "does not meet GHS hazard criteria for X%")}. + """ + names = {r["ReferenceNumber"]: r.get("SourceName", "") for r in record.get("Reference", [])} + sources: dict[int, dict[str, Any]] = {} + for info in _information(record): + ref = info.get("ReferenceNumber") + source = sources.setdefault(ref, {"source": names.get(ref, ""), "pictograms": set(), + "reports": 0, "not_hazardous_percent": 0.0}) + texts = info.get("Value", {}).get("StringWithMarkup", []) + if info.get("Name") == "Pictogram(s)": + for text in texts: + for markup in text.get("Markup", []): + # the icon's URL names the pictogram: .../images/ghs/GHS02.svg + match = re.search(r"(GHS\d\d)\.svg$", markup.get("URL", "")) + if match: + source["pictograms"].add(match.group(1)) + elif info.get("Name") == "ECHA C&L Notifications Summary" and texts: + match = re.search(r"per (\d+) reports", texts[0].get("String", "")) + if match: + source["reports"] = int(match.group(1)) + elif info.get("Name") == "Note" and texts: + match = re.search(r"does not meet GHS hazard criteria for ([\d.]+)%", texts[0].get("String", "")) + if match: + source["not_hazardous_percent"] = float(match.group(1)) + return sources def _information(section: dict[str, Any]) -> Iterator[dict[str, Any]]: diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 8f78259..cae5768 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -547,18 +547,32 @@ def _answer(self, url, **kwargs): def ghs_answer(*sources): - """A PubChem GHS Classification record; each source is a list of pictogram codes.""" - info = [] - for codes in sources: - info.append({"Name": "Pictogram(s)", "Value": {"StringWithMarkup": [{"String": "", "Markup": [ - {"URL": f"https://pubchem.ncbi.nlm.nih.gov/images/ghs/{c}.svg", "Type": "Icon"} - for c in codes]}]}}) - info.append({"Name": "Signal", "Value": {"StringWithMarkup": [{"String": "Danger"}]}}) + """A PubChem GHS Classification record, shaped like the real ones. Each source is a list of + pictogram codes, or a dict {source, pictograms, reports, not_hazardous_percent}.""" + info, references = [], [] + for ref, src in enumerate(sources, start=1): + if isinstance(src, list): + src = {"pictograms": src} + references.append({"ReferenceNumber": ref, "SourceName": src.get("source", f"Source {ref}")}) + if "not_hazardous_percent" in src: + info.append({"ReferenceNumber": ref, "Name": "Note", "Value": {"StringWithMarkup": [{"String": + f"This chemical does not meet GHS hazard criteria for {src['not_hazardous_percent']}% (x of y) of all reports."}]}}) + if src.get("pictograms"): + info.append({"ReferenceNumber": ref, "Name": "Pictogram(s)", "Value": {"StringWithMarkup": [ + {"String": "", "Markup": [{"URL": f"https://pubchem.ncbi.nlm.nih.gov/images/ghs/{c}.svg", "Type": "Icon"} + for c in src["pictograms"]]}]}}) + if "reports" in src: + info.append({"ReferenceNumber": ref, "Name": "ECHA C&L Notifications Summary", "Value": {"StringWithMarkup": [ + {"String": f"Aggregated GHS information provided per {src['reports']} reports by companies from 9 notifications."}]}}) section = {"TOCHeading": "GHS Classification", "Information": info} - return {"Record": {"Section": [{"TOCHeading": "Safety and Hazards", "Section": [ + return {"Record": {"Reference": references, "Section": [{"TOCHeading": "Safety and Hazards", "Section": [ {"TOCHeading": "Hazards Identification", "Section": [section]}]}]}} +OFFICIAL = "Regulation (EC) No 1272/2008 of the European Parliament and of the Council" +ECHA = "European Chemicals Agency (ECHA)" + + THF_PROPERTIES = {"PropertyTable": {"Properties": [{ "CID": 8028, "Title": "Tetrahydrofuran", "IUPACName": "oxolane", "MolecularFormula": "C4H8O", "MolecularWeight": "72.11", "SMILES": "C1CCOC1", "InChI": "InChI=1S/C4H8O/c1-2-4-5-3-1/h1-4H2", @@ -570,12 +584,34 @@ def ghs_answer(*sources): class TestPubChem: """eln_common.pubchem with PubChem's answers faked (shapes copied from real ones).""" - def test_pictograms_from_all_sources_are_combined(self, monkeypatch): - fake = FakePubChem({"/pug_view/data/compound/8028/": ghs_answer(["GHS02", "GHS07", "GHS08"], ["GHS07"])}) + def test_without_eu_or_echa_data_other_sources_are_combined(self, monkeypatch): + fake = FakePubChem({"/pug_view/data/compound/8028/": ghs_answer(["GHS02", "GHS07"], ["GHS08"])}) monkeypatch.setattr(pubchem, "requests", fake) assert pubchem.ghs_pictograms(8028) == ["GHS02", "GHS07", "GHS08"] assert fake.calls[0][1]["params"] == {"heading": "GHS Classification"} + def test_official_eu_classification_wins(self, monkeypatch): + # hydrogen peroxide: Japan's NITE lists concentrated grades with more pictograms + monkeypatch.setattr(pubchem, "requests", FakePubChem({"/pug_view/": ghs_answer( + {"source": OFFICIAL, "pictograms": ["GHS03", "GHS05", "GHS07"]}, + {"source": ECHA, "pictograms": ["GHS03", "GHS05", "GHS07"], "reports": 1964, "not_hazardous_percent": 0.1}, + {"source": "NITE-CMC", "pictograms": ["GHS03", "GHS05", "GHS06", "GHS07", "GHS08", "GHS09"]})})) + assert pubchem.ghs_pictograms(784) == ["GHS03", "GHS05", "GHS07"] + + def test_echa_main_summary_saying_not_hazardous_means_none(self, monkeypatch): + # water: 99.5% of 1876 reports say not hazardous; a 39-report group (another substance) says GHS07 + monkeypatch.setattr(pubchem, "requests", FakePubChem({"/pug_view/": ghs_answer( + {"source": ECHA, "reports": 1876, "not_hazardous_percent": 99.5}, + {"source": ECHA, "pictograms": ["GHS07"], "reports": 39})})) + assert pubchem.ghs_pictograms(962) == [] + + def test_echa_main_summary_is_used_without_eu_classification(self, monkeypatch): + monkeypatch.setattr(pubchem, "requests", FakePubChem({"/pug_view/": ghs_answer( + {"source": ECHA, "pictograms": ["GHS02", "GHS07"], "reports": 500, "not_hazardous_percent": 2}, + {"source": ECHA, "pictograms": ["GHS06"], "reports": 3}, + {"source": "NITE-CMC", "pictograms": ["GHS09"]})})) + assert pubchem.ghs_pictograms(1) == ["GHS02", "GHS07"] + def test_classified_without_pictograms_is_empty_list(self, monkeypatch): # like glucose: PubChem has a GHS section but no source gives a pictogram monkeypatch.setattr(pubchem, "requests", FakePubChem({"/pug_view/": ghs_answer()})) From e0543d862bb9dc93883ed5d20c799a945011ce6b Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 15:19:03 -0400 Subject: [PATCH 17/26] Add-bottle page, part 6: print the new bottles' labels After creating, "Print label(s)" sends the new ids to POST /print and opens the PDF in a new tab (opened on the click, since browsers block tabs opened after a wait; a link is shown if pop-ups are blocked). /print now makes the PDF in memory instead of writing the shared static/print.pdf, so two people printing at once can't get each other's labels, and answers 400 for bad ids and 401 without an API key instead of failing with 500. Co-Authored-By: Claude Opus 5.5 --- automations/labels/generate_label.py | 7 ++++++ static/add_bottle.js | 36 +++++++++++++++++++++++++++- tests/test_routes_offline.py | 23 ++++++++++++++++++ web/interface.py | 27 ++++++++++++++------- web/print_handling.py | 12 ++++------ 5 files changed, 88 insertions(+), 17 deletions(-) diff --git a/automations/labels/generate_label.py b/automations/labels/generate_label.py index 2775bb6..5a13054 100644 --- a/automations/labels/generate_label.py +++ b/automations/labels/generate_label.py @@ -37,6 +37,13 @@ def add_item(self, id: int): ) ) + def pdf(self) -> bytes: + """The labels added so far as PDF data, without writing a file (so two people + printing at the same time can't get each other's labels).""" + data = self.label_writer.write_labels(self.records, target=None) + self.records = [] + return data + # generates pdf for all labels in records def write_labels(self, target: str | None = None): self.label_writer.write_labels(self.records, target=target or self.path) diff --git a/static/add_bottle.js b/static/add_bottle.js index 3188197..65de2c7 100644 --- a/static/add_bottle.js +++ b/static/add_bottle.js @@ -5,6 +5,7 @@ // POST /storage_units -> add a new place (the server refuses near-duplicate names) // POST /resources -> create the bottle(s) // GET /compounds/pubchem, POST /compounds -> find a missing compound in PubChem and add it +// POST /print -> the new bottles' labels as a PDF // The user's API key travels by itself in the apiKey cookie. // what the page knows, filled once when it opens @@ -649,10 +650,43 @@ function showCreated(data) { kind: problems.length ? "" : "ok", links, items: problems.map((p) => `⚠ ${p} (finish this in eLabFTW)`), - buttons: [{ text: "Add another bottle", onClick: () => window.location.reload() }], + buttons: [ + { text: bottles.length === 1 ? "Print label" : `Print ${bottles.length} labels`, + onClick: () => printLabels(bottles.map((b) => b.id)) }, + { text: "Add another bottle", onClick: () => window.location.reload(), secondary: true }, + ], }); } +// POST /print and show the PDF in a new tab, to print on the label printer +async function printLabels(ids) { + // browsers block tabs opened after a wait, so open it now (empty) and fill it in below + const tab = window.open("", "_blank"); + try { + const response = await fetch("/print", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: ids }), + }); + if (!response.ok) throw new Error((await response.json()).error || `status ${response.status}`); + const pdfUrl = URL.createObjectURL(await response.blob()); + if (tab) { + tab.location = pdfUrl; + } else { + // pop-ups are blocked: offer a link instead + const link = Object.assign(document.createElement("a"), { + href: pdfUrl, target: "_blank", download: "labels.pdf", textContent: "Open the labels (PDF)", + }); + const line = Object.assign(document.createElement("div"), { className: "hint" }); + line.append("Your browser blocked the new tab: ", link); + document.getElementById("create-message").append(line); + } + } catch (e) { + tab?.close(); + alert(`Printing the labels failed: ${e.message}. Print them later from the start page (scan or type the numbers, then "print").`); + } +} + // ---------- start ---------- async function start() { diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index cae5768..4bd9694 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -332,6 +332,29 @@ def test_missing_received_field_leaves_date_blank(self): gen.add_item(393) assert gen.records[0]["received_date"] == "" + def test_pdf_is_made_in_memory(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + rm = FakeRM(item={"id": 624, "title": "Hydrogen peroxide 30%", "category": 2, + "metadata": json.dumps({"extra_fields": {"Received": {"value": "2026-10-05"}}})}) + gen = LabelGenerator(rm) # type: ignore[arg-type] + gen.add_item(624) + assert gen.pdf().startswith(b"%PDF") + assert gen.records == [] and list(tmp_path.iterdir()) == [] + + def test_print_route_returns_pdf(self, client, monkeypatch): + rm = FakeRM(item={"id": 624, "title": "t", "category": 2, "metadata": None}) + monkeypatch.setattr(interface, "rm", lambda: rm) + resp = client.post("/print", json={"id": [624, "625"]}) + assert resp.status_code == 200 and resp.mimetype == "application/pdf" + assert resp.data.startswith(b"%PDF") + + @pytest.mark.parametrize("body", [{"id": "624"}, {"id": ["abc"]}, {}]) + def test_print_bad_ids_are_400(self, client, body): + assert client.post("/print", json=body).status_code == 400 + + def test_print_needs_an_api_key(self, client): + assert client.post("/print", json={"id": [624]}).status_code == 401 + def test_null_metadata_leaves_date_blank(self): rm = FakeRM(item={"id": 393, "title": "t", "category": 2, "metadata": None}) gen = LabelGenerator(rm) # type: ignore[arg-type] diff --git a/web/interface.py b/web/interface.py index 2d9bd33..91548cf 100644 --- a/web/interface.py +++ b/web/interface.py @@ -1,7 +1,8 @@ +import io import json from datetime import datetime -from flask import Blueprint, current_app, jsonify, request, send_from_directory +from flask import Blueprint, current_app, jsonify, request, send_file, send_from_directory from flask_cors import cross_origin from requests import HTTPError @@ -403,17 +404,27 @@ def search(): @interface_bp.route('/print', methods=['POST']) @cross_origin(origins="http://localhost:8000") def print_registry(): - data = request.get_json() + """The labels for {id: [resource ids]} as one PDF, for the page to open and print.""" + data = request.get_json(force=True, silent=True) or {} ids = data.get('id', []) - if len(ids) == 0: - return jsonify({"error": "No IDs provided"}), 400 if not isinstance(ids, list): return jsonify({"error": "Expected a list of IDs"}), 400 - - print_handling.add_item(rm(), [int(x) for x in ids]) + if len(ids) == 0: + return jsonify({"error": "No IDs provided"}), 400 + try: + ids = [int(x) for x in ids] + except (TypeError, ValueError): + return jsonify({"error": "IDs must be numbers"}), 400 + try: + rmn = rm() + except ValueError as e: + return jsonify({"error": str(e)}), 401 + try: + pdf = print_handling.labels_pdf(rmn, ids) + except Exception as e: + return jsonify({"error": f"Making the labels failed: {e}"}), 500 print("Printing items with IDs:", ids) - - return send_from_directory(current_app.static_folder, "print.pdf") # type: ignore + return send_file(io.BytesIO(pdf), mimetype="application/pdf", download_name="labels.pdf") @interface_bp.route('/associate', methods=['POST']) diff --git a/web/print_handling.py b/web/print_handling.py index e0bc71e..cd3ff15 100644 --- a/web/print_handling.py +++ b/web/print_handling.py @@ -1,15 +1,11 @@ -from pathlib import Path - from automations.labels.generate_label import LabelGenerator from eln_common.resourcemanage import Resource_Manager -temp_path = str(Path(__file__).parent.parent / "static" / "print.pdf") - -def add_item(rm: Resource_Manager, ids: list[int]): - """Generates the labels for the given items on the fly (rather than fetching - the label.pdf stored on each resource) and writes them to static/print.pdf.""" +def labels_pdf(rm: Resource_Manager, ids: list[int]) -> bytes: + """The labels for the given items as one PDF, generated on the fly from their + current data (rather than fetching the label.pdf stored on each resource).""" labelgen = LabelGenerator(rm) for id in ids: labelgen.add_item(id) - labelgen.write_labels(target=temp_path) + return labelgen.pdf() From 1b8e04bda979a9588075e8aaa520f64ab11aaa84 Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 16:21:44 -0400 Subject: [PATCH 18/26] Remove the old add-resource form and the code only it used The add-bottle page replaces the 16-field form, so this removes add_resource.html, render_form.js, the /template, /search, /add_resource and /add_option routes, search_process.py and fill_info.py (check_if_cas moves to pubchem.py), and pubchempy. Also removed: scripts/add_all_compounds.py, which used eLabFTW's upserting PubChem import, and associate_compounds.py, which used a link path eLabFTW does not have, with the Resource_Manager methods only they and the old form used. The start page's button opens the new page and the old address redirects there. /create_label now makes its PDF in memory like /print. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- eln_common/fill_info.py | 138 --------- eln_common/pubchem.py | 7 +- eln_common/resourcemanage.py | 30 -- requirements.txt | 1 - scripts/add_all_compounds.py | 12 - scripts/associate_compounds.py | 20 -- static/add_resource.html | 175 ------------ static/index.html | 2 +- static/render_form.js | 503 --------------------------------- tests/README.md | 2 +- tests/test_live_eln.py | 61 ---- tests/test_routes_offline.py | 119 ++------ web/interface.py | 118 +------- web/label_creating.py | 8 +- web/search_process.py | 33 --- 16 files changed, 35 insertions(+), 1196 deletions(-) delete mode 100644 eln_common/fill_info.py delete mode 100644 scripts/add_all_compounds.py delete mode 100644 scripts/associate_compounds.py delete mode 100644 static/add_resource.html delete mode 100644 static/render_form.js delete mode 100644 web/search_process.py diff --git a/README.md b/README.md index 1e4e2c9..8575c59 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ To install an eLabFTW instance, follow the eLabFTW installation instructions at | Directory | Contents | |---|---| | `app.py` | Flask entry point; registers blueprints (gunicorn target `app:app`) | -| `eln_common/` | eLab API wrapper (`Resource_Manager`, `config`, `fill_info`) — formerly `eln_packages_common` | +| `eln_common/` | eLab API wrapper (`Resource_Manager`, `config`, `pubchem`, `compound_import`, `add_bottle`...) — formerly `eln_packages_common` | | `automations/` | label generation, RDKit images, peroxide checks, Slack bot — formerly `eln_packages_backend` | | `web/` | Flask blueprints: `interface` (the UI routes) and `automation_api` (`/api/...`) | | `static/` | Web UI pages and label templates | diff --git a/eln_common/fill_info.py b/eln_common/fill_info.py deleted file mode 100644 index 782c4bf..0000000 --- a/eln_common/fill_info.py +++ /dev/null @@ -1,138 +0,0 @@ -import pubchempy as pcp -import json -from rdkit import Chem -from eln_common.resourcemanage import Resource_Manager - -# CAS numbers fall in the 'name' category on pubchem, so they are searched as names -# you could also search by the common name or any other synonym, however CAS numbers -# should return more consistent results -def get_compound(CAS) -> pcp.Compound: - compound_list: list[pcp.Compound] = pcp.get_compounds(CAS, "name") - if len(compound_list) > 1: - raise ValueError( - "Multiple compounds with this name have been found, please input a more specific name or CAS number" - ) - elif len(compound_list) == 0: - raise ValueError("No compound with this CAS/name has been found") - compound: pcp.Compound = compound_list[0] - return compound - -def check_if_cas(input: str) -> bool: - parts = input.split('-') - if len(parts) != 3: - return False - if not (parts[0].isdigit() and parts[1].isdigit() and parts[2].isdigit()): - return False - if len(parts[0]) < 2 or len(parts[0]) > 7: - return False - if len(parts[1]) != 2: - return False - if len(parts[2]) != 1: - return False - return True - -def find_cas(input:list) -> str: - for item in input: - if check_if_cas(item): - return item - return "" - -def canonicalize_smiles(smiles: str) -> str: - # converts the input SMILES string into a Mol object to be interpreted by RDKit - # then returns the canonicalized SMILES - if smiles is None or smiles == "": # stupid workaround for NoneType error, thanks PubChem :( - return "PubChem Error, could not fetch SMILES" - mol = Chem.MolFromSmiles(smiles) - return Chem.MolToSmiles(mol) - -def pull_values(searchquery: str) -> dict: - compound: pcp.Compound = get_compound(searchquery) - values:dict = { - "Title_0": compound.synonyms[0], - "Full name": compound.iupac_name, - "SMILES": canonicalize_smiles(compound.isomeric_smiles), - "Molecular Weight": compound.molecular_weight, - "Pubchem Link": f"https://pubchem.ncbi.nlm.nih.gov/compound/{compound.cid}", - "Hazards Link": f"https://pubchem.ncbi.nlm.nih.gov/compound/{compound.cid}#section=Hazards-Identification", - } - if not check_if_cas(searchquery): - values.update({"CAS": find_cas(compound.synonyms)}) - return values - -def fill_in(rm: Resource_Manager, id: int): - body = rm.get_item(id) - body = get_filled_dictionary(body) - rm.change_item(id, body) - -def get_filled_dictionary(body: dict) -> dict: - metadata: dict = json.loads(body["metadata"]) - new_title: str = body["title"] - if check_if_cas(body["title"]): - # if the title is a CAS number, search by CAS number, and replace the title with the first synonym on PubChem - CAS: str = body["title"] - values: dict = pull_values(body["title"]) - new_title = values["Title_0"] - elif "CAS" in metadata["extra_fields"] and metadata["extra_fields"]["CAS"]["value"] != "": - # if the title is not a CAS but there is a CAS in the metadata, search by that CAS - CAS = metadata["extra_fields"]["CAS"]["value"] - values: dict = pull_values(CAS) - else: - # otherwise try to search by the non-CAS title - values: dict = pull_values(body["title"]) - CAS = "" - if "Full name" not in metadata["extra_fields"]: - # if there isn't a Full name field, create one - metadata["extra_fields"]["Full name"] = { - "type": "text", - "value": "", - "description": "From PubChem", - } - metadata["extra_fields"]["Full name"]["value"] = values["Full name"] - - if "SMILES" not in metadata["extra_fields"]: - # if there isn't a SMILES field, create one - metadata["extra_fields"]["SMILES"] = { - "type": "text", - "value": "", - "description": "From PubChem", - } - metadata["extra_fields"]["SMILES"]["value"] = values["SMILES"] - if "CAS" not in metadata["extra_fields"]: - # if there isn't a CAS field, create one - metadata["extra_fields"]["CAS"] = { - "type": "text", - "value": "", - "description": "", - } - metadata["extra_fields"]["CAS"]["value"] = CAS - if "Molecular Weight" not in metadata["extra_fields"]: - # if there isn't a molecular weight field, create one #TODO: make this a number - metadata["extra_fields"]["Molecular Weight"] = { - "type": "text", - "value": "", - "description": "From PubChem (g/mol)", - } - metadata["extra_fields"]["Molecular Weight"]["value"] = values["Molecular Weight"] - if "Pubchem Link" not in metadata["extra_fields"]: - # if there isn't a Pubchem link field, create one - metadata["extra_fields"]["Pubchem Link"] = { - "type": "url", - "value": "", - "description": "Link to PubChem page", - } - metadata["extra_fields"]["Pubchem Link"]["value"] = values["Pubchem Link"] - if "Hazards Link" not in metadata["extra_fields"]: - # if there isn't a hazards link field, create one - metadata["extra_fields"]["Hazards Link"] = { - "type": "url", - "value": "", - "description": "Link to Hazards section of PubChem", - } - metadata["extra_fields"]["Hazards Link"]["value"] = values["Hazards Link"] - new_body = { - "title": new_title, - "category": body["category"], - "metadata": json.dumps(metadata), - "rating": 0, # before i figured out tags I used this to mark autofilled items, no longer necessary. this will remove ratings - } - return new_body \ No newline at end of file diff --git a/eln_common/pubchem.py b/eln_common/pubchem.py index 8ee1c06..d637e81 100644 --- a/eln_common/pubchem.py +++ b/eln_common/pubchem.py @@ -15,14 +15,17 @@ import requests -from eln_common.fill_info import check_if_cas - PUG = "https://pubchem.ncbi.nlm.nih.gov/rest/pug" PUG_VIEW = "https://pubchem.ncbi.nlm.nih.gov/rest/pug_view" PROPERTIES = "Title,IUPACName,MolecularFormula,MolecularWeight,SMILES,InChI,InChIKey" TIMEOUT = 30 +def check_if_cas(text: str) -> bool: + """Whether the text is shaped like a CAS number: 2-7 digits, 2 digits, 1 digit (e.g. 109-99-9).""" + return re.fullmatch(r"\d{2,7}-\d{2}-\d", text) is not None + + def _json(response: requests.Response) -> dict[str, Any] | None: """The answer as JSON, or None when PubChem has no such record (404 NotFound).""" if response.status_code == 404: diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index 3bf7da1..6832378 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -44,21 +44,6 @@ def patch_url(self,url:str,json:dict|None=None) -> requests.Response: """ url = config.URL + url return requests.patch(url, headers=self.header, json=json) - def create_item(self, template: int, body_dict: dict[str, Any]) -> int: - """ - Creates an item in the ELN from the given resource template, then applies body_dict. - Starting from the template gives the item the template's category and default - status, and still works when the team enforces template use. - :param int template: The ID of the resource template (items_types) to create from. - Template IDs are team-specific; list them with get_items_types(). - :param dict body_dict: The body of the item to be created. - :return: The ID of the newly created item. - """ - item_id = self.create_item_from_template(template) - # the template already set the right category; callers pass the template ID there - self.change_item(item_id, {k: v for k, v in body_dict.items() if k != "category"}) - return item_id - def create_item_from_template(self, template: int) -> int: """ Creates an item from the given resource template, with the template's category, @@ -121,21 +106,6 @@ def experiment_item_link(self, experiment_id: int, item_id: int): except (config.elabapi_python.rest.ApiException, requests.HTTPError): raise ValueError("Experiment or item does not exist") self.post_url(url) - def find_and_create_compound(self, CAS:str): - """ - Finds a compound in the ELN with the given CAS number and creates it if it does not exist. - :param str CAS: The CAS number of the compound to be found. - :return: The ID of the compound. - """ - self.post_url("/compounds/", json={"action":"duplicate", "cas":CAS}) - def associate_compound(self, comp_id:int,res_id:int): - """ - Associates a compound with the given CAS number to an item in the ELN with the given ID. - :param str CAS: The CAS number of the compound to be associated. - :param int id: The ID of the item to be associated with. - """ - self.post_url("/items/" + str(res_id) + "/compounds/" + str(comp_id)) - def get_compounds(self): """ Gets every (non-deleted) compound in the ELN as dictionaries. diff --git a/requirements.txt b/requirements.txt index 1889e8c..1349f54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,6 @@ urllib3 pandas pypdf tabulate -pubchempy elabapi_python==5.6.0 pyyaml blabel diff --git a/scripts/add_all_compounds.py b/scripts/add_all_compounds.py deleted file mode 100644 index eee4f88..0000000 --- a/scripts/add_all_compounds.py +++ /dev/null @@ -1,12 +0,0 @@ -"""One-off script: create eLab compounds for every valid CAS in the inventory. Uses the local api_key file.""" -from eln_common.fill_info import check_if_cas -from eln_common.resourcemanage import Resource_Manager - -rm = Resource_Manager() - -if __name__ == "__main__": - df = rm.get_items_df(size=1000) - for d in df["CAS"]: - if check_if_cas(str(d)): - print(d) - rm.find_and_create_compound(CAS=str(d)) diff --git a/scripts/associate_compounds.py b/scripts/associate_compounds.py deleted file mode 100644 index fbe4e47..0000000 --- a/scripts/associate_compounds.py +++ /dev/null @@ -1,20 +0,0 @@ -"""One-off script: link items to eLab compounds by matching CAS numbers. Uses the local api_key file.""" -from eln_common.fill_info import check_if_cas -from eln_common.resourcemanage import Resource_Manager - -rm = Resource_Manager() - -if __name__ == "__main__": - items = rm.get_items_df(size=1000)[["id", "CAS"]] - comps = rm.get_compounds_df()[["id", "cas_number"]] - - for _, item_row in items.iterrows(): - cas = str(item_row["CAS"]).strip() - resource_id = item_row["id"] - - if check_if_cas(cas): - # Find matching compound row(s) by CAS - matching_comps = comps[comps["cas_number"].astype(str).str.strip() == cas] - print(f"Found {len(matching_comps)} matching compounds for CAS: {cas} and resource ID: {resource_id}") - for _, comp_row in matching_comps.iterrows(): - rm.associate_compound(comp_row["id"], resource_id) diff --git a/static/add_resource.html b/static/add_resource.html deleted file mode 100644 index 97be5cd..0000000 --- a/static/add_resource.html +++ /dev/null @@ -1,175 +0,0 @@ - - - - - - Resource Entry Form - - - -
-

Resource Entry Form

- -
-
- - -
-
-
Go Back
-
-
- - - - - - - - - - - - diff --git a/static/index.html b/static/index.html index f0d2653..dbc9901 100644 --- a/static/index.html +++ b/static/index.html @@ -510,7 +510,7 @@

Locations

clearRegistry(); success(); } else if (val === "add resource") { - window.location.href = "/add_resource_interface"; + window.location.href = "/add_bottle_interface"; } else if (val === "custom label") { window.location.href = "/label_gen_interface"; } diff --git a/static/render_form.js b/static/render_form.js deleted file mode 100644 index f8ab33f..0000000 --- a/static/render_form.js +++ /dev/null @@ -1,503 +0,0 @@ -const BASE_URL = ""; // adjust as needed -// select value meaning "the user typed their own value in the Other box" -const OTHER_SENTINEL = "__other__"; -const formEl = document.getElementById("generated-form"); -let currentFields = []; -let elnWebUrl = ""; // the lab's eLabFTW web UI, served by /eln_config -let resourceCategories = []; // [{id, title}] from /categories (the team's own list) - -function setCookie(name, value) { - const expires = "Fri, 31 Dec 9999 23:59:59 GMT"; - document.cookie = `${name}=${encodeURIComponent( - value - )}; expires=${expires}; path=/`; -} - -function getCookie(name) { - const match = document.cookie - .split("; ") - .find((row) => row.startsWith(name + "=")); - return match && decodeURIComponent(match.split("=")[1]); -} - -function getFinalOutput() { - const formData = new FormData(formEl); - const extra_fields = {}; - currentFields.forEach(([key, field]) => { - const copy = structuredClone(field); - let value = formData.get(key) || ""; - if (value === OTHER_SENTINEL) { - value = (formData.get(`${key}__other`) || "").trim(); - } - copy.value = value; - extra_fields[key] = copy; - }); - return { - title: formData.get("title") || "", - body: formData.get("body") || "", - category: parseInt(formData.get("category"), 10) || null, - extra_fields, - }; -} - -function getOptionAdditions() { - // Fields where "Other" is selected with "Add to options?" checked: - // [{field, option}] to send to /add_option so the template learns them. - const formData = new FormData(formEl); - const additions = []; - currentFields.forEach(([key, field]) => { - if (field.type !== "select") return; - if (formData.get(key) !== OTHER_SENTINEL) return; - if (!formData.get(`${key}__addopt`)) return; - const option = (formData.get(`${key}__other`) || "").trim(); - if (option) additions.push({ field: key, option }); - }); - return additions; -} - -function fetchTemplate(category) { - const url = category - ? `${BASE_URL}/template?category=${category}` - : `${BASE_URL}/template`; - return fetch(url).then((res) => { - if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); - return res.json(); - }); -} - -function buildFormFromJson(jsonData) { - formEl.innerHTML = ""; - console.log("Rendering schema:", jsonData); - // API Key - const apiKey = getCookie("apiKey"); - if (!apiKey) { - alert( - "No API key found. Please paste an API key to continue. To generate your own, navigate to " + - (elnWebUrl - ? `${elnWebUrl}/ucp.php?tab=3` - : "the API keys tab of your eLabFTW user settings") - ); - const wrapper = document.createElement("div"); - wrapper.className = "field"; - const label = document.createElement("label"); - label.htmlFor = "apiKey"; - label.textContent = "API Key"; - const input = document.createElement("input"); - input.type = "text"; - input.id = "apiKey"; - input.name = "apiKey"; - input.required = true; - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = "Save Key"; - btn.className = "save-btn"; - btn.addEventListener("click", () => { - const key = input.value.trim(); - if (key) { - setCookie("apiKey", key); - location.reload(); - } else alert("Enter a valid API key."); - }); - wrapper.append(label, input, btn); - formEl.append(wrapper); - } - - // Category - const categoryWrapper = document.createElement("div"); - categoryWrapper.className = "field"; - const categoryLabel = document.createElement("label"); - categoryLabel.htmlFor = "category"; - categoryLabel.innerHTML = 'Category *'; - const categorySelect = document.createElement("select"); - categorySelect.id = "category"; - categorySelect.name = "category"; - categorySelect.required = true; - const placeholder = document.createElement("option"); - placeholder.value = ""; - placeholder.disabled = true; - placeholder.selected = true; - placeholder.hidden = true; - placeholder.textContent = "Select a category"; - categorySelect.append(placeholder); - resourceCategories.forEach((cat) => { - const o = document.createElement("option"); - o.value = cat.id; - o.textContent = cat.title; - categorySelect.append(o); - }); - categorySelect.value = jsonData.category || ""; - categorySelect.addEventListener("change", () => { - const selected = categorySelect.value; - const previousData = getFinalOutput(); - fetchTemplate(selected) - .then((newSchema) => { - // Inject previous values where keys match - Object.entries(newSchema.extra_fields || {}).forEach(([key, field]) => { - if ( - previousData.extra_fields[key] && - previousData.extra_fields[key].value - ) { - field.value = previousData.extra_fields[key].value; - } - }); - - // Also preserve title and body if present - newSchema.title = previousData.title; - newSchema.body = previousData.body; - newSchema.category = selected; - - buildFormFromJson(newSchema); - }) - .catch((e) => alert(e.message)); - }); - - categoryWrapper.append(categoryLabel, categorySelect); - formEl.append(categoryWrapper); - - // CAS Search - const searchWrapper = document.createElement("div"); - searchWrapper.id = "cas-search-wrapper"; - searchWrapper.className = "field"; - // searchWrapper.style.display = "flex"; - searchWrapper.style.gap = "0.5em"; - // Determine visibility of CAS search - const catVal = jsonData.category; - if (catVal && catVal !== "1") { - searchWrapper.style.display = "flex"; - } else { - searchWrapper.style.display = "none"; -} - - const searchInput = document.createElement("input"); - searchInput.type = "text"; - searchInput.placeholder = "Search by CAS"; - const searchBtn = document.createElement("button"); - searchBtn.type = "button"; - searchBtn.textContent = "Search"; - searchBtn.className = "search-btn"; - searchBtn.addEventListener("click", () => { - const cas = searchInput.value.trim(); - if (!cas) return alert("Enter a CAS."); - fetch(`${BASE_URL}/search`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ CAS: cas, template: getFinalOutput() }), - credentials: "same-origin", - }) - .then((r) => r.json()) - .then((data) => { - if (data && data.extra_fields && !data.error) { - // preserve currently selected category - const selected = formEl.querySelector("#category")?.value; - if (selected) { - data.category = selected; // override category from response - } - buildFormFromJson(data); - } else if (data.error) { - alert(`Error: ${data.error}`); - } - else { - alert("Invalid response"); - } - }) - - .catch((e) => alert(e.message)); - }); - searchWrapper.append(searchInput, searchBtn); - formEl.append(searchWrapper); - - // Title - const titleWrapper = document.createElement("div"); - titleWrapper.className = "field"; - const titleLabel = document.createElement("label"); - titleLabel.htmlFor = "title"; - titleLabel.innerHTML = 'Title *'; - const titleInput = document.createElement("input"); - titleInput.type = "text"; - titleInput.id = "title"; - titleInput.name = "title"; - titleInput.required = true; - titleInput.value = jsonData.title || ""; // ✅ SET VALUE - titleWrapper.append(titleLabel, titleInput); - formEl.append(titleWrapper); - - // Body - const bodyWrapper = document.createElement("div"); - bodyWrapper.className = "field"; - const bodyLabel = document.createElement("label"); - bodyLabel.htmlFor = "body"; - bodyLabel.innerHTML = "Body"; - const bodyInput = document.createElement("textarea"); - bodyInput.id = "body"; - bodyInput.name = "body"; - bodyInput.rows = 5; - bodyInput.value = jsonData.body || ""; // ✅ SET VALUE - bodyWrapper.append(bodyLabel, bodyInput); - formEl.append(bodyWrapper); - - // Extra fields - let fields = {}; - if (jsonData.extra_fields) { - fields = jsonData.extra_fields; - } else if (jsonData.metadata) { - try { - const parsed = JSON.parse(jsonData.metadata); - if (parsed.extra_fields) { - fields = parsed.extra_fields; - } - } catch (e) { - console.error("Failed to parse metadata:", e); - } - } - - currentFields = Object.entries(fields).sort( - (a, b) => (a[1].position || 0) - (b[1].position || 0) - ); - - currentFields.forEach(([key, field]) => { - const wrapper = document.createElement("div"); - wrapper.className = "field"; - - const label = document.createElement("label"); - label.htmlFor = key; - label.innerHTML = field.required - ? `${key} *` - : key; - wrapper.append(label); - - let input; - let otherControls = null; // "Other" text box + "Add to options?" checkbox (selects only) - let otherNote = null; // shown while "Add to options?" is checked - let applySelectValue = null; // sets a select's value, routing unknown values to "Other" - if (field.type === "select") { - input = document.createElement("select"); - (field.options || []).forEach((opt) => { - const o = document.createElement("option"); - o.value = opt; - o.textContent = opt; - input.append(o); - }); - const otherOption = document.createElement("option"); - otherOption.value = OTHER_SENTINEL; - otherOption.textContent = "Other…"; - input.append(otherOption); - - const otherInput = document.createElement("input"); - otherInput.type = "text"; - otherInput.name = `${key}__other`; - otherInput.placeholder = "Enter other value"; - otherInput.disabled = true; - // let the flex row shrink the input instead of overflowing the wrapper - otherInput.style.flex = "1 1 auto"; - otherInput.style.minWidth = "0"; - otherInput.style.width = "auto"; - - const addLabel = document.createElement("label"); - addLabel.style.fontWeight = "normal"; - addLabel.style.whiteSpace = "nowrap"; - addLabel.style.display = "flex"; - addLabel.style.alignItems = "center"; - addLabel.style.gap = "0.4em"; - addLabel.style.marginBottom = "0"; - const addCheckbox = document.createElement("input"); - addCheckbox.type = "checkbox"; - addCheckbox.name = `${key}__addopt`; - addCheckbox.disabled = true; - // opt out of the page's full-width/padded input styling - addCheckbox.style.width = "auto"; - addCheckbox.style.boxShadow = "none"; - addCheckbox.style.padding = "0"; - addLabel.append(addCheckbox, document.createTextNode(" Add to options?")); - - otherControls = document.createElement("div"); - otherControls.style.display = "none"; - otherControls.style.gap = "0.5em"; - otherControls.style.alignItems = "center"; - otherControls.style.marginTop = "0.5em"; - otherControls.append(otherInput, addLabel); - - otherNote = document.createElement("div"); - otherNote.className = "description"; - otherNote.style.display = "none"; - otherNote.textContent = - "Only adds the option for the selected category (e.g. Chemical Compound locations are managed independently from Polymer locations)."; - - const syncOtherVisibility = () => { - const isOther = input.value === OTHER_SENTINEL; - otherControls.style.display = isOther ? "flex" : "none"; - // disabled inputs stay out of FormData and skip validation - otherInput.disabled = !isOther; - addCheckbox.disabled = !isOther; - otherInput.required = isOther && !!field.required; - otherNote.style.display = isOther && addCheckbox.checked ? "" : "none"; - }; - addCheckbox.addEventListener("change", syncOtherVisibility); - input.addEventListener("change", () => { - syncOtherVisibility(); - if (input.value === OTHER_SENTINEL) otherInput.focus(); - }); - - applySelectValue = (value) => { - input.value = value; - if (value && input.value !== value) { - // value isn't one of the options: it came from "Other" - input.value = OTHER_SENTINEL; - otherInput.value = value; - } - syncOtherVisibility(); - }; - } else if (field.type === "date") { - input = document.createElement("input"); - input.type = "date"; - - const todayBtn = document.createElement("button"); - todayBtn.type = "button"; - todayBtn.textContent = "Today"; - todayBtn.className = "today-btn"; - todayBtn.addEventListener("click", () => { - input.value = new Date().toISOString().split("T")[0]; - }); - - const cnt = document.createElement("div"); - cnt.style.display = "flex"; - cnt.style.gap = "0.5em"; - cnt.append(input, todayBtn); - wrapper.append(cnt); - } else { - input = document.createElement("input"); - input.type = field.type || "text"; - wrapper.append(input); - } - - input.id = key; - input.name = key; - if (applySelectValue) applySelectValue(field.value || ""); - else input.value = field.value || ""; - if (field.required) input.required = true; - - if (field.type !== "date") wrapper.append(input); - if (otherControls) wrapper.append(otherControls, otherNote); - - if (field.type === "number") { - input.type = "number"; - input.step = "any"; // allow decimals - } - - if (Array.isArray(field.units) && field.units.length > 0) { - const unitSelect = document.createElement("select"); - unitSelect.name = `${key}_unit`; - - field.units.forEach((unitOpt) => { - const opt = document.createElement("option"); - opt.value = unitOpt; - opt.textContent = unitOpt; - unitSelect.append(opt); - }); - - // Preselect the current value (if specified) - if (field.unit) { - unitSelect.value = field.unit; - } - - wrapper.append(unitSelect); - } - - if (field.description) { - const d = document.createElement("div"); - d.className = "description"; - d.textContent = field.description; - wrapper.append(d); - } - - formEl.append(wrapper); - }); - - // Submit - const submit = document.createElement("button"); - submit.type = "submit"; - submit.textContent = "Submit"; - formEl.append(submit); - formEl.onsubmit = (e) => { - e.preventDefault(); - - // Custom conditional requirement: one of Mw or Mn must be filled - const mwInput = formEl.querySelector('[name="Mw"]'); - const mnInput = formEl.querySelector('[name="Mn"]'); - - const mwValue = mwInput ? mwInput.value.trim() : ""; - const mnValue = mnInput ? mnInput.value.trim() : ""; - - if (mwInput && mnInput && !mwValue && !mnValue) { - alert("Either 'Mw' or 'Mn' must be filled."); - if (mwInput) mwInput.focus(); - return; - } - - // Continue normal submission - const payload = getFinalOutput(); - const optionAdditions = getOptionAdditions(); - - fetch(`${BASE_URL}/add_resource`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload, null, 2), - credentials: "same-origin", - }) - .then((res) => { - if (!res.ok) throw new Error(`Server responded with ${res.status}`); - return res.json(); - }) - .then((responseData) => { - console.log("Submission succeeded:", responseData); - // alert("Form submitted successfully."); - if (optionAdditions.length === 0) return; - // The resource exists now; teach the category template the new options - return Promise.all( - optionAdditions.map((a) => - fetch(`${BASE_URL}/add_option`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ category: payload.category, ...a }), - credentials: "same-origin", - }).then((r) => { - if (!r.ok) - return r.json().then((d) => { - throw new Error(d.error || `Server responded with ${r.status}`); - }); - }) - ) - ).catch((err) => { - console.error("Option update error:", err); - alert( - `Resource was created, but adding the new option(s) to the template failed: ${err.message}` - ); - }); - }) - .then(() => location.reload()) - .catch((err) => { - console.error("Submission error:", err); - alert("Error submitting form."); - }); - }; -} - -// Initial load: learn the instance's web URL and category list first -Promise.all([ - fetch(`${BASE_URL}/eln_config`) - .then((res) => (res.ok ? res.json() : {})) - .then((cfg) => { - elnWebUrl = cfg.eln_web_url || ""; - }) - .catch(() => {}), - fetch(`${BASE_URL}/categories`) - .then((res) => (res.ok ? res.json() : [])) - .then((cats) => { - resourceCategories = Array.isArray(cats) ? cats : []; - }) - .catch(() => {}), -]) - .then(() => fetchTemplate()) - .then(buildFormFromJson) - .catch((e) => alert(e.message)); diff --git a/tests/README.md b/tests/README.md index 17f90e6..ad85aa7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,7 +5,7 @@ Two kinds of tests: | File | What it covers | Needs | |---|---|---| | `test_routes_offline.py` | Routing, auth-key extraction, input validation, CAS validation, config loading, pure helpers | Nothing external | -| `test_live_eln.py` | Reads, mutations, label printing, the automation API, and `/search` against the **real** eLabFTW server (and PubChem) | An eLN API key | +| `test_live_eln.py` | Reads, mutations, label printing, and the automation API against the **real** eLabFTW server (and PubChem) | An eLN API key | Live tests are marked `@pytest.mark.live` (registered in `pytest.ini`). diff --git a/tests/test_live_eln.py b/tests/test_live_eln.py index bcfc725..bfec5f2 100644 --- a/tests/test_live_eln.py +++ b/tests/test_live_eln.py @@ -48,24 +48,12 @@ def test_item_393_exists(self, live_rm): assert item["id"] == TEST_ITEM_ID assert item["title"] - def test_template_endpoint(self, client, auth_headers, live_rm): - category = int(live_rm.get_item(TEST_ITEM_ID)["category"]) - resp = client.get(f"/template?category={category}", headers=auth_headers) - assert resp.status_code == 200 - template = resp.get_json() - assert int(template["id"]) == category - assert "metadata" in template - def test_get_locations(self, client, auth_headers): resp = client.get("/get_locations", headers=auth_headers) assert resp.status_code == 200 locations = resp.get_json() assert isinstance(locations, list) - def test_template_unknown_category_is_404(self, client, auth_headers): - resp = client.get("/template?category=999999", headers=auth_headers) - assert resp.status_code == 404 - def test_categories_lists_team_categories(self, client, auth_headers): resp = client.get("/categories", headers=auth_headers) assert resp.status_code == 200 @@ -101,12 +89,6 @@ def test_post_settings_updates_config_file(self, client, auth_headers, monkeypat assert config.setting("chemical_categories", []) == [2, 3, 4] assert "## Team-specific IDs" in cfg_copy.read_text() # comments survive - def test_template_with_garbage_key_errors(self, client): - resp = client.get( - "/template?category=2", headers={"Authorization": "not-a-real-key"} - ) - assert resp.status_code == 400 - class TestMutations: def test_mark_open_sets_date_and_rejects_reopen( @@ -164,46 +146,3 @@ def test_print_generates_label_on_the_fly(self, client, auth_headers): resp = client.post("/print", json={"id": [TEST_ITEM_ID]}, headers=auth_headers) assert resp.status_code == 200 assert resp.data.startswith(b"%PDF") - - -class TestSearch: - TEMPLATE = { - "title": "", - "body": "", - "category": 2, - "extra_fields": { - "Full name": {"type": "text", "value": ""}, - "CAS": {"type": "text", "value": ""}, - }, - } - - def test_search_by_cas_fills_fields(self, client): - resp = client.post( - "/search", json={"CAS": "7732-18-5", "template": self.TEMPLATE} - ) - assert resp.status_code == 200 - result = resp.get_json() - fields = result["extra_fields"] - assert fields["CAS"]["value"] == "7732-18-5" - assert "pubchem" in fields["Pubchem Link"]["value"].lower() - assert float(fields["Molecular Weight"]["value"]) == pytest.approx(18.015, abs=0.1) - assert result["title"].lower() == "water" - - @pytest.mark.xfail( - reason="pubchempy cannot read SMILES from the current PubChem schema; " - "fill_info falls back to 'PubChem Error, could not fetch SMILES'", - strict=False, - ) - def test_search_fills_smiles(self, client): - resp = client.post( - "/search", json={"CAS": "7732-18-5", "template": self.TEMPLATE} - ) - assert resp.status_code == 200 - assert resp.get_json()["extra_fields"]["SMILES"]["value"] == "O" - - def test_search_unknown_compound_is_400(self, client): - resp = client.post( - "/search", json={"CAS": "0000000-99-9", "template": self.TEMPLATE} - ) - assert resp.status_code == 400 - assert "error" in resp.get_json() diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 4bd9694..43d3c95 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -14,9 +14,7 @@ import eln_common.pubchem as pubchem import eln_common.storage_places as storage_places import web.interface as interface -import web.search_process as search_process from automations.labels.generate_label import LabelGenerator -from eln_common.fill_info import check_if_cas from eln_common.resourcemanage import Resource_Manager from web.auth import get_key @@ -32,8 +30,16 @@ def test_index_serves_html(self, client): assert resp.status_code == 200 assert b"hi

", - "category": 2, - "extra_fields": {"CAS": {"type": "text", "value": "7732-18-5"}}, - } - - def test_dict_complexify(self): - complexed = search_process.dict_complexify(self.TEMPLATE) - assert complexed["title"] == "Water" - assert complexed["category"] == 2 - assert json.loads(complexed["metadata"])["extra_fields"] == self.TEMPLATE["extra_fields"] - - def test_simplify_inverts_complexify(self): - simplified = search_process.dict_simplify(search_process.dict_complexify(self.TEMPLATE)) - assert simplified == { - "title": "Water", - "extra_fields": self.TEMPLATE["extra_fields"], - } - - class TestCasValidation: @pytest.mark.parametrize("cas", ["7732-18-5", "50-00-0", "1234567-89-1"]) def test_valid_cas(self, cas): - assert check_if_cas(cas) + assert pubchem.check_if_cas(cas) @pytest.mark.parametrize( "not_cas", ["", "water", "7732-18", "7732-185-5", "7732-18-55", "a-bc-d", "7-73-2"] ) def test_invalid_cas(self, not_cas): - assert not check_if_cas(not_cas) + assert not pubchem.check_if_cas(not_cas) class TestConfig: @@ -303,21 +247,16 @@ def test_example_file_has_the_expected_fields(self): class FakeRM: - """Minimal Resource_Manager stand-in for label/creation tests.""" + """Minimal Resource_Manager stand-in for label tests.""" printer_path = "/tmp/label.pdf" - def __init__(self, item: dict | None = None, create_id: int = 999): + def __init__(self, item: dict | None = None): self.item = item - self.create_id = create_id - self.created = [] def get_item(self, id): return self.item - def create_item(self, category, body): - self.created.append((category, body)) - return self.create_id class TestLabelGenerator: @@ -362,25 +301,6 @@ def test_null_metadata_leaves_date_blank(self): assert gen.records[0]["received_date"] == "" -class TestAddResource: - def test_add_resource_creates_item_from_template(self, client, monkeypatch): - fake_rm = FakeRM(create_id=999) - monkeypatch.setattr(interface, "rm", lambda: fake_rm) - resp = client.post("/add_resource", json={ - "title": "new thing", - "body": "", - "category": 2, - "extra_fields": {}, - }) - assert resp.status_code == 200 - assert resp.get_json() == { - "status": "ok", - "received": {"title": "new thing", "body": "", "category": 2, "extra_fields": {}}, - "id": 999, - } - assert fake_rm.created[0][0] == 2 - - class TestLookupLists: """The add-resource page loads existing compounds and storage places into dropdowns.""" @@ -1184,18 +1104,13 @@ class TestCreateItemFromTemplate: """New resources start from their template, so they get its category and default status (items created from a bare category had no status).""" - def test_posts_template_and_patches_the_rest(self): - posted, patched = [], [] + def test_posts_only_the_template(self): + posted = [] rm = Resource_Manager.__new__(Resource_Manager) # skip the API-key setup rm.itemsapi = SimpleNamespace(post_item_with_http_info=lambda body: ( posted.append(body) or (None, 201, {"Location": "https://eln/api/v2/items/615"}))) - rm.change_item = lambda id, body: patched.append((id, body)) - - body = {"title": "Sudan I", "body": "", "category": 2, "metadata": "{}"} - assert rm.create_item(2, body) == 615 + assert rm.create_item_from_template(2) == 615 assert posted == [{"template": 2}] - # category comes from the template, so it isn't patched over - assert patched == [(615, {"title": "Sudan I", "body": "", "metadata": "{}"})] class TestCreateLabel: diff --git a/web/interface.py b/web/interface.py index 91548cf..dfb8051 100644 --- a/web/interface.py +++ b/web/interface.py @@ -2,7 +2,7 @@ import json from datetime import datetime -from flask import Blueprint, current_app, jsonify, request, send_file, send_from_directory +from flask import Blueprint, current_app, jsonify, redirect, request, send_file, send_from_directory from flask_cors import cross_origin from requests import HTTPError @@ -12,10 +12,8 @@ import eln_common.config as config import eln_common.pubchem as pubchem import eln_common.storage_places as storage_places -from eln_common.fill_info import check_if_cas import web.label_creating as label_creating import web.print_handling as print_handling -import web.search_process as search_process from web.auth import rm interface_bp = Blueprint("interface", __name__) @@ -45,7 +43,8 @@ def eln_config(): @interface_bp.route("/add_resource_interface") def add_resource_interface(): - return send_from_directory(current_app.static_folder, "add_resource.html") # type: ignore + # the old 16-field form was replaced by the add-bottle page; keep old links working + return redirect("/add_bottle_interface") @interface_bp.route("/add_bottle_interface") @@ -204,7 +203,7 @@ def create_compound(): except (TypeError, ValueError): return jsonify({"status": "error", "error": "Missing or invalid PubChem ID (cid)"}), 400 cas = (data.get('cas') or '').strip() or None - if cas and not check_if_cas(cas): + if cas and not pubchem.check_if_cas(cas): return jsonify({"status": "error", "error": f"'{cas}' is not a valid CAS number"}), 400 restore_id = data.get('restore') if restore_id is not None and (not isinstance(restore_id, int) or isinstance(restore_id, bool)): @@ -374,31 +373,14 @@ def create_label(): icon = None if icon == "None": icon = None - label_creating.print_label( + pdf = label_creating.label_pdf( caption=title, longcaption=text, icon=icon, codecontent=qr_content, height=height ) - return send_from_directory(current_app.static_folder, "print.pdf") # type: ignore - - -@interface_bp.route('/search', methods=['POST']) -@cross_origin(origins="http://localhost:8000") -def search(): - data = request.get_json(force=True) # Parse JSON from body - CAS = data.get('CAS') - template = data.get('template') - - print("Search query:", CAS) - try: - results = search_process.search_and_fill(template, CAS) - except ValueError as e: - print("Error in search_and_fill:", e) - return jsonify({"error": str(e)}), 400 - print("Results:", results) - return jsonify(results) + return send_file(io.BytesIO(pdf), mimetype="application/pdf", download_name="label.pdf") @interface_bp.route('/print', methods=['POST']) @@ -506,72 +488,6 @@ def mark_empty(): return "Success", 200 -@interface_bp.route('/template', methods=['GET']) -@cross_origin(origins="http://localhost:8000") -def get_template(): - cat = request.args.get('category') - if cat is None: - return jsonify({}) - try: - # match on the category's id -- list position is not stable across - # instances (or across deleting/reordering categories) - rmn = rm() - types = rmn.get_items_types() - template = next((t for t in types if int(t["id"]) == int(cat)), None) - if template is None: - return jsonify({"status": "error", "error": f"No resource category with id {cat}"}), 404 - if "metadata" not in template: - # eLabFTW >= 5.6 omits metadata from the items_types listing; - # the client needs it to build the form, so fetch the full type - template = rmn.get_items_type(int(cat)) - return template - except Exception as e: - print("Error initializing Resource_Manager:", e) - return jsonify({"status": "error", "error": str(e)}), 400 - - -@interface_bp.route('/add_option', methods=['POST']) -@cross_origin(origins="http://localhost:8000") -def add_option(): - """Appends an option to a select extra field in a category's template, so - a value typed under "Other" becomes a real choice for future resources. - Expects {category, field, option}. Note eLabFTW only lets team admins edit - templates, so this fails with the eLN's error for everyone else.""" - data = request.get_json(force=True) - field = data.get('field') - option = str(data.get('option') or "").strip() - try: - category = int(data.get('category')) - except (TypeError, ValueError): - return jsonify({"status": "error", "error": "Invalid category id"}), 400 - if not field or not option: - return jsonify({"status": "error", "error": "Both 'field' and 'option' are required"}), 400 - - try: - rmn = rm() - except ValueError as e: - return jsonify({"status": "error", "error": str(e)}), 401 - - try: - template = rmn.get_items_type(category) - metadata = json.loads(template.get("metadata") or "{}") - field_def = metadata.get("extra_fields", {}).get(field) - if field_def is None: - return jsonify({"status": "error", - "error": f"Category {category} has no extra field named '{field}'"}), 404 - if field_def.get("type") != "select": - return jsonify({"status": "error", - "error": f"Field '{field}' is not a select field"}), 400 - options = field_def.setdefault("options", []) - if option not in options: - options.append(option) - rmn.change_items_type(category, {"metadata": json.dumps(metadata)}) - return jsonify({"status": "ok", "options": options}) - except Exception as e: - print("Error adding option to template:", e) - return jsonify({"status": "error", "error": str(e)}), 400 - - @interface_bp.route('/resources', methods=['POST']) @cross_origin(origins="http://localhost:8000") def create_resource(): @@ -602,28 +518,6 @@ def create_resource(): return jsonify(result), 201 -@interface_bp.route('/add_resource', methods=['POST']) -@cross_origin(origins="http://localhost:8000") -def add_resource(): - try: - data = request.get_json(force=True) - if not isinstance(data, dict): - raise ValueError("Expected top-level JSON object") - - resource = search_process.dict_complexify(data) - try: - rmn = rm() - item_id = rmn.create_item(data['category'], resource) - return jsonify({"status": "ok", "received": data, "id": item_id}) - except Exception as e: - print("Error Initializing Resource Manager:", e) - return jsonify({"status": "error", "error": str(e)}), 400 - - except Exception as e: # send all errors to the client - print("Error parsing submission:", e) - return jsonify({"status": "error", "error": str(e)}), 400 - - @interface_bp.route('/get_locations', methods=['GET']) @cross_origin(origins="http://localhost:8000") def get_locations(): diff --git a/web/label_creating.py b/web/label_creating.py index 68212f7..0a9927b 100644 --- a/web/label_creating.py +++ b/web/label_creating.py @@ -4,8 +4,9 @@ static_dir = Path(__file__).parent.parent / "static" -def print_label(caption: str = "", codecontent: str | None = None, longcaption: str | None = None, icon: str | None = None, height=18): - """Writes a single flex label to static/print.pdf +def label_pdf(caption: str = "", codecontent: str | None = None, longcaption: str | None = None, icon: str | None = None, height=18) -> bytes: + """A single flex label as PDF data (made in memory, so two people printing at once + can't get each other's labels) :param caption: The caption for the label :param codecontent: The content for the QR code, if None, no QR code is generated :param longcaption: A longer caption for the label, if None, no long caption is displayed @@ -16,7 +17,6 @@ def print_label(caption: str = "", codecontent: str | None = None, longcaption: str(static_dir / "Flex_Label.html"), default_stylesheets=(str(static_dir / "flex_style.css"),), ) - path = str(static_dir / "print.pdf") if codecontent is not None and icon is not None: raise ValueError("Cannot have both codecontent and icon at the same time") @@ -29,4 +29,4 @@ def print_label(caption: str = "", codecontent: str | None = None, longcaption: user_height=height, ) ] - label_writer.write_labels(records, target=path) + return label_writer.write_labels(records, target=None) diff --git a/web/search_process.py b/web/search_process.py deleted file mode 100644 index f47f9e0..0000000 --- a/web/search_process.py +++ /dev/null @@ -1,33 +0,0 @@ -import json - -import eln_common.fill_info as fill_info - - -def dict_simplify(d: dict) -> dict: - """ - param d: dict The dictionary, as pulled from ELN. extra_fields still as strings and stuff - Remove unnecessary keys from the dictionary and reformat to be sent to UI - """ - return_dict = {} - return_dict['title'] = d['title'] - return_dict['extra_fields'] = json.loads(d['metadata'])['extra_fields'] - return return_dict - - -def dict_complexify(d: dict) -> dict: - """ - inverse of dict_simplify - """ - return_dict = {} - return_dict['title'] = d['title'] - return_dict['body'] = d['body'] - return_dict['category'] = d['category'] # NOTE this is an integer, not a string ! - return_dict['metadata'] = json.dumps({'extra_fields': d['extra_fields']}) - return return_dict - - -def search_and_fill(template: dict, CAS: str) -> dict: - template['CAS'] = CAS - template = dict_complexify(template) - template['title'] = CAS - return dict_simplify(fill_info.get_filled_dictionary(template)) From 09ea181ad9ed6294fcda61c542f6a259d95e760b Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 16:28:30 -0400 Subject: [PATCH 19/26] Scanner: change location moves the bottle's storage entry "Change Location" is back on the scanner page. Its popup shows a QR code (STORAGE=) for every storage place from /storage_tree; scanning one after the bottles calls POST /move_to_storage, which moves each bottle's storage entry there (eLabFTW logs the move) and no longer marks the bottle as opened. Bottles with no storage entry or several are reported, not guessed. This replaces /change_location (the Location text field), /get_locations (the template's old Location options) and the SUBLOC= command, whose route never existed. The label generator's "Location" QR type now picks a storage place and encodes STORAGE=. Co-Authored-By: Claude Opus 5.5 --- eln_common/bottle_actions.py | 50 +++++++++++ eln_common/resourcemanage.py | 9 ++ static/index.html | 155 ++++++++++++++--------------------- static/label_gen.html | 24 +++--- tests/test_live_eln.py | 34 ++++---- tests/test_routes_offline.py | 66 +++++++++++++-- web/interface.py | 85 +++++++++---------- 7 files changed, 250 insertions(+), 173 deletions(-) create mode 100644 eln_common/bottle_actions.py diff --git a/eln_common/bottle_actions.py b/eln_common/bottle_actions.py new file mode 100644 index 0000000..5015091 --- /dev/null +++ b/eln_common/bottle_actions.py @@ -0,0 +1,50 @@ +""" +What the scanner page does to bottles that are already in the ELN, now that each +bottle's place and amount live in its storage entry (an eLabFTW "container"). +""" + +from typing import Any + +from eln_common.resourcemanage import Resource_Manager + + +class UnknownPlace(Exception): + """There is no storage place with that id.""" + + +def storage_entry(rm: Resource_Manager, item_id: int) -> dict[str, Any]: + """ + The bottle's one storage entry. + :raises ValueError: with a message for the user when the bottle has no storage + entry or more than one (those are sorted out in eLabFTW by hand). + """ + containers = rm.get_item(item_id).get("containers") or [] + if not containers: + raise ValueError(f"#{item_id} has no place yet: add its place and amount in eLabFTW") + if len(containers) > 1: + raise ValueError(f"#{item_id} is in {len(containers)} places: move it in eLabFTW") + return containers[0] + + +def move_bottles(rm: Resource_Manager, ids: list[int], storage_id: int) -> dict[str, Any]: + """ + Moves each bottle's storage entry to another place (its amount stays). Unlike the old + Location field, moving no longer marks the bottle as opened. + :raises UnknownPlace: no storage place has that id (nothing is moved). + :return: {"moved": [ids], "place": the place's full path, "problems": [messages]} + """ + places = {u["id"]: u for u in rm.get_storage_units()} + if storage_id not in places: + raise UnknownPlace(f"There is no storage place #{storage_id}") + moved, problems = [], [] + for item_id in ids: + try: + entry = storage_entry(rm, item_id) + rm.move_container(item_id, entry["id"], storage_id) + moved.append(item_id) + except ValueError as e: + problems.append(str(e)) + except Exception as e: + problems.append(f"#{item_id}: moving failed: {e}") + return {"moved": moved, "place": places[storage_id].get("full_path") or places[storage_id]["name"], + "problems": problems} diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index 6832378..4e2b28e 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -169,6 +169,15 @@ def add_to_storage(self, item_id: int, storage_id: int, amount: float, unit: str self.post_url(f"/items/{item_id}/containers/{storage_id}", json={"qty_stored": amount, "qty_unit": unit}).raise_for_status() + def move_container(self, item_id: int, container_id: int, storage_id: int) -> None: + """ + Moves one of an item's storage entries to another storage place; eLabFTW records + the move in the item's history. Note the two different ids: container_id is the + entry's own id (item["containers"][i]["id"]), storage_id the destination place's. + """ + self.patch_url(f"/items/{item_id}/containers/{container_id}", + json={"storage_id": storage_id}).raise_for_status() + def create_storage_unit(self, name: str, parent_id: int) -> int: """ Creates a storage place inside another one. Prefer storage_places.create_place_safely(), diff --git a/static/index.html b/static/index.html index dbc9901..2f4b372 100644 --- a/static/index.html +++ b/static/index.html @@ -347,7 +347,7 @@

Locations

"Clear", "Batch", "Add resource", - // "Change Location", + "Change Location", "Mark Open", "Mark Empty", "Print", @@ -596,59 +596,10 @@

Locations

const ids = [...registryList.children].map((li) => li.textContent); return; } else if (val === "change location") { - inputProcessor.value = ""; - return; - } else if (val.includes("location=")) { - const loc = value.split("LOCATION=")[1]; - const ids = [...registryList.children].map((li) => li.textContent); - - // console.log("Changing location to:", location); - fetch("/change_location", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ id: ids , location : loc }), - }) - .then((response) => { - if (!response.ok) { - error(); - throw new Error("Failed to change location"); - } else { - success("Location changed to: " + loc); - popup.style.display = "none"; - if (focusOnInput) inputProcessor.focus(); - - } - }) - .catch((err) => { - console.error(err); - }); - } - else if (val.includes("subloc=")) { - const loc = value.split("SUBLOC=")[1]; - const ids = [...registryList.children].map((li) => li.textContent); - fetch("/change_sublocation", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ id: ids , sublocation : loc }), - }) - .then((response) => { - if (!response.ok) { - error(); - throw new Error("Failed to change sublocation"); - } else { - success("Sublocation comment added: " + loc); - popup.style.display = "none"; - if (focusOnInput) inputProcessor.focus(); - - } - }) - .catch((err) => { - console.error(err); - }); + openLocationPopup(); + } else if (val.startsWith("storage=")) { + // a storage place's QR code: move the bottles in the list there + moveToStorage(parseInt(val.split("storage=")[1], 10)); }else if (!isNaN(val) && val !== "") { addToRegistry(val); } else { @@ -661,6 +612,63 @@

Locations

} } + // POST /move_to_storage: move every bottle in the list to the scanned place + function moveToStorage(storageId) { + const ids = [...registryList.children].map((li) => li.textContent); + if (ids.length === 0 || !storageId) { + error("Scan the bottles first, then the place"); + return; + } + fetch("/move_to_storage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: ids, storage_id: storageId }), + }) + .then(async (response) => { + const data = await response.json(); + if (!response.ok) { + error(data.error || "Moving failed"); + } else if (data.problems.length) { + error(data.problems.join(" · ")); + } else { + success("Moved to " + data.place.split(" > ").join(" › ")); + popup.style.display = "none"; + if (focusOnInput) inputProcessor.focus(); + } + }) + .catch((err) => { + console.error(err); + error("Moving failed"); + }); + } + + // the "Change Location" popup: one QR code per storage place (STORAGE=) + function openLocationPopup() { + fetch("/storage_tree") + .then((response) => response.json()) + .then((places) => { + if (!Array.isArray(places)) { + error(places.error || "Could not load the storage places"); + return; + } + popup.style.display = "block"; + popupGrid.innerHTML = ""; + places + .filter((p) => p.parent_id !== null) // rooms hold places, not bottles + .sort((a, b) => a.full_path.localeCompare(b.full_path)) + .forEach((place) => { + const container = document.createElement("div"); + container.className = "qrItem"; + popupGrid.appendChild(container); + renderQRCode(container, place.full_path.split(" > ").join(" › "), `STORAGE=${place.id}`); + }); + }) + .catch((err) => { + console.error("Error loading storage places:", err); + error("Could not load the storage places"); + }); + } + function showExperimentPrompt() { focusOnInput = false; const input = document.getElementById("experimentInput"); @@ -882,44 +890,7 @@

Locations

renderQRCode(container, caption); if (caption === "Change Location") { - container.addEventListener("click", () => { - // console.log("Clicked Change Location"); - - fetch("/get_locations", { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => { - // console.log("Received response:", response); - return response.json(); - }) - .then((data) => { - // console.log("Parsed JSON data:", data); - - const extraCaptions = data; - if (!Array.isArray(extraCaptions)) { - console.error("Expected array but got:", extraCaptions); - return; - } - - // console.log("Extra captions:", extraCaptions); - - popup.style.display = "block"; - popupGrid.innerHTML = ""; - - extraCaptions.forEach((extra, index) => { - const extraContainer = document.createElement("div"); - extraContainer.className = "qrItem"; - popupGrid.appendChild(extraContainer); - renderQRCode(extraContainer, extra, `LOCATION=${extra}`); - }); - }) - .catch((error) => { - console.error("Error fetching extra captions:", error); - }); - }); + container.addEventListener("click", openLocationPopup); } }); diff --git a/static/label_gen.html b/static/label_gen.html index 51655c0..af86fb1 100644 --- a/static/label_gen.html +++ b/static/label_gen.html @@ -277,7 +277,7 @@

Generate Label

hideResourceDropdown(); const type = qrContentTypeSelect.value; if (type === "Location") { - qrContentLabel.textContent = "Location"; + qrContentLabel.textContent = "Storage place (pick from the list)"; codeContentInput.setAttribute("list", "location-options"); loadLocations(); } else { @@ -291,25 +291,29 @@

Generate Label

applyQrContentType(); }); - // ── location suggestions (free text allowed) ──────────────────────── + // ── storage places for "Location" QR codes (the QR holds STORAGE=) ── let locationsLoaded = false; async function loadLocations() { if (locationsLoaded) return; locationsLoaded = true; try { - const r = await fetch("/get_locations"); + const r = await fetch("/storage_tree"); if (!r.ok) throw new Error(`HTTP ${r.status}`); - const locs = await r.json(); + const places = await r.json(); const datalist = document.getElementById("location-options"); datalist.innerHTML = ""; - (Array.isArray(locs) ? locs : []).forEach((loc) => { - const opt = document.createElement("option"); - opt.value = loc; - datalist.appendChild(opt); - }); + (Array.isArray(places) ? places : []) + .filter((p) => p.parent_id !== null) + .sort((a, b) => a.full_path.localeCompare(b.full_path)) + .forEach((place) => { + const opt = document.createElement("option"); + opt.value = place.id; // the label's QR content + opt.label = place.full_path.split(" > ").join(" › "); + datalist.appendChild(opt); + }); } catch (e) { locationsLoaded = false; // allow a retry next time - console.error("Failed to load locations:", e); + console.error("Failed to load storage places:", e); } } diff --git a/tests/test_live_eln.py b/tests/test_live_eln.py index bfec5f2..528ab80 100644 --- a/tests/test_live_eln.py +++ b/tests/test_live_eln.py @@ -48,12 +48,6 @@ def test_item_393_exists(self, live_rm): assert item["id"] == TEST_ITEM_ID assert item["title"] - def test_get_locations(self, client, auth_headers): - resp = client.get("/get_locations", headers=auth_headers) - assert resp.status_code == 200 - locations = resp.get_json() - assert isinstance(locations, list) - def test_categories_lists_team_categories(self, client, auth_headers): resp = client.get("/categories", headers=auth_headers) assert resp.status_code == 200 @@ -118,20 +112,20 @@ def test_mark_open_sets_date_and_rejects_reopen( assert resp.status_code == 400 assert "already marked" in resp.get_json()["error"] - def test_change_location(self, client, auth_headers, live_rm, restore_item): - meta = json.loads(restore_item["metadata"]) - if "Location" not in meta.get("extra_fields", {}): - pytest.skip(f"Item {TEST_ITEM_ID} has no 'Location' extra field") - - resp = client.post( - "/change_location", - json={"id": [TEST_ITEM_ID], "location": "pytest test location"}, - headers=auth_headers, - ) - assert resp.status_code == 200 - - updated = json.loads(get_item_raw(live_rm)["metadata"]) - assert updated["extra_fields"]["Location"]["value"] == "pytest test location" + def test_move_to_storage_and_back(self, client, auth_headers, live_rm): + containers = get_item_raw(live_rm).get("containers") or [] + if len(containers) != 1: + pytest.skip(f"Item {TEST_ITEM_ID} needs exactly one storage entry") + start = containers[0]["storage_id"] + other = next(u["id"] for u in live_rm.get_storage_units() + if u.get("parent_id") is not None and u["id"] != start) + try: + resp = client.post("/move_to_storage", json={"id": [TEST_ITEM_ID], "storage_id": other}, + headers=auth_headers) + assert resp.status_code == 200 and resp.get_json()["moved"] == [TEST_ITEM_ID] + assert get_item_raw(live_rm)["containers"][0]["storage_id"] == other + finally: + live_rm.move_container(TEST_ITEM_ID, containers[0]["id"], start) def test_mark_empty(self, client, auth_headers, live_rm, restore_item): resp = client.post("/mark_empty", json={"id": [TEST_ITEM_ID]}, headers=auth_headers) diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 43d3c95..36355dc 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -9,6 +9,7 @@ import pytest import eln_common.add_bottle as add_bottle +import eln_common.bottle_actions as bottle_actions import eln_common.bottle_tags as bottle_tags import eln_common.compound_import as compound_import import eln_common.pubchem as pubchem @@ -93,17 +94,13 @@ class TestInputValidation: """Routes that validate the body before ever touching the eLN.""" @pytest.mark.parametrize( - "route", ["/print", "/mark_open", "/mark_empty", "/change_location", "/associate"] + "route", ["/print", "/mark_open", "/mark_empty", "/move_to_storage", "/associate"] ) def test_empty_id_list_rejected(self, client, route): resp = client.post(route, json={"id": []}) assert resp.status_code == 400 assert "error" in resp.get_json() - def test_get_locations_without_key_errors(self, client): - resp = client.get("/get_locations") - assert resp.status_code == 400 - class TestCasValidation: @pytest.mark.parametrize("cas", ["7732-18-5", "50-00-0", "1234567-89-1"]) def test_valid_cas(self, cas): @@ -1100,6 +1097,65 @@ def test_needs_an_api_key(self, client): assert client.post("/resources", json=THF_BOTTLE).status_code == 401 +class FakeScannerRM: + """Stand-in for the calls the scanner actions make; bottles map id -> containers.""" + + def __init__(self, bottles, fail_move=()): + self.bottles = bottles + self.fail_move = set(fail_move) + self.moves = [] + + def get_storage_units(self): + return STORAGE + + def get_item(self, id): + if id not in self.bottles: + raise RuntimeError("404 Not Found") + return {"id": id, "containers": self.bottles[id]} + + def move_container(self, item_id, container_id, storage_id): + if item_id in self.fail_move: + raise RuntimeError("403 Forbidden") + self.moves.append((item_id, container_id, storage_id)) + + +class TestMoveBottles: + def test_moves_each_bottles_storage_entry(self): + rm = FakeScannerRM({624: [{"id": 234, "storage_id": 7}], 625: [{"id": 235, "storage_id": 7}]}) + result = bottle_actions.move_bottles(rm, [624, 625], 6) + assert rm.moves == [(624, 234, 6), (625, 235, 6)] # the entry's own id, then the place's + assert result == {"moved": [624, 625], "place": "Room 3057 > Front hood > Flammable cabinet", + "problems": []} + + def test_bottles_without_one_place_are_reported_not_guessed(self): + rm = FakeScannerRM({1: [], 2: [{"id": 8}, {"id": 9}], 3: [{"id": 10}]}, fail_move={3}) + result = bottle_actions.move_bottles(rm, [1, 2, 3, 4], 6) + assert result["moved"] == [] and rm.moves == [] + assert [p.split(" ")[0] for p in result["problems"]] == ["#1", "#2", "#3:", "#4:"] + assert "no place yet" in result["problems"][0] and "in 2 places" in result["problems"][1] + + def test_unknown_place_moves_nothing(self): + rm = FakeScannerRM({624: [{"id": 234}]}) + with pytest.raises(bottle_actions.UnknownPlace): + bottle_actions.move_bottles(rm, [624], 999) + assert rm.moves == [] + + def test_route(self, client, monkeypatch): + monkeypatch.setattr(interface, "rm", lambda: FakeScannerRM({624: [{"id": 234}]})) + resp = client.post("/move_to_storage", json={"id": ["624"], "storage_id": 6}) + assert resp.status_code == 200 and resp.get_json()["moved"] == [624] + assert client.post("/move_to_storage", json={"id": [624], "storage_id": 999}).status_code == 404 + + @pytest.mark.parametrize("body", [{"id": [624]}, {"id": [624], "storage_id": "6"}, + {"id": ["x"], "storage_id": 6}, {"id": 624, "storage_id": 6}]) + def test_route_bad_input_is_400(self, client, body): + assert client.post("/move_to_storage", json=body).status_code == 400 + + def test_old_location_routes_are_gone(self, client): + assert client.post("/change_location", json={"id": [1], "location": "x"}).status_code == 404 + assert client.get("/get_locations").status_code == 404 + + class TestCreateItemFromTemplate: """New resources start from their template, so they get its category and default status (items created from a bare category had no status).""" diff --git a/web/interface.py b/web/interface.py index dfb8051..4bd3ea0 100644 --- a/web/interface.py +++ b/web/interface.py @@ -7,6 +7,7 @@ from requests import HTTPError import eln_common.add_bottle as add_bottle +import eln_common.bottle_actions as bottle_actions import eln_common.compound_import as compound_import import eln_common.bottle_tags as bottle_tags import eln_common.config as config @@ -367,8 +368,8 @@ def create_label(): if qr_type == "Resource": qr_content = config.item_web_url(qr_content) elif qr_type == "Location": - # the registry scanner recognizes "LOCATION=" codes - qr_content = "LOCATION=" + (qr_content or "") + # a storage place's id; scanning it on the scanner page moves the scanned bottles there + qr_content = f"STORAGE={qr_content or ''}" if icon == "QR Code": icon = None if icon == "None": @@ -450,27 +451,44 @@ def mark_open(): return "Success", 200 -@interface_bp.route('/change_location', methods=['POST']) -@cross_origin(origins="http://localhost:8000") -def change_location(): - data = request.get_json() +def _id_list(data: dict) -> list[int]: + """The resource ids in a scanner request {id: [...]}. + :raises ValueError: with a message for a 400 answer.""" ids = data.get('id', []) - if len(ids) == 0: - return jsonify({"error": "No IDs provided"}), 400 - rmn = rm() if not isinstance(ids, list): - return jsonify({"error": "Expected a list of IDs"}), 400 - for id in ids: - body = rmn.get_item(id) - metadata = json.loads(body["metadata"] or "{}") - location = metadata.get("extra_fields", {}).get("Location") - if location is None: - return jsonify({"error": f"Item {id} has no 'Location' extra field. " - "Its category's template must define an extra field named " - "'Location' (exact spelling) before its location can be set."}), 400 - location["value"] = data.get('location', "") - rmn.change_item(id, {"metadata": json.dumps(metadata), "status": config.setting("status_open", 4)}) - return "Success", 200 + raise ValueError("Expected a list of IDs") + if len(ids) == 0: + raise ValueError("No IDs provided") + try: + return [int(x) for x in ids] + except (TypeError, ValueError): + raise ValueError("IDs must be numbers") + + +@interface_bp.route('/move_to_storage', methods=['POST']) +@cross_origin(origins="http://localhost:8000") +def move_to_storage(): + """Moves the scanned bottles to a storage place: {id: [ids], storage_id}. The scanner + sends this when a place's QR code (STORAGE=) is scanned. 200 with {moved, place, + problems}; problems lists bottles that could not be moved and why.""" + data = request.get_json(force=True, silent=True) or {} + try: + ids = _id_list(data) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + storage_id = data.get('storage_id') + if not isinstance(storage_id, int) or isinstance(storage_id, bool): + return jsonify({"error": "Scan a storage place's QR code (storage_id)"}), 400 + try: + rmn = rm() + except ValueError as e: + return jsonify({"error": str(e)}), 401 + try: + return jsonify(bottle_actions.move_bottles(rmn, ids, storage_id)) + except bottle_actions.UnknownPlace as e: + return jsonify({"error": str(e)}), 404 + except Exception as e: + return jsonify({"error": str(e)}), 500 @interface_bp.route('/mark_empty', methods=['POST']) @@ -516,28 +534,3 @@ def create_resource(): for bottle in result["bottles"]: bottle["url"] = config.item_web_url(bottle["id"]) return jsonify(result), 201 - - -@interface_bp.route('/get_locations', methods=['GET']) -@cross_origin(origins="http://localhost:8000") -def get_locations(): - try: - rmn = rm() - types = rmn.get_items_types() - locations = [] - for t in types: - if "metadata" not in t: - # eLabFTW >= 5.6 omits metadata from the items_types listing - t = rmn.get_items_type(t["id"]) - try: - locs = json.loads(t["metadata"] or "{}")["extra_fields"]["Location"]["options"] - except KeyError: - locs = [] - # Add only unique locations - for loc in locs: - if loc not in locations: - locations.append(loc) - return jsonify(locations) - except Exception as e: - print("Error getting locations:", e) - return jsonify({"status": "error", "error": str(e)}), 400 From ffd0ad023b5fbe0c3d8d1a65c02afee84335186f Mon Sep 17 00:00:00 2001 From: ravi Date: Mon, 5 Oct 2026 16:45:52 -0400 Subject: [PATCH 20/26] Scanner: mark empty also sets the amount to 0 Marking a bottle empty now sets its status to Empty and the amount in each of its storage entries to 0 (eLabFTW logs the change), so the inventory no longer counts a used-up bottle as full. The bottle and its place stay on record. The scanner page shows any bottle that could not be emptied. Co-Authored-By: Claude Opus 5.5 --- eln_common/bottle_actions.py | 23 +++++++++++++++++++++++ eln_common/resourcemanage.py | 6 ++++++ static/index.html | 12 +++++++----- tests/test_routes_offline.py | 30 +++++++++++++++++++++++++++++- web/interface.py | 22 ++++++++++++---------- 5 files changed, 77 insertions(+), 16 deletions(-) diff --git a/eln_common/bottle_actions.py b/eln_common/bottle_actions.py index 5015091..03e0410 100644 --- a/eln_common/bottle_actions.py +++ b/eln_common/bottle_actions.py @@ -48,3 +48,26 @@ def move_bottles(rm: Resource_Manager, ids: list[int], storage_id: int) -> dict[ problems.append(f"#{item_id}: moving failed: {e}") return {"moved": moved, "place": places[storage_id].get("full_path") or places[storage_id]["name"], "problems": problems} + + +def mark_empty(rm: Resource_Manager, ids: list[int], empty_status: int) -> dict[str, Any]: + """ + Marks each bottle as used up: its status becomes Empty and the amount in its storage + entries 0, so eLabFTW's inventory no longer counts it. The bottle and its place stay + on record (nothing is deleted). + :return: {"emptied": [ids], "problems": [messages]} + """ + emptied, problems = [], [] + for item_id in ids: + try: + rm.change_item(item_id, {"status": empty_status}) + except Exception as e: + problems.append(f"#{item_id}: marking it empty failed: {e}") + continue + try: + for entry in rm.get_item(item_id).get("containers") or []: + rm.set_container_amount(item_id, entry["id"], 0) + emptied.append(item_id) + except Exception as e: + problems.append(f"#{item_id} is marked Empty, but setting its amount to 0 failed: {e}") + return {"emptied": emptied, "problems": problems} diff --git a/eln_common/resourcemanage.py b/eln_common/resourcemanage.py index 4e2b28e..86739f9 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -178,6 +178,12 @@ def move_container(self, item_id: int, container_id: int, storage_id: int) -> No self.patch_url(f"/items/{item_id}/containers/{container_id}", json={"storage_id": storage_id}).raise_for_status() + def set_container_amount(self, item_id: int, container_id: int, amount: float) -> None: + """Changes the amount in one of an item's storage entries (container_id is the entry's + own id); eLabFTW records the change in the item's history.""" + self.patch_url(f"/items/{item_id}/containers/{container_id}", + json={"qty_stored": amount}).raise_for_status() + def create_storage_unit(self, name: str, parent_id: int) -> int: """ Creates a storage place inside another one. Prefer storage_places.create_place_safely(), diff --git a/static/index.html b/static/index.html index 2f4b372..085d535 100644 --- a/static/index.html +++ b/static/index.html @@ -577,14 +577,16 @@

Locations

"Content-Type": "application/json", }, body: JSON.stringify({ id: ids }), - }).then((response) => { + }).then(async (response) => { + const data = await response.json(); if (!response.ok) { - error(); - throw new Error("Failed"); + error(data.error || "Failed"); + } else if (data.problems.length) { + error(data.problems.join(" · ")); } else { - success(); + success("Marked empty (amount set to 0)"); } - }); + }).catch(() => error("Failed")); } else if (val === "associate with experiment") { const ids = [...registryList.children].map((li) => li.textContent); if (ids.length === 0) { diff --git a/tests/test_routes_offline.py b/tests/test_routes_offline.py index 36355dc..200040d 100644 --- a/tests/test_routes_offline.py +++ b/tests/test_routes_offline.py @@ -1103,7 +1103,7 @@ class FakeScannerRM: def __init__(self, bottles, fail_move=()): self.bottles = bottles self.fail_move = set(fail_move) - self.moves = [] + self.moves, self.changes, self.amounts = [], [], [] def get_storage_units(self): return STORAGE @@ -1118,6 +1118,14 @@ def move_container(self, item_id, container_id, storage_id): raise RuntimeError("403 Forbidden") self.moves.append((item_id, container_id, storage_id)) + def change_item(self, id, body): + if id not in self.bottles: + raise RuntimeError("404 Not Found") + self.changes.append((id, body)) + + def set_container_amount(self, item_id, container_id, amount): + self.amounts.append((item_id, container_id, amount)) + class TestMoveBottles: def test_moves_each_bottles_storage_entry(self): @@ -1156,6 +1164,26 @@ def test_old_location_routes_are_gone(self, client): assert client.get("/get_locations").status_code == 404 +class TestMarkEmpty: + def test_status_empty_and_every_storage_entry_to_zero(self): + rm = FakeScannerRM({624: [{"id": 234}], 700: [{"id": 8}, {"id": 9}], 393: []}) + result = bottle_actions.mark_empty(rm, [624, 700, 393], empty_status=5) + assert result == {"emptied": [624, 700, 393], "problems": []} + assert rm.changes == [(624, {"status": 5}), (700, {"status": 5}), (393, {"status": 5})] + assert rm.amounts == [(624, 234, 0), (700, 8, 0), (700, 9, 0)] + + def test_unknown_bottle_is_reported(self): + result = bottle_actions.mark_empty(FakeScannerRM({}), [999], empty_status=5) + assert result["emptied"] == [] and result["problems"][0].startswith("#999") + + def test_route(self, client, monkeypatch): + rm = FakeScannerRM({624: [{"id": 234}]}) + monkeypatch.setattr(interface, "rm", lambda: rm) + resp = client.post("/mark_empty", json={"id": [624]}) + assert resp.status_code == 200 and resp.get_json()["emptied"] == [624] + assert rm.amounts == [(624, 234, 0)] + + class TestCreateItemFromTemplate: """New resources start from their template, so they get its category and default status (items created from a bare category had no status).""" diff --git a/web/interface.py b/web/interface.py index 4bd3ea0..4a2fce9 100644 --- a/web/interface.py +++ b/web/interface.py @@ -494,16 +494,18 @@ def move_to_storage(): @interface_bp.route('/mark_empty', methods=['POST']) @cross_origin(origins="http://localhost:8000") def mark_empty(): - data = request.get_json() - ids = data.get('id', []) - if len(ids) == 0: - return jsonify({"error": "No IDs provided"}), 400 - rmn = rm() - if not isinstance(ids, list): - return jsonify({"error": "Expected a list of IDs"}), 400 - for id in ids: - rmn.change_item(id, {"status": config.setting("status_empty", 5)}) - return "Success", 200 + """Marks the scanned bottles as used up: {id: [ids]}. Status Empty and amount 0. + 200 with {emptied, problems}.""" + data = request.get_json(force=True, silent=True) or {} + try: + ids = _id_list(data) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + try: + rmn = rm() + except ValueError as e: + return jsonify({"error": str(e)}), 401 + return jsonify(bottle_actions.mark_empty(rmn, ids, config.setting("status_empty", 5))) @interface_bp.route('/resources', methods=['POST']) From 786f1f8edbda8bfc5648f532571708a6186664a2 Mon Sep 17 00:00:00 2001 From: ravi Date: Tue, 6 Oct 2026 11:22:17 -0400 Subject: [PATCH 21/26] Scanner page: "Resource Manager" heading The start page gets a "Resource Manager" heading and browser-tab title (was "Inventory Manager"), and the change-location popup says what its QR codes do. Co-Authored-By: Claude Opus 5.5 --- static/index.html | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/static/index.html b/static/index.html index 085d535..328254c 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,7 @@ - Inventory Manager + Resource Manager + +
- ← Back

Add a bottle

@@ -178,6 +191,7 @@

Add a bottle

+
diff --git a/static/index.html b/static/index.html index d17e43b..bebc5eb 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,8 @@ - Resource Manager + Toolkit + - ← Back to Inventory Manager + ← Back to Toolkit

ELN Server Settings

Maps this server's actions onto your team's eLabFTW status and resource diff --git a/static/tabs.css b/static/tabs.css new file mode 100644 index 0000000..cb416ad --- /dev/null +++ b/static/tabs.css @@ -0,0 +1,29 @@ +/* The Toolkit's page tabs, at the top of the left column of every page. + To add a page (e.g. Experiments), add one to each page's