InvenTree: Hromadný export štítků skladových míst do PDF pomocí Python skriptu
Tento automatický skript pro export skladových míst dokáže exportovat velké množství skladových míst a volitelně je sloučit do vícestránkového PDF. To je mnohem rychlejší než exportovat je jednotlivě z webového rozhraní InvenTree.
Používám jej s mými vlastními 62mm Brother šablonami a InvenTree Brother-QL kompatibilní šablonou štítku skladového místa 62x27mm a BrotherQLLabelPrintService, který podporuje tisk vícestránkových PDF přímo přes ovladače Brother QL series.
export_inventree_stock_locations.py
#!/usr/bin/env python3
"""Stáhne a sloučí PDF štítky skladových míst z InvenTree.
Tento skript se připojuje k instanci InvenTree přes REST API, načítá
skladová místa (volitelně filtrovaná podle vzoru názvu a/nebo nadřazené
lokality), generuje PDF štítky paralelně pomocí šablony štítků InvenTree
a sloučí výsledky do jednoho PDF souboru.
Konfigurace se načítá z ``config.yaml`` ve stejném adresáři::
inventree:
server: https://inventree.example.com
token: your-api-token-here
Požadavky:
- Python 3.8+
- requests
- PyYAML
- pypdf
Příklady použití::
# Generovat štítky pro VŠECHNA skladová místa, sloučené do jednoho PDF
python3 export_location_labels.py
# Filtrovat podle názvu: glob vzor (s *) nebo podřetězec (bez *)
python3 export_location_labels.py -q "Schublade A*"
python3 export_location_labels.py -q "Schublade"
# Filtrovat podle nadřazené lokality (název nebo číselný pk)
python3 export_location_labels.py -p Apothekerschrank
python3 export_location_labels.py -p 9
# Kombinovat oba filtry
python3 export_location_labels.py -q "Schublade A*" -p Apothekerschrank
# Vyloučit konkrétní lokality podle názvu (glob nebo podřetězec, stejná pravidla jako -q)
python3 export_location_labels.py -e "Test*" -e "Illerbeuren"
# Zahrnout pouze konkrétní lokality (přepisuje vyloučení a -q)
python3 export_location_labels.py -i "Schublade A1" -i "Schublade B2"
# Zahrnout + vyloučit kombinováno: zahrnutí má přednost
python3 export_location_labels.py -i "Schublade*" -e "Schublade C*"
# Vybrat konkrétní šablonu štítků (-q zkratka)
python3 export_location_labels.py -t "Lagerort Groß 62mm"
# Vlastní cesta výstupu pro sloučené PDF
python3 export_location_labels.py -o ./my_labels.pdf
# Zapsat jednotlivá PDF do adresáře místo sloučení
python3 export_location_labels.py --individual -o ./my_labels_dir/
# Automaticky pojmenovaný výstup: odvodí název souboru z hledaného termínu
# "Schublade A*" -> Schublade_A.pdf (sloučené) nebo Schublade_A/ (jednotlivé)
python3 export_location_labels.py -q "Schublade A*"
# Upravit paralelismus a timeout
python3 export_location_labels.py --workers 16 --timeout 120
Filtrování:
Veškeré porovnávání názvů normalizuje mezery: jakákoliv sekvence mezer
(mezery, tabulátory, nové řádky atd.) v názvu lokality i ve vzoru
query/exclude/include je před porovnáním zredukována na jednu mezeru.
- **Filtr názvu** (``-q``): Pokud hledaný řetězec obsahuje ``*``, je
brán jako glob vzor (např. ``"Schublade A*"`` odpovídá
``Schublade A1``, ``Schublade A10`` atd.). Pokud ``*`` chybí,
použije se case-insensitive porovnání podřetězce.
- **Filtr nadřazené lokality** (``-p``): Filtruje lokality, jejichž nadřazená
lokality odpovídá zadané hodnotě. Hodnota může být buď název nadřazené
lokality (např. ``Apothekerschrank``) nebo její číselný primární klíč
(např. ``9``).
- **Vyloučení** (``-e``): Vyloučí lokality odpovídající zadanému vzoru.
Lze zadat vícekrát. Stejná pravidla glob/podřetězec jako u ``-q``.
Lokalita je vyloučena, pokud odpovídá *libovolnému* vzoru vyloučení.
- **Zahrnutí** (``-i``): Zahrne pouze lokality odpovídající zadanému
vzoru. Lze zadat vícekrát. Stejná pravidla glob/podřetězec jako u ``-q``.
Lokalita je zahrnuta, pokud odpovídá *libovolnému* vzoru zahrnutí.
**Zahrnutí přepisuje jak ``-q``, tak ``-e``**: pokud je zadáno
``-i``, filtr query je ignorován a vyloučené lokality, které odpovídají
vzoru zahrnutí, jsou stále zahrnuty.
Výstup:
Ve výchozím nastavení jsou všechny vygenerované PDF štítky sloučeny
do jednoho PDF souboru pomocí ``pypdf``. Při zadání ``--individual``
je každý štítek zapsán jako samostatný PDF soubor v adresáři.
Výstupní cesta je určena následovně:
1. Pokud je zadáno ``-o``, použije se přímo (cesta k souboru pro sloučený
režim, cesta k adresáři pro jednotlivý režim).
2. Pokud ``-o`` není zadáno, název je automaticky odvozen:
- Z hledaného termínu ``-q`` se odstraní glob znaky (``*``, ``?``)
a mezery se nahradí podtržítky, např.
``"Schublade A*"`` -> ``Schublade_A.pdf``.
- Pokud chybí ``-q``, ale je zadáno ``-i``, z prvního vzoru zahrnutí
(stejné odstranění).
- Pokud ani jedno, ``StockLocationLabels.pdf`` (sloučené) nebo
``StockLocationLabels/`` (jednotlivé).
Jak to funguje:
1. Načte všechna skladová místa z API InvenTree (paginovaně).
2. Aplikuje volitelné filtry názvu, nadřazené lokality, zahrnutí a vyloučení.
3. Načte dostupné šablony štítků ``stocklocation``.
4. Odešle úlohu tisku štítku pro každou odpovídající lokalitu paralelně
přes ``POST /api/label/print/``.
5. Polluje ``GET /api/data-output/<pk>/`` až do dokončení každé úlohy.
6. Stáhne vygenerované PDF z výstupní cesty.
7. Sloučí všechna jednotlivá PDF do jednoho pomocí ``pypdf`` (nebo je zapíše
jednotlivě, pokud je zadáno ``--individual``).
"""
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):
"""Načte všechna skladová místa přes paginovaná API volání."""
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):
"""Načte všechny povolené šablony štítků pro skladová místa."""
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):
"""Odešle úlohu tisku štítku, polluje až do dokončení, stáhne PDF.
Vrací obsah PDF jako bytes.
"""
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):
"""Upraví řetězec pro bezpečné použití jako název souboru."""
for ch in r'<>:"/\\|?*':
name = name.replace(ch, "_")
return name.strip()
def normalize_ws(s):
"""Redukuje všechny sekvence mezer v řetězci na jednotlivé mezery."""
return re.sub(r"\s+", " ", s).strip()
def name_matches(name, pattern):
"""Zkontroluje, zda název lokality odpovídá vzoru.
Glob pokud vzor obsahuje *, jinak case-insensitive podřetězec.
Mezery jsou v obou před porovnáním normalizovány.
"""
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):
"""Filtruje lokality podle vzoru názvu, nadřazené lokality, seznamů zahrnutí/vyloučení.
- query: glob vzor (pokud obsahuje *) nebo case-insensitive podřetězec
- parent: název nadřazené lokality nebo pk (int string)
- includes: seznam vzorů; pokud neprázdný, pouze odpovídající lokality
jsou zachovány (přepisuje query a excludes)
- excludes: seznam vzorů; odpovídající lokality jsou odstraněny
"""
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="Download and merge PDF storage location labels from InvenTree"
)
parser.add_argument(
"-q", "--query", default=None,
help="Filter locations by name (glob if contains *, else substring)",
)
parser.add_argument(
"-p", "--parent", default=None,
help="Filter by parent location (name or numeric pk)",
)
parser.add_argument(
"-e", "--exclude", action="append", default=[],
help="Exclude locations matching this pattern (glob or substring). Can be given multiple times.",
)
parser.add_argument(
"-i", "--include", action="append", default=[],
help="Include only locations matching this pattern (glob or substring). Overrides -q and -e. Can be given multiple times.",
)
parser.add_argument(
"-t", "--template", default=None,
help="Name of the label template to use (default: first available)",
)
parser.add_argument(
"-o", "--output", default=None,
help="Output path: PDF file (merged mode) or directory (individual mode). "
"If not given, auto-derived from the search term or include pattern.",
)
parser.add_argument(
"--individual", action="store_true",
help="Write individual PDFs to a directory instead of merging into one PDF",
)
parser.add_argument(
"--workers", type=int, default=8,
help="Number of parallel print jobs (default: 8)",
)
parser.add_argument(
"--timeout", type=int, default=60,
help="Timeout in seconds per label job (default: 60)",
)
args = parser.parse_args()
config = load_config()
api = InvenTreeAPI(config["server"], config["token"])
# --- Načtení šablon štítků ---
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']})")
# --- Načtení a filtrování lokalit ---
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']})")
# --- Paralelní tisk štítků ---
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)
# --- Určení výstupní cesty ---
def derive_name():
"""Automaticky odvodí název výstupu z query nebo prvního vzoru zahrnutí."""
source = None
if args.query:
source = args.query
elif args.include:
source = args.include[0]
if source:
# Odstranit glob znaky, normalizovat mezery, nahradit mezery _
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")
# --- Zápis výstupu ---
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()Pokud vám tento článek pomohl, zvažte, zda mi nekoupíte kávu nebo nepřispějete přes PayPal na podporu výzkumu a publikování nových článků na TechOverflow