InvenTree: массовый экспорт PDF-этикеток мест хранения через скрипт на Python
Этот скрипт автоматического экспорта мест хранения может экспортировать большое количество мест хранения и при необходимости объединять их в многостраничный PDF. Это намного быстрее, чем экспортировать их по отдельности через веб-интерфейс InvenTree.
Я использую его со своими пользовательскими 62mm Brother-шаблонами и InvenTree Brother-QL совместимым шаблоном мест хранения 62x27mm и BrotherQLLabelPrintService, который поддерживает печать многостраничных PDF непосредственно на принтерах серии Brother QL.
export_inventree_stock_locations.py
#!/usr/bin/env python3
"""Загрузка и объединение PDF-этикеток мест хранения из InvenTree.
Этот скрипт подключается к экземпляру InvenTree через REST API, получает
места хранения (с необязательной фильтрацией по шаблону имени и/или
родительскому местоположению), генерирует PDF-этикетки параллельно с
использованием шаблона этикеток InvenTree и объединяет результаты в
один PDF-файл.
Конфигурация читается из ``config.yaml`` в том же каталоге::
inventree:
server: https://inventree.example.com
token: your-api-token-here
Требования:
- Python 3.8+
- requests
- PyYAML
- pypdf
Примеры использования::
# Генерация этикеток для ВСЕХ мест хранения, объединённых в один PDF
python3 export_location_labels.py
# Фильтр по имени: glob-шаблон (с *) или подстрока (без *)
python3 export_location_labels.py -q "Schublade A*"
python3 export_location_labels.py -q "Schublade"
# Фильтр по родительскому местоположению (имя или числовой pk)
python3 export_location_labels.py -p Apothekerschrank
python3 export_location_labels.py -p 9
# Комбинация обоих фильтров
python3 export_location_labels.py -q "Schublade A*" -p Apothekerschrank
# Исключение определённых мест по имени (glob или подстрока, те же правила, что и -q)
python3 export_location_labels.py -e "Test*" -e "Illerbeuren"
# Включение только определённых мест (переопределяет исключения и -q)
python3 export_location_labels.py -i "Schublade A1" -i "Schublade B2"
# Включение + исключение: включение имеет приоритет
python3 export_location_labels.py -i "Schublade*" -e "Schublade C*"
# Выбор конкретного шаблона этикетки (-t сокращение)
python3 export_location_labels.py -t "Lagerort Groß 62mm"
# Пользовательский путь вывода для объединённого PDF
python3 export_location_labels.py -o ./my_labels.pdf
# Запись отдельных PDF в каталог вместо объединения
python3 export_location_labels.py --individual -o ./my_labels_dir/
# Автоматическое именование вывода: имя файла выводится из поискового запроса
# "Schublade A*" -> Schublade_A.pdf (объединённый) или Schublade_A/ (отдельные)
python3 export_location_labels.py -q "Schublade A*"
# Настройка параллелизма и таймаута
python3 export_location_labels.py --workers 16 --timeout 120
Фильтрация:
Все сравнения по имени нормализуют пробелы: любая последовательность
пробельных символов (пробелы, табы, переводы строк и т.д.) в имени
места и в шаблоне запроса/исключения/включения сворачивается в
один пробел перед сравнением.
- **Фильтр по имени** (``-q``): Если строка запроса содержит ``*``,
она обрабатывается как glob-шаблон (например ``"Schublade A*"``
соответствует ``Schublade A1``, ``Schublade A10`` и т.д.). Если
``*`` отсутствует, используется регистронезависимый поиск подстроки.
- **Фильтр по родителю** (``-p``): Фильтрует места, родитель которых
соответствует заданному значению. Значение может быть либо именем
родительского места (например ``Apothekerschrank``), либо его
числовым первичным ключом (например ``9``).
- **Исключение** (``-e``): Исключает места, соответствующие заданному
шаблону. Можно указать несколько раз. Те же правила glob/подстроки,
что и для ``-q``. Место исключается, если соответствует *любому*
шаблону исключения.
- **Включение** (``-i``): Включает только места, соответствующие
заданному шаблону. Можно указать несколько раз. Те же правила
glob/подстроки, что и для ``-q``. Место включается, если
соответствует *любому* шаблону включения. **Включение переопределяет
и ``-q``, и ``-e``**: если задано ``-i``, фильтр запроса
игнорируется, а исключённые места, соответствующие шаблону
включения, всё равно включаются.
Вывод:
По умолчанию все сгенерированные PDF-этикетки объединяются в один
PDF-файл с помощью ``pypdf``. Если задан ``--individual``, каждая
этикетка записывается как отдельный PDF-файл в каталог.
Путь вывода определяется следующим образом:
1. Если задан ``-o``, он используется напрямую (путь к файлу для
объединённого режима, путь к каталогу для режима отдельных файлов).
2. Если ``-o`` не задан, имя выводится автоматически:
- Из поискового запроса ``-q`` с удалёнными glob-символами
(``*``, ``?``) и заменой пробелов на подчёркивания, например
``"Schublade A*"`` -> ``Schublade_A.pdf``.
- Если нет ``-q``, но есть ``-i``, из первого шаблона включения
(та же очистка).
- Если ни того, ни другого, ``StockLocationLabels.pdf``
(объединённый) или ``StockLocationLabels/`` (отдельные).
Как это работает:
1. Получает все места хранения из API InvenTree (постранично).
2. Применяет необязательные фильтры по имени, родителю, включению
и исключению.
3. Получает доступные шаблоны этикеток ``stocklocation``.
4. Параллельно отправляет задание печати этикетки для каждого
соответствующего места через ``POST /api/label/print/``.
5. Опрашивает ``GET /api/data-output/<pk>/`` до завершения каждого
задания.
6. Загружает сгенерированный PDF по пути вывода.
7. Объединяет все отдельные PDF в один с помощью ``pypdf``
(или записывает их по отдельности, если задан ``--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):
"""Получение всех мест хранения через постраничные вызовы 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):
"""Получение всех включённых шаблонов этикеток для мест хранения."""
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):
"""Отправка задания печати этикетки, ожидание завершения, загрузка PDF.
Возвращает содержимое PDF в виде байтов.
"""
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):
"""Делает строку безопасной для использования в качестве имени файла."""
for ch in r'<>:"/\\|?*':
name = name.replace(ch, "_")
return name.strip()
def normalize_ws(s):
"""Сворачивает все последовательности пробелов в строке в одинарные пробелы."""
return re.sub(r"\s+", " ", s).strip()
def name_matches(name, pattern):
"""Проверяет, соответствует ли имя места шаблону.
Glob, если шаблон содержит *, иначе регистронезависимая подстрока.
Пробелы нормализуются в обоих перед сравнением.
"""
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):
"""Фильтрация мест по шаблону имени, родителю, спискам включения/исключения.
- query: glob-шаблон (если содержит *) или регистронезависимая подстрока
- parent: имя родительского места или pk (строка с числом)
- includes: список шаблонов; если непустой, только соответствующие места
остаются (переопределяет query и excludes)
- excludes: список шаблонов; соответствующие места удаляются
"""
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"])
# --- Получение шаблонов этикеток ---
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']})")
# --- Получение и фильтрация мест ---
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']})")
# --- Параллельная печать этикеток ---
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)
# --- Определение пути вывода ---
def derive_name():
"""Автоматический вывод имени из запроса или первого шаблона включения."""
source = None
if args.query:
source = args.query
elif args.include:
source = args.include[0]
if source:
# Удаление glob-символов, нормализация пробелов, замена пробелов на _
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")
# --- Запись вывода ---
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