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

Check out similar posts by category: InvenTree, Python