InvenTree: exportação em lote de etiquetas de local de estoque em PDF via script Python

Este script de exportação automática de locais de estoque pode exportar um grande número de locais de estoque e, opcionalmente, mesclá-los em um PDF de várias páginas. Isso é muito mais rápido do que exportá-los individualmente pela interface web do InvenTree.

Eu o uso com meus modelos personalizados de 62mm para Brother e com o modelo de local de estoque compatível com Brother QL de 62x27mm e o BrotherQLLabelPrintService, que suporta a impressão direta de PDFs de várias páginas nos drivers da série Brother QL.

export_inventree_stock_locations.py
#!/usr/bin/env python3
"""Baixar e mesclar etiquetas de local de armazenamento em PDF do InvenTree.

Este script se conecta a uma instância do InvenTree via API REST, recupera
locais de estoque (opcionalmente filtrados por padrão de nome e/ou local
pai), gera etiquetas em PDF em paralelo usando um modelo de etiqueta do
InvenTree e mescla os resultados em um único arquivo PDF.

A configuração é lida de ``config.yaml`` no mesmo diretório::

    inventree:
      server: https://inventree.example.com
      token: your-api-token-here

Requisitos:
    - Python 3.8+
    - requests
    - PyYAML
    - pypdf

Exemplos de uso::

    # Gerar etiquetas para TODOS os locais de armazenamento, mescladas em um PDF
    python3 export_location_labels.py

    # Filtrar por nome: padrão glob (com *) ou substring (sem *)
    python3 export_location_labels.py -q "Schublade A*"
    python3 export_location_labels.py -q "Schublade"

    # Filtrar por local pai (nome ou pk numérico)
    python3 export_location_labels.py -p Apothekerschrank
    python3 export_location_labels.py -p 9

    # Combinar ambos os filtros
    python3 export_location_labels.py -q "Schublade A*" -p Apothekerschrank

    # Excluir locais específicos por nome (glob ou substring, mesmas regras de -q)
    python3 export_location_labels.py -e "Test*" -e "Illerbeuren"

    # Incluir apenas locais específicos (sobrepõe exclusões e -q)
    python3 export_location_labels.py -i "Schublade A1" -i "Schublade B2"

    # Inclusão + exclusão combinadas: inclusão tem precedência
    python3 export_location_labels.py -i "Schublade*" -e "Schublade C*"

    # Selecionar um modelo de etiqueta específico (-t é a abreviação)
    python3 export_location_labels.py -t "Lagerort Groß 62mm"

    # Caminho de saída personalizado para o PDF mesclado
    python3 export_location_labels.py -o ./my_labels.pdf

    # Escrever PDFs individuais em um diretório em vez de mesclar
    python3 export_location_labels.py --individual -o ./my_labels_dir/

    # Saída com nome automático: deriva o nome do arquivo do termo de busca
    # "Schublade A*" -> Schublade_A.pdf (mesclado) ou Schublade_A/ (individual)
    python3 export_location_labels.py -q "Schublade A*"

    # Ajustar paralelismo e timeout
    python3 export_location_labels.py --workers 16 --timeout 120

Filtragem:
    Toda correspondência de nome normaliza espaços em branco: qualquer
    sequência de caracteres de espaço em branco (espaços, tabs, newlines,
    etc.) tanto no nome do local quanto no padrão de
    busca/exclusão/inclusão é colapsada para um único espaço antes da
    comparação.

    - **Filtro de nome** (``-q``): Se a string de consulta contiver ``*``,
      ela é tratada como um padrão glob (ex.: ``"Schublade A*"`` corresponde
      a ``Schublade A1``, ``Schublade A10``, etc.). Se não houver ``*``,
      é usada uma correspondência de substring que diferencia maiúsculas de
      minúsculas.

    - **Filtro de pai** (``-p``): Filtra locais cujo pai corresponde ao
      valor informado. O valor pode ser tanto o nome do local pai (ex.:
      ``Apothekerschrank``) quanto sua chave primária numérica (ex.: ``9``).

    - **Exclusão** (``-e``): Exclui locais que correspondem ao padrão
      informado. Pode ser especificado múltiplas vezes. Mesmas regras de
      glob/substring de ``-q``. Um local é excluído se corresponder a
      *qualquer* padrão de exclusão.

    - **Inclusão** (``-i``): Inclui apenas locais que correspondem ao
      padrão informado. Pode ser especificado múltiplas vezes. Mesmas
      regras de glob/substring de ``-q``. Um local é incluído se
      corresponder a *qualquer* padrão de inclusão. **A inclusão sobrepõe
      tanto ``-q`` quanto ``-e``**: quando ``-i`` é fornecido, o filtro de
      consulta é ignorado e locais excluídos que correspondem a um padrão
      de inclusão ainda são incluídos.

Saída:
    Por padrão, todos os PDFs de etiquetas gerados são mesclados em um
    único arquivo PDF usando ``pypdf``. Quando ``--individual`` é
    fornecido, cada etiqueta é escrita como um arquivo PDF separado em um
    diretório.

    O caminho de saída é determinado da seguinte forma:

    1. Se ``-o`` for fornecido, ele é usado diretamente (caminho de
       arquivo para modo mesclado, caminho de diretório para modo
       individual).
    2. Se ``-o`` não for fornecido, o nome é derivado automaticamente:
       - Do termo de busca ``-q`` com caracteres glob (``*``, ``?``)
         removidos e espaços substituídos por underscores, ex.:
         ``"Schublade A*"`` -> ``Schublade_A.pdf``.
       - Se não houver ``-q`` mas houver ``-i``, do primeiro padrão de
         inclusão (mesma remoção).
       - Se nenhum dos dois, ``StockLocationLabels.pdf`` (mesclado) ou
         ``StockLocationLabels/`` (individual).

Como funciona:
    1. Busca todos os locais de estoque da API do InvenTree (paginada).
    2. Aplica os filtros opcionais de nome, pai, inclusão e exclusão.
    3. Busca os modelos de etiqueta ``stocklocation`` disponíveis.
    4. Submete um job de impressão de etiqueta para cada local
       correspondente em paralelo via ``POST /api/label/print/``.
    5. Consulta ``GET /api/data-output/<pk>/`` até que cada job seja
       concluído.
    6. Baixa o PDF gerado a partir do caminho de saída.
    7. Mescla todos os PDFs individuais em um usando ``pypdf`` (ou os
       escreve individualmente se ``--individual`` for fornecido).
"""

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):
    """Buscar todos os locais de estoque via chamadas paginadas da API."""
    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):
    """Buscar todos os modelos de etiqueta habilitados para locais de estoque."""
    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):
    """Submeter um job de impressão de etiqueta, aguardar conclusão, baixar o PDF.

    Retorna o conteúdo do PDF como 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):
    """Tornar uma string segura para uso como nome de arquivo."""
    for ch in r'<>:"/\\|?*':
        name = name.replace(ch, "_")
    return name.strip()


def normalize_ws(s):
    """Colapsar todas as sequências de espaço em branco em espaços simples."""
    return re.sub(r"\s+", " ", s).strip()


def name_matches(name, pattern):
    """Verificar se um nome de local corresponde a um padrão.

    Glob se o padrão contiver *, caso contrário substring sem distinção de maiúsculas.
    Espaços em branco são normalizados em ambos antes da comparação.
    """
    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):
    """Filtrar locais por padrão de nome, pai, listas de inclusão/exclusão.

    - query: padrão glob (se contiver *) ou substring sem distinção de maiúsculas
    - parent: nome do local pai ou pk (string int)
    - includes: lista de padrões; se não vazia, apenas locais correspondentes
      são mantidos (sobrepõe query e excludes)
    - excludes: lista de padrões; locais correspondentes são removidos
    """
    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"])

    # --- Buscar modelos de etiqueta ---
    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']})")

    # --- Buscar e filtrar locais ---
    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']})")

    # --- Imprimir etiquetas em paralelo ---
    print(f"\nGenerating {len(locations)} labels in parallel "
          f"({args.workers} workers)...")

    results = {}  # pk -> (nome, pdf_bytes ou 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)

    # --- Determinar caminho de saída ---
    def derive_name():
        """Derivar automaticamente o nome de saída da consulta ou primeiro padrão de inclusão."""
        source = None
        if args.query:
            source = args.query
        elif args.include:
            source = args.include[0]
        if source:
            # Remover caracteres glob, normalizar espaços, substituir espaços por _
            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")

    # --- Escrever saída ---
    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()

Check out similar posts by category: InvenTree, Python