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..8575c59 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 @@ -30,8 +29,8 @@ 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` | -| `automations/` | autofill, label generation, RDKit images, peroxide checks, Slack bot — formerly `eln_packages_backend` | +| `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 | | `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/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/automations/routine_checks.yaml b/automations/routine_checks.yaml new file mode 100644 index 0000000..9a5331e --- /dev/null +++ b/automations/routine_checks.yaml @@ -0,0 +1,23 @@ +# Routine checks on opened bottles, kept as steps on each bottle's ELN page. +# +# A bottle with one of these tags gets the step when it is marked open in the +# Resource Manager app, due `every_months` later. Scanning it with "Tested" ticks +# the step and adds the next one; "Mark Empty" closes it. The tags come from the +# bottle's compound when the bottle is added (see eln_common/bottle_tags.py). +# +# Intervals: common lab-safety practice for peroxide formers (test after opening). +# Change them here; the app reads this file when it starts. + +checks: + - tag: "Peroxide former: A" + step: "Test for peroxides (class A)" + every_months: 3 + - tag: "Peroxide former: B" + step: "Test for peroxides (class B)" + every_months: 6 + - tag: "Peroxide former: C" + step: "Test for peroxides (class C)" + every_months: 6 + - tag: "Peroxide former: D" + step: "Test for peroxides (class D)" + every_months: 12 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/add_bottle.py b/eln_common/add_bottle.py new file mode 100644 index 0000000..8bc3390 --- /dev/null +++ b/eln_common/add_bottle.py @@ -0,0 +1,274 @@ +""" +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_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 +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) +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"], +} + +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. +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.""" + + +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__(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]: + """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 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("µ", "μ") + 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")) + + 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 {} + 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, "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_same_lot(rm: Resource_Manager, request: dict[str, Any]) -> list[dict[str, Any]]: + """ + 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"] + 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) + 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_bottles(rm: Resource_Manager, data: dict[str, Any]) -> dict[str, Any]: + """ + 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_same_lot": true it goes ahead even when bottles from that lot exist. + :raises InvalidBottle: nothing 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_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 = [] + + 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}") + + 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}") + + smiles = request["compound_data"][0].get("smiles") if request["compound_data"] else None + if smiles: + try: + 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, "tags": tags, "problems": problems} diff --git a/eln_common/bottle_actions.py b/eln_common/bottle_actions.py new file mode 100644 index 0000000..55c2dec --- /dev/null +++ b/eln_common/bottle_actions.py @@ -0,0 +1,148 @@ +""" +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"), +and routine checks (e.g. peroxide tests) are steps on the bottle (see routine_checks). +""" + +import json +from datetime import date +from typing import Any + +from eln_common import routine_checks +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} + + +def mark_open(rm: Resource_Manager, ids: list[int], open_status: int, + today: date | None = None) -> dict[str, Any]: + """ + Marks each bottle as opened today (its Opened field and status) and, if it has a + routine check (e.g. tag "Peroxide former: B"), adds the first check step. + :return: {"opened": [ids], "checks": [{id, step, due}], "problems": [messages]} + """ + today = today or date.today() + opened, checks, problems = [], [], [] + for item_id in ids: + try: + item = rm.get_item(item_id) + metadata = json.loads(item.get("metadata") or "{}") + field = metadata.get("extra_fields", {}).get("Opened") + if field is None: + problems.append(f"#{item_id} has no 'Opened' field (its category's template needs one)") + continue + if field.get("value"): + problems.append(f"#{item_id} was already marked open on {field['value']}") + continue + field["value"] = today.isoformat() + rm.change_item(item_id, {"metadata": json.dumps(metadata), "status": open_status}) + opened.append(item_id) + except Exception as e: + problems.append(f"#{item_id}: marking it open failed: {e}") + continue + rule = routine_checks.rule_for(item.get("tags")) + if rule and not routine_checks.open_check_steps(rm.get_steps(item_id)): + try: + due = routine_checks.schedule(rm, item_id, rule, today) + checks.append({"id": item_id, "step": rule["step"], "due": due.isoformat()}) + except Exception as e: + problems.append(f"#{item_id} is marked open, but adding its check '{rule['step']}' failed: {e}") + return {"opened": opened, "checks": checks, "problems": problems} + + +def mark_tested(rm: Resource_Manager, ids: list[int], today: date | None = None) -> dict[str, Any]: + """ + Records that each bottle's routine check was done today: ticks its open check step + and adds the next one, due `every_months` from today. + :return: {"tested": [{id, step, next_due}], "problems": [messages]} + """ + today = today or date.today() + tested, problems = [], [] + for item_id in ids: + try: + item = rm.get_item(item_id) + rule = routine_checks.rule_for(item.get("tags")) + if rule is None: + problems.append(f"#{item_id} has no routine check (no tag like 'Peroxide former: B')") + continue + opened = json.loads(item.get("metadata") or "{}").get("extra_fields", {}).get("Opened", {}) + if not opened.get("value"): + problems.append(f"#{item_id} is not marked open yet: use Mark Open first") + continue + for step in routine_checks.open_check_steps(rm.get_steps(item_id)): + rm.finish_step(item_id, step["id"]) + due = routine_checks.schedule(rm, item_id, rule, today) + tested.append({"id": item_id, "step": rule["step"], "next_due": due.isoformat()}) + except Exception as e: + problems.append(f"#{item_id}: recording the test failed: {e}") + return {"tested": tested, "problems": problems} + + +def mark_empty(rm: Resource_Manager, ids: list[int], empty_status: int, + today: date | None = None) -> dict[str, Any]: + """ + Marks each bottle as used up: its status becomes Empty, the amount in its storage + entries 0 (so eLabFTW's inventory no longer counts it), and any open routine-check + step is closed with a note (ticked, not deleted). The bottle and its place stay on + record. + :return: {"emptied": [ids], "problems": [messages]} + """ + today = today or date.today() + 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) + for step in routine_checks.open_check_steps(rm.get_steps(item_id)): + rm.set_step(item_id, step["id"], { + "body": f"{step['body']}: closed, bottle marked empty on {today.isoformat()} (no test needed)"}) + rm.finish_step(item_id, step["id"]) + emptied.append(item_id) + except Exception as e: + problems.append(f"#{item_id} is marked Empty, but setting its amount to 0 or closing its check failed: {e}") + return {"emptied": emptied, "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/compound_import.py b/eln_common/compound_import.py new file mode 100644 index 0000000..49d63fe --- /dev/null +++ b/eln_common/compound_import.py @@ -0,0 +1,149 @@ +"""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, 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).""" + + 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) + + +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_clash(pubchem: dict[str, Any], cas: str | None, + compounds: list[dict[str, Any]]) -> CompoundClash | None: + """ + 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): + 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]: + """ + 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, "")} + + +def create_compound_safely(rm: Resource_Manager, pubchem: 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. 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 or restored compound as eLabFTW stores it. + """ + compounds = rm.get_all_compounds() + clash = find_clash(pubchem, cas, compounds) + if 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)) + 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("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/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/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 new file mode 100644 index 0000000..d637e81 --- /dev/null +++ b/eln_common/pubchem.py @@ -0,0 +1,168 @@ +""" +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 + +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: + 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) + + +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 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). + """ + data = _json(requests.get(f"{PUG_VIEW}/data/compound/{cid}/JSON", + params={"heading": "GHS Classification"}, timeout=TIMEOUT)) + if data is None: + return None + 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]]: + """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 f98df10..7471204 100644 --- a/eln_common/resourcemanage.py +++ b/eln_common/resourcemanage.py @@ -44,14 +44,10 @@ 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. + 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 @@ -61,10 +57,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: """ @@ -113,34 +106,123 @@ 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): + def get_compounds(self): """ - 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. + Gets every (non-deleted) compound in the ELN as dictionaries. + :return: A list of dictionaries containing the compounds. """ - self.post_url("/compounds/", json={"action":"duplicate", "cas":CAS}) - def associate_compound(self, comp_id:int,res_id:int): + response = self.get_url("/compounds?limit=9999") + response.raise_for_status() + return response.json() + + def get_all_compounds(self) -> list[dict[str, Any]]: """ - 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. + 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. """ - self.post_url("/items/" + str(res_id) + "/compounds/" + str(comp_id)) - - def get_compounds(self): + response = self.get_url("/compounds?limit=9999&state=1,2,3") + response.raise_for_status() + return response.json() + + def create_compound(self, body: dict[str, Any]) -> int: """ - Gets a list of compounds in the ELN as dictionaries. - :return: A list of dictionaries containing the compounds. + 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. """ - return self.get_url("/compounds?limit=1000").json() + 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. + :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 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 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 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 get_steps(self, item_id: int) -> list[dict[str, Any]]: + """An item's steps: [{id, body, finished, finished_time, deadline, ...}].""" + response = self.get_url(f"/items/{item_id}/steps") + response.raise_for_status() + return response.json() + + def add_step(self, item_id: int, body: str) -> int: + """Adds a step (a to-do line) to an item. + :return: The new step's id.""" + response = self.post_url(f"/items/{item_id}/steps", json={"body": body}) + response.raise_for_status() + return int(str(response.headers["Location"]).rstrip("/").split("/").pop()) + + def set_step(self, item_id: int, step_id: int, fields: dict[str, Any]) -> None: + """Changes a step's body and/or deadline ("YYYY-MM-DD HH:MM:SS"). Note: eLabFTW + refuses an "action" key here, unlike finish_step.""" + self.patch_url(f"/items/{item_id}/steps/{step_id}", json=fields).raise_for_status() + + def finish_step(self, item_id: int, step_id: int) -> None: + """Ticks a step: eLabFTW records the time and clears its deadline. It toggles, so + only call it on a step that is not ticked yet.""" + self.patch_url(f"/items/{item_id}/steps/{step_id}", json={"action": "finish"}).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(), + 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 :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" @@ -274,6 +356,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/eln_common/routine_checks.py b/eln_common/routine_checks.py new file mode 100644 index 0000000..e0dbc1d --- /dev/null +++ b/eln_common/routine_checks.py @@ -0,0 +1,65 @@ +""" +Routine checks on bottles (e.g. testing peroxide formers), kept as eLabFTW steps. + +A bottle with a tag listed in automations/routine_checks.yaml gets a step such as +"Test for peroxides (class B), due 2027-04-06" when it is marked open. "Tested" +ticks it and adds the next one; "Mark Empty" closes it (see bottle_actions). The +due date is written into the step's text as well as its deadline, because eLabFTW +clears the deadline when a step is ticked. +""" + +import calendar +import functools +from datetime import date +from pathlib import Path +from typing import Any + +import yaml + +from eln_common.resourcemanage import Resource_Manager + +RULES_PATH = Path(__file__).resolve().parent.parent / "automations" / "routine_checks.yaml" + + +@functools.cache +def rules() -> list[dict[str, Any]]: + """The checks from the rules file: [{tag, step, every_months}].""" + with RULES_PATH.open() as f: + checks = (yaml.safe_load(f) or {}).get("checks") or [] + for check in checks: + if not check.get("tag") or not check.get("step") or not isinstance(check.get("every_months"), int) \ + or check["every_months"] < 1: + raise ValueError(f"{RULES_PATH}: each check needs a tag, a step and every_months >= 1: {check}") + return checks + + +def rule_for(tags: str | None) -> dict[str, Any] | None: + """The check for a bottle, from its tags as eLabFTW sends them ("Flammable|Peroxide former: B").""" + names = set((tags or "").split("|")) + return next((rule for rule in rules() if rule["tag"] in names), None) + + +def add_months(start: date, months: int) -> date: + """The same day `months` later (the 31st becomes the month's last day if needed).""" + month_index = start.month - 1 + months + year, month = start.year + month_index // 12, month_index % 12 + 1 + return date(year, month, min(start.day, calendar.monthrange(year, month)[1])) + + +def is_check_step(step: dict[str, Any]) -> bool: + """Whether a step is one of our routine checks (from any rule).""" + return any((step.get("body") or "").startswith(rule["step"]) for rule in rules()) + + +def open_check_steps(steps: list[dict[str, Any]]) -> list[dict[str, Any]]: + """The routine-check steps that are not ticked yet.""" + return [s for s in steps if not s.get("finished") and is_check_step(s)] + + +def schedule(rm: Resource_Manager, item_id: int, rule: dict[str, Any], start: date) -> date: + """Adds the rule's step to the bottle, due `every_months` after start. + :return: The due date.""" + due = add_months(start, rule["every_months"]) + step_id = rm.add_step(item_id, f"{rule['step']}, due {due.isoformat()}") + rm.set_step(item_id, step_id, {"deadline": f"{due.isoformat()} 09:00:00"}) + return due 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/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/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/add_bottle.html b/static/add_bottle.html new file mode 100644 index 0000000..0388a94 --- /dev/null +++ b/static/add_bottle.html @@ -0,0 +1,198 @@ + + + + + + Add a bottle · ELN Toolkit + + + + + +
+
+

Add a bottle

+ + + + +
+
+ + +
+ + +
+ 1. What is in it +
+ +
+ + +
+ 2. Where it is kept +
+ + +
+
+ + +
+
+ + + +
+ + +
+ 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 +
+ + +
Filled in from the chemical; you can change it.
+
+
+
+ + + + + + +
+ Show what "Create" sends +
{}
+
+
+
+ +
+ + + diff --git a/static/add_bottle.js b/static/add_bottle.js new file mode 100644 index 0000000..65de2c7 --- /dev/null +++ b/static/add_bottle.js @@ -0,0 +1,729 @@ +// 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) +// 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 +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 ---------- + +// 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(); +} + +// 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() { + 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 = ` + + + + + `; + box.append(field); + 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); } + }); + }); +} + +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); +} + +// ---------- 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() { + 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 || ""; +} + +// ---------- 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"); + } +} + +// ---------- 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() { + 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"); + const stateSlot = document.getElementById("state-slot"); + box.innerHTML = ""; + 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 +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-form [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), + 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(), + }; +} + +// 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); +} + +// ---------- 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: 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() { + 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); + } + // 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)); + // 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 = ""; + updatePreview(); + }); + try { + // 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(); +} + +start(); 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..e9cd50e 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,8 @@ - Inventory Manager + ELN Toolkit + - ← Back to Inventory Manager + ← Back to ELN Toolkit

ELN Server Settings

Maps this server's actions onto your team's eLabFTW status and resource @@ -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/static/tabs.css b/static/tabs.css new file mode 100644 index 0000000..54768e1 --- /dev/null +++ b/static/tabs.css @@ -0,0 +1,29 @@ +/* The ELN 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