InvenTree: exportación masiva en PDF de etiquetas de ubicaciones de stock mediante scripting en Python
Este script de exportación automática de ubicaciones de stock permite exportar un gran número de ubicaciones y, opcionalmente, combinarlas en un PDF multipágina. Esto es mucho más rápido que exportarlas individualmente desde la interfaz web de InvenTree.
Lo uso con mis plantillas personalizadas de 62 mm para Brother y la plantilla de ubicación de stock compatible con Brother-QL de 62x27 mm para InvenTree junto con BrotherQLLabelPrintService, que permite imprimir directamente PDFs multipágina en los drivers de la serie Brother QL.
export_inventree_stock_locations.py
#!/usr/bin/env python3
"""Descarga y combina etiquetas PDF de ubicaciones de almacenamiento de InvenTree.
Este script se conecta a una instancia de InvenTree mediante la REST API,
recupera ubicaciones de stock (opcionalmente filtradas por patrón de nombre
y/o ubicación padre), genera etiquetas PDF en paralelo utilizando una
plantilla de etiquetas de InvenTree y combina los resultados en un único
archivo PDF.
La configuración se lee desde ``config.yaml`` en el mismo directorio::
inventree:
server: https://inventree.example.com
token: your-api-token-here
Requisitos:
- Python 3.8+
- requests
- PyYAML
- pypdf
Ejemplos de uso::
# Generar etiquetas para TODAS las ubicaciones de almacenamiento, combinadas en un PDF
python3 export_location_labels.py
# Filtrar por nombre: patrón glob (con *) o subcadena (sin *)
python3 export_location_labels.py -q "Schublade A*"
python3 export_location_labels.py -q "Schublade"
# Filtrar por ubicación padre (nombre o pk numérico)
python3 export_location_labels.py -p Apothekerschrank
python3 export_location_labels.py -p 9
# Combinar ambos filtros
python3 export_location_labels.py -q "Schublade A*" -p Apothekerschrank
# Excluir ubicaciones específicas por nombre (glob o subcadena, mismas reglas que -q)
python3 export_location_labels.py -e "Test*" -e "Illerbeuren"
# Incluir solo ubicaciones específicas (anula exclusiones y -q)
python3 export_location_labels.py -i "Schublade A1" -i "Schublade B2"
# Incluir + excluir combinados: include tiene prioridad
python3 export_location_labels.py -i "Schublade*" -e "Schublade C*"
# Seleccionar una plantilla de etiqueta específica (-t abreviatura)
python3 export_location_labels.py -t "Lagerort Groß 62mm"
# Ruta de salida personalizada para el PDF combinado
python3 export_location_labels.py -o ./my_labels.pdf
# Escribir PDFs individuales en un directorio en lugar de combinarlos
python3 export_location_labels.py --individual -o ./my_labels_dir/
# Salida con nombre automático: deriva el nombre del término de búsqueda
# "Schublade A*" -> Schublade_A.pdf (combinado) o Schublade_A/ (individual)
python3 export_location_labels.py -q "Schublade A*"
# Ajustar paralelismo y timeout
python3 export_location_labels.py --workers 16 --timeout 120
Filtrado:
Todas las coincidencias de nombre normalizan los espacios en blanco:
cualquier secuencia de caracteres de espacio en blanco (espacios,
tabulaciones, saltos de línea, etc.) tanto en el nombre de la ubicación
como en el patrón de query/exclude/include se colapsa a un único espacio
antes de la comparación.
- **Filtro por nombre** (``-q``): Si la cadena de búsqueda contiene ``*``,
se trata como patrón glob (p. ej. ``"Schublade A*"`` coincide con
``Schublade A1``, ``Schublade A10``, etc.). Si no hay ``*``, se utiliza
una coincidencia de subcadena insensible a mayúsculas/minúsculas.
- **Filtro por padre** (``-p``): Filtra ubicaciones cuyo padre coincide
con el valor dado. El valor puede ser el nombre de la ubicación padre
(p. ej. ``Apothekerschrank``) o su clave primaria numérica (p. ej. ``9``).
- **Excluir** (``-e``): Excluye ubicaciones que coinciden con el patrón
dado. Se puede especificar múltiples veces. Mismas reglas glob/subcadena
que ``-q``. Una ubicación se excluye si coincide con *cualquier* patrón
de exclusión.
- **Incluir** (``-i``): Incluye solo ubicaciones que coinciden con el
patrón dado. Se puede especificar múltiples veces. Mismas reglas
glob/subcadena que ``-q``. Una ubicación se incluye si coincide con
*cualquier* patrón de inclusión. **Include anula tanto ``-q`` como
``-e``**: cuando se proporciona ``-i``, el filtro de búsqueda se ignora
y las ubicaciones excluidas que coinciden con un patrón de inclusión
se siguen incluyendo.
Salida:
Por defecto, todos los PDFs de etiquetas generados se combinan en un
único archivo PDF usando ``pypdf``. Cuando se especifica ``--individual``,
cada etiqueta se escribe como un archivo PDF separado en un directorio.
La ruta de salida se determina así:
1. Si se proporciona ``-o``, se usa directamente (ruta de archivo para
modo combinado, ruta de directorio para modo individual).
2. Si no se proporciona ``-o``, el nombre se deriva automáticamente:
- Del término de búsqueda ``-q`` con los caracteres glob (``*``, ``?``)
eliminados y los espacios reemplazados por guiones bajos, p. ej.
``"Schublade A*"`` -> ``Schublade_A.pdf``.
- Si no hay ``-q`` pero sí ``-i``, del primer patrón de inclusión
(misma eliminación).
- Si ninguno, ``StockLocationLabels.pdf`` (combinado) o
``StockLocationLabels/`` (individual).
Cómo funciona:
1. Obtiene todas las ubicaciones de stock de la API de InvenTree (paginado).
2. Aplica los filtros opcionales de nombre, padre, inclusión y exclusión.
3. Obtiene las plantillas de etiqueta ``stocklocation`` disponibles.
4. Envía un job de impresión de etiqueta para cada ubicación coincidente
en paralelo mediante ``POST /api/label/print/``.
5. Consulta ``GET /api/data-output/<pk>/`` hasta que cada job se complete.
6. Descarga el PDF generado desde la ruta de salida.
7. Combina todos los PDFs individuales en uno usando ``pypdf`` (o los
escribe individualmente si se especifica ``--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):
"""Obtiene todas las ubicaciones de stock mediante llamadas paginadas a la 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):
"""Obtiene todas las plantillas de etiqueta habilitadas para ubicaciones de stock."""
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):
"""Envía un job de impresión de etiqueta, consulta hasta que se complete, descarga el PDF.
Devuelve el contenido del 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):
"""Hace que una cadena sea segura para usar como nombre de archivo."""
for ch in r'<>:"/\\|?*':
name = name.replace(ch, "_")
return name.strip()
def normalize_ws(s):
"""Colapsa todas las secuencias de espacios en blanco en una cadena a espacios simples."""
return re.sub(r"\s+", " ", s).strip()
def name_matches(name, pattern):
"""Comprueba si un nombre de ubicación coincide con un patrón.
Glob si el patrón contiene *, en caso contrario subcadena insensible
a mayúsculas/minúsculas. Los espacios en blanco se normalizan en ambos
antes de la comparación.
"""
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):
"""Filtra ubicaciones por patrón de nombre, padre, listas de inclusión/exclusión.
- query: patrón glob (si contiene *) o subcadena insensible a mayúsculas/minúsculas
- parent: nombre de ubicación padre o pk (cadena de entero)
- includes: lista de patrones; si no está vacía, solo se conservan las
ubicaciones que coinciden (anula query y excludes)
- excludes: lista de patrones; las ubicaciones que coinciden se eliminan
"""
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="Descarga y combina etiquetas PDF de ubicaciones de almacenamiento de InvenTree"
)
parser.add_argument(
"-q", "--query", default=None,
help="Filtrar ubicaciones por nombre (glob si contiene *, en caso contrario subcadena)",
)
parser.add_argument(
"-p", "--parent", default=None,
help="Filtrar por ubicación padre (nombre o pk numérico)",
)
parser.add_argument(
"-e", "--exclude", action="append", default=[],
help="Excluir ubicaciones que coincidan con este patrón (glob o subcadena). Se puede especificar múltiples veces.",
)
parser.add_argument(
"-i", "--include", action="append", default=[],
help="Incluir solo ubicaciones que coincidan con este patrón (glob o subcadena). Anula -q y -e. Se puede especificar múltiples veces.",
)
parser.add_argument(
"-t", "--template", default=None,
help="Nombre de la plantilla de etiqueta a usar (por defecto: la primera disponible)",
)
parser.add_argument(
"-o", "--output", default=None,
help="Ruta de salida: archivo PDF (modo combinado) o directorio (modo individual). "
"Si no se especifica, se deriva automáticamente del término de búsqueda o patrón de inclusión.",
)
parser.add_argument(
"--individual", action="store_true",
help="Escribir PDFs individuales en un directorio en lugar de combinarlos en un único PDF",
)
parser.add_argument(
"--workers", type=int, default=8,
help="Número de jobs de impresión en paralelo (por defecto: 8)",
)
parser.add_argument(
"--timeout", type=int, default=60,
help="Timeout en segundos por job de etiqueta (por defecto: 60)",
)
args = parser.parse_args()
config = load_config()
api = InvenTreeAPI(config["server"], config["token"])
# --- Obtener plantillas 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']})")
# --- Obtener y filtrar ubicaciones ---
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 en paralelo ---
print(f"\nGenerating {len(locations)} labels in parallel "
f"({args.workers} workers)...")
results = {} # pk -> (nombre, pdf_bytes o 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 ruta de salida ---
def derive_name():
"""Deriva automáticamente el nombre de salida desde query o el primer patrón de inclusión."""
source = None
if args.query:
source = args.query
elif args.include:
source = args.include[0]
if source:
# Eliminar caracteres glob, normalizar espacios en blanco, reemplazar espacios 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")
# --- Escribir salida ---
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()Si este artículo te ha ayudado, considera invitarme a un café o hacer una donación vía PayPal para apoyar la investigación y publicación de nuevos artículos en TechOverflow