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()

Podívejte se na podobné články podle kategorie: InvenTree, Python