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()If this post helped you, please consider buying me a coffee or donating via PayPal to support research & publishing of new posts on TechOverflow