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
+
+
+
+ You're not logged in. Open the start page, enter your eLabFTW API key, then come back.
+
+
+
+
+
+
+
+
+
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