InvenTree: Lagerort-Labels als PDF exportieren
Dieses Skript exportiert automatisch eine große Anzahl von Lagerorten und führt sie optional zu einem mehrseitigen PDF zusammen. Das ist deutlich schneller als der Einzelexport über die InvenTree-Weboberfläche.
Ich nutze es mit meinen eigenen 62mm-Brother-Vorlagen, der InvenTree Brother-QL-kompatiblen 62x27mm-Lagerort-Vorlage und BrotherQLLabelPrintService. Dieser Service unterstützt mehrseitige PDFs und druckt direkt auf Brother-QL-Druckern.
export_inventree_stock_locations.py
#!/usr/bin/env python3
"""Lagerort-Labels aus InvenTree herunterladen und zusammenführen.
Dieses Skript verbindet sich über die REST-API mit einer InvenTree-Instanz,
ruft Lagerorte ab (optional gefiltert nach Namensmuster und/oder
übergeordnetem Lagerort), erzeugt parallel PDF-Labels anhand einer
InvenTree-Label-Vorlage und führt die Ergebnisse in einer einzigen PDF-Datei
zusammen.
Die Konfiguration wird aus ``config.yaml`` im selben Verzeichnis gelesen::
inventree:
server: https://inventree.example.com
token: your-api-token-here
Voraussetzungen:
- Python 3.8+
- requests
- PyYAML
- pypdf
Anwendungsbeispiele::
# Labels für ALLE Lagerorte erzeugen und in ein PDF zusammenführen
python3 export_location_labels.py
# Nach Namen filtern: Glob-Muster (mit *) oder Teilstring (ohne *)
python3 export_location_labels.py -q "Schublade A*"
python3 export_location_labels.py -q "Schublade"
# Nach übergeordnetem Lagerort filtern (Name oder numerischer pk)
python3 export_location_labels.py -p Apothekerschrank
python3 export_location_labels.py -p 9
# Beide Filter kombinieren
python3 export_location_labels.py -q "Schublade A*" -p Apothekerschrank
# Bestimmte Lagerorte nach Namen ausschließen (Glob oder Teilstring, gleiche Regeln wie -q)
python3 export_location_labels.py -e "Test*" -e "Illerbeuren"
# Nur bestimmte Lagerorte einschließen (überstimmt excludes und -q)
python3 export_location_labels.py -i "Schublade A1" -i "Schublade B2"
# Include + Exclude kombiniert: Include hat Vorrang
python3 export_location_labels.py -i "Schublade*" -e "Schublade C*"
# Eine bestimmte Label-Vorlage auswählen (-t als Abkürzung)
python3 export_location_labels.py -t "Lagerort Groß 62mm"
# Eigener Ausgabepfad für das zusammengeführte PDF
python3 export_location_labels.py -o ./my_labels.pdf
# Einzelne PDFs in ein Verzeichnis schreiben statt zusammenzuführen
python3 export_location_labels.py --individual -o ./my_labels_dir/
# Automatisch benannte Ausgabe: Dateiname wird vom Suchbegriff abgeleitet
# "Schublade A*" -> Schublade_A.pdf (zusammengeführt) oder Schublade_A/ (einzeln)
python3 export_location_labels.py -q "Schublade A*"
# Parallelität und Timeout anpassen
python3 export_location_labels.py --workers 16 --timeout 120
Filterung:
Beim Abgleich von Namen wird Whitespace normalisiert: jede Folge von
Whitespace-Zeichen (Leerzeichen, Tabs, Zeilenumbrüche usw.) sowohl im
Lagerortnamen als auch im Such-/Exclude-/Include-Muster wird vor dem
Vergleich zu einem einzelnen Leerzeichen zusammengefasst.
- **Namensfilter** (``-q``): Enthält der Suchbegriff ``*``, wird er als
Glob-Muster behandelt (z. B. passt ``"Schublade A*"`` auf
``Schublade A1``, ``Schublade A10`` usw.). Ohne ``*`` wird ein
Groß-/Kleinschreibung ignorierender Teilstring-Abgleich verwendet.
- **Übergeordneter Lagerort** (``-p``): Filtert Lagerorte, deren
übergeordneter Lagerort zum angegebenen Wert passt. Der Wert kann
entweder der Name (z. B. ``Apothekerschrank``) oder der numerische
Primärschlüssel (z. B. ``9``) sein.
- **Exclude** (``-e``): Schließt Lagerorte aus, die zum angegebenen
Muster passen. Mehrfach angebbar. Gleiche Glob-/Teilstring-Regeln wie
``-q``. Ein Lagerort wird ausgeschlossen, wenn er auf *irgendein*
Exclude-Muster passt.
- **Include** (``-i``): Schließt nur Lagerorte ein, die zum angegebenen
Muster passen. Mehrfach angebbar. Gleiche Glob-/Teilstring-Regeln wie
``-q``. Ein Lagerort wird eingeschlossen, wenn er auf *irgendein*
Include-Muster passt. **Include überstimmt sowohl ``-q`` als auch
``-e``**: Wenn ``-i`` angegeben ist, wird der Namensfilter ignoriert
und ausgeschlossene Lagerorte, die auf ein Include-Muster passen,
werden dennoch eingeschlossen.
Ausgabe:
Standardmäßig werden alle erzeugten Label-PDFs mit ``pypdf`` zu einer
einzigen PDF-Datei zusammengeführt. Mit ``--individual`` wird jedes
Label stattdessen als separate PDF-Datei in ein Verzeichnis
geschrieben.
Der Ausgabepfad wird wie folgt bestimmt:
1. Wenn ``-o`` angegeben ist, wird es direkt verwendet (Dateipfad für
den Zusammenführungsmodus, Verzeichnispfad für den Einzelmodus).
2. Ohne ``-o`` wird der Name automatisch abgeleitet:
- Aus dem ``-q``-Suchbegriff, wobei Glob-Zeichen (``*``, ``?``)
entfernt und Leerzeichen durch Unterstriche ersetzt werden, z. B.
``"Schublade A*"`` -> ``Schublade_A.pdf``.
- Ohne ``-q``, aber mit ``-i``, aus dem ersten Include-Muster
(gleiche Bereinigung).
- Ohne beide: ``StockLocationLabels.pdf`` (zusammengeführt) bzw.
``StockLocationLabels/`` (einzeln).
Funktionsweise:
1. Ruft alle Lagerorte über die InvenTree-API ab (paginiert).
2. Wendet die optionalen Filter für Name, übergeordneten Lagerort,
Include und Exclude an.
3. Ruft die verfügbaren ``stocklocation``-Label-Vorlagen ab.
4. Sendet für jeden passenden Lagerort parallel einen Label-Druckauftrag
über ``POST /api/label/print/``.
5. Pollt ``GET /api/data-output/<pk>/``, bis jeder Auftrag fertig ist.
6. Lädt das erzeugte PDF vom Ausgabepfad herunter.
7. Führt alle einzelnen PDFs mit ``pypdf`` zu einer zusammen (oder
schreibt sie einzeln, wenn ``--individual`` angegeben ist).
"""
import argparse
import fnmatch
import io
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import requests
import yaml
from pypdf import PdfWriter, PdfReader
CONFIG_PATH = Path(__file__).parent / "config.yaml"
def load_config():
with open(CONFIG_PATH, "r") as f:
return yaml.safe_load(f)["inventree"]
class InvenTreeAPI:
def __init__(self, server, token):
self.server = server.rstrip("/")
self.token = token
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Token {token}",
})
def get(self, path, params=None):
r = self.session.get(f"{self.server}{path}", params=params)
r.raise_for_status()
return r
def post(self, path, data=None, json=None):
r = self.session.post(f"{self.server}{path}", data=data, json=json)
if r.status_code == 400:
print(f" ERROR 400: {r.text}")
r.raise_for_status()
return r
def get_all_locations(api):
"""Alle Lagerorte über paginierte API-Aufrufe abrufen."""
locations = []
offset = 0
while True:
r = api.get("/api/stock/location/", params={
"limit": 100, "offset": offset,
})
data = r.json()
locations.extend(data["results"])
if not data["next"]:
break
offset += 100
return locations
def get_location_templates(api):
"""Alle aktivierten Label-Vorlagen für Lagerorte abrufen."""
templates = []
offset = 0
while True:
r = api.get("/api/label/template/", params={
"limit": 100, "offset": offset,
"model_type": "stocklocation", "enabled": True,
})
data = r.json()
templates.extend(data["results"])
if not data["next"]:
break
offset += 100
return templates
def print_and_download_label(api, template_pk, item_pks, timeout=60):
"""Label-Druckauftrag senden, bis zur Fertigstellung pollen, PDF herunterladen.
Gibt den PDF-Inhalt als Bytes zurück.
"""
r = api.post("/api/label/print/", json={
"template": template_pk,
"items": item_pks,
})
result = r.json()
output_pk = result["pk"]
deadline = time.time() + timeout
while time.time() < deadline:
r = api.get(f"/api/data-output/{output_pk}/")
data = r.json()
if data.get("complete"):
output_path = data.get("output")
if not output_path:
raise RuntimeError(
f"Label output {output_pk} complete but no output path"
)
pdf_url = f"{api.server}{output_path}"
pr = api.session.get(pdf_url)
pr.raise_for_status()
return pr.content
time.sleep(0.5)
raise TimeoutError(
f"Label output {output_pk} did not complete within {timeout}s"
)
def sanitize_filename(name):
"""Einen String für die Verwendung als Dateinamen absichern."""
for ch in r'<>:"/\\|?*':
name = name.replace(ch, "_")
return name.strip()
def normalize_ws(s):
"""Alle Whitespace-Folgen in einem String zu einzelnen Leerzeichen zusammenfassen."""
return re.sub(r"\s+", " ", s).strip()
def name_matches(name, pattern):
"""Prüfen, ob ein Lagerortname zu einem Muster passt.
Glob, wenn das Muster * enthält, sonst Groß-/Kleinschreibung ignorierender
Teilstring. Whitespace wird in beiden vor dem Vergleich normalisiert.
"""
name = normalize_ws(name)
pattern = normalize_ws(pattern)
if "*" in pattern:
return fnmatch.fnmatch(name, pattern)
return pattern.lower() in name.lower()
def filter_locations(locations, query, parent, includes, excludes):
"""Lagerorte nach Namensmuster, übergeordnetem Lagerort und Include-/Exclude-Listen filtern.
- query: Glob-Muster (bei *) oder Groß-/Kleinschreibung ignorierender Teilstring
- parent: Name oder pk (als String) des übergeordneten Lagerorts
- includes: Liste von Mustern; bei nicht-leerer Liste werden nur
passende Lagerorte behalten (überstimmt query und excludes)
- excludes: Liste von Mustern; passende Lagerorte werden entfernt
"""
if includes:
filtered = [l for l in locations if any(name_matches(l.get("name", ""), p) for p in includes)]
else:
filtered = locations
if query:
filtered = [l for l in filtered if name_matches(l.get("name", ""), query)]
if parent:
parent_pk = None
if parent.isdigit():
parent_pk = int(parent)
else:
for l in locations:
if normalize_ws(l.get("name", "")) == normalize_ws(parent):
parent_pk = l["pk"]
break
if parent_pk is None:
print(f"ERROR: Parent location '{parent}' not found")
sys.exit(1)
filtered = [l for l in filtered if l.get("parent") == parent_pk]
if excludes and not includes:
filtered = [l for l in filtered if not any(name_matches(l.get("name", ""), p) for p in excludes)]
elif excludes and includes:
filtered = [l for l in filtered if not any(name_matches(l.get("name", ""), p) for p in excludes) or any(name_matches(l.get("name", ""), p) for p in includes)]
return filtered
def main():
parser = argparse.ArgumentParser(
description="Lagerort-Labels aus InvenTree herunterladen und zusammenführen"
)
parser.add_argument(
"-q", "--query", default=None,
help="Lagerorte nach Namen filtern (Glob bei *, sonst Teilstring)",
)
parser.add_argument(
"-p", "--parent", default=None,
help="Nach übergeordnetem Lagerort filtern (Name oder numerischer pk)",
)
parser.add_argument(
"-e", "--exclude", action="append", default=[],
help="Lagerorte ausschließen, die zu diesem Muster passen (Glob oder Teilstring). Mehrfach angebbar.",
)
parser.add_argument(
"-i", "--include", action="append", default=[],
help="Nur Lagerorte einschließen, die zu diesem Muster passen (Glob oder Teilstring). Überstimmt -q und -e. Mehrfach angebbar.",
)
parser.add_argument(
"-t", "--template", default=None,
help="Name der zu verwendenden Label-Vorlage (Standard: erste verfügbare)",
)
parser.add_argument(
"-o", "--output", default=None,
help="Ausgabepfad: PDF-Datei (Zusammenführungsmodus) oder Verzeichnis (Einzelmodus). "
"Ohne Angabe wird der Name automatisch vom Suchbegriff oder Include-Muster abgeleitet.",
)
parser.add_argument(
"--individual", action="store_true",
help="Einzelne PDFs in ein Verzeichnis schreiben statt in ein PDF zusammenzuführen",
)
parser.add_argument(
"--workers", type=int, default=8,
help="Anzahl paralleler Druckaufträge (Standard: 8)",
)
parser.add_argument(
"--timeout", type=int, default=60,
help="Timeout in Sekunden pro Label-Auftrag (Standard: 60)",
)
args = parser.parse_args()
config = load_config()
api = InvenTreeAPI(config["server"], config["token"])
# --- Label-Vorlagen abrufen ---
templates = get_location_templates(api)
if not templates:
print("ERROR: No enabled label templates found for model_type 'stocklocation'")
sys.exit(1)
print(f"Found {len(templates)} stock location label template(s):")
for t in templates:
print(f" - {t['name']} (pk={t['pk']}, {t['width']}x{t['height']}mm)")
selected = None
if args.template:
for t in templates:
if t["name"] == args.template:
selected = t
break
if not selected:
print(f"ERROR: Template '{args.template}' not found")
sys.exit(1)
else:
selected = templates[0]
print(f"\nUsing template: {selected['name']} (pk={selected['pk']})")
# --- Lagerorte abrufen und filtern ---
locations = get_all_locations(api)
print(f"Found {len(locations)} total stock locations")
locations = filter_locations(locations, args.query, args.parent, args.include, args.exclude)
print(f"After filtering: {len(locations)} locations")
if not locations:
print("No locations match the filter criteria.")
return
for loc in locations:
print(f" {loc['name']} (pk={loc['pk']})")
# --- Labels parallel erzeugen ---
print(f"\nGenerating {len(locations)} labels in parallel "
f"({args.workers} workers)...")
results = {} # pk -> (name, pdf_bytes or None)
errors = {}
def _print_one(loc):
name = loc.get("name", f"location_{loc['pk']}")
pk = loc["pk"]
try:
pdf = print_and_download_label(
api, selected["pk"], [pk], timeout=args.timeout
)
return pk, name, pdf, None
except Exception as e:
return pk, name, None, str(e)
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {pool.submit(_print_one, loc): loc for loc in locations}
for fut in as_completed(futures):
pk, name, pdf, err = fut.result()
if err:
print(f" FAILED: {name} (pk={pk}): {err}")
errors[pk] = err
else:
print(f" OK: {name} (pk={pk}, {len(pdf)} bytes)")
results[pk] = (name, pdf)
if not results:
print("\nERROR: No labels were generated successfully.")
sys.exit(1)
# --- Ausgabepfad bestimmen ---
def derive_name():
"""Ausgabename automatisch vom Suchbegriff oder ersten Include-Muster ableiten."""
source = None
if args.query:
source = args.query
elif args.include:
source = args.include[0]
if source:
# Glob-Zeichen entfernen, Whitespace normalisieren, Leerzeichen durch _ ersetzen
cleaned = re.sub(r"[*?]", "", source)
cleaned = normalize_ws(cleaned).replace(" ", "_")
cleaned = sanitize_filename(cleaned)
return cleaned if cleaned else "StockLocationLabels"
return "StockLocationLabels"
if args.output:
output_path = Path(args.output)
else:
base_name = derive_name()
if args.individual:
output_path = Path(base_name)
else:
output_path = Path(f"{base_name}.pdf")
# --- Ausgabe schreiben ---
if args.individual:
output_path.mkdir(parents=True, exist_ok=True)
print(f"\nWriting {len(results)} individual PDFs to {output_path}/...")
for pk in sorted(results.keys()):
name, pdf_bytes = results[pk]
safe_name = sanitize_filename(normalize_ws(name).replace(" ", "_"))
pdf_path = output_path / f"{safe_name}.pdf"
pdf_path.write_bytes(pdf_bytes)
print(f" {pdf_path.name} ({len(pdf_bytes)} bytes)")
print(f"\nDone: {len(results)} labels written, {len(errors)} failed")
print(f"Output directory: {output_path.resolve()}")
else:
print(f"\nMerging {len(results)} PDFs into {output_path}...")
writer = PdfWriter()
for pk in sorted(results.keys()):
name, pdf_bytes = results[pk]
reader = PdfReader(io.BytesIO(pdf_bytes))
for page in reader.pages:
writer.add_page(page)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
writer.write(f)
print(f"\nDone: {len(results)} labels merged, {len(errors)} failed")
print(f"Output: {output_path.resolve()}")
if __name__ == "__main__":
main()Wenn dieser Beitrag dir geholfen hat, erwäge, mir einen Kaffee zu spendieren oder per PayPal zu spenden, um die Recherche und Veröffentlichung neuer Beiträge auf TechOverflow zu unterstützen