Files
paperless-ngx/src/documents/tests/test_barcodes.py
T
jurassicparkicecreamandshamoon 62cc31fbdb Feature: store barcode contents, list and search them (#14276)
* Feature: store barcode contents, list and search them

New setting PAPERLESS_CONSUMER_STORE_BARCODE_VALUES (off by default)
stores all barcodes found during consumption with the document: page,
type and content. They are listed on the metadata tab with a copy
button, returned by the documents API and searchable with barcodes:
in the advanced search. Versions keep their own barcodes, reprocessing
reads them again. Refs #9898

* Tests: cover the remaining barcode branches

Covers unchanged and failing barcode reads on reprocessing, unsupported files and DocumentBarcode.__str__, and uses toHaveLength in the barcode list spec as suggested by SonarCloud.

* Address review: keep barcodes when they can't be read, format choices

- Reprocessing and new versions keep the stored barcodes when the file
  can't be scanned or the scan fails, and replace them atomically.
- Format is a TextChoices of the zxing-cpp formats, with a test.
- Shared scan code, latest_version helper, TypedDict, serializer reuse.
- Barcodes in the split manifest, export/import tests, pytest-style tests.

* Barcode tests: TIFF reprocess, format check both ways, fixtures

- Reprocessing a TIFF with TIFF support off keeps the stored barcodes.
- The format test also fails when zxing-cpp drops a format.
- Fixtures in place of the sample dir mixin, plugin-level disabled test.
- Format labels aren't translated, OpenAPI enum named BarcodeFormatEnum.

* Review: module-level zxing reader, shorter barcode docs

- read_barcodes_zxing is a module-level function used by scan_pdf.
- Drop the trivial __str__ test, mark it no cover.
- Shorten the barcode docs and remove the duplicate in configuration.md.

* Fix header

* Use utility class

* Return barcodes from the metadata endpoint only

Drop the barcodes field and its prefetches from the document serializer,
as agreed in the review. Also remove the now empty component stylesheet.

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-05 16:25:19 +00:00

1445 lines
50 KiB
Python

from __future__ import annotations
import shutil
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
import zxingcpp
from django.conf import settings
from django.test import TestCase
from django.test import override_settings
from rest_framework import status
from documents import tasks
from documents.barcodes import BarcodePlugin
from documents.barcodes import read_barcode_values
from documents.consumer import ConsumerError
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.models import Document
from documents.models import DocumentBarcode
from documents.models import Tag
from documents.plugins.base import StopConsumeTaskError
from documents.tests.utils import ConsumeTaskMixin
from documents.tests.utils import SampleDirMixin
from paperless.config import BarcodeConfig
from paperless.models import ApplicationConfiguration
from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.fakes.progress import FakeProgressManager
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Generator
from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture
from rest_framework.test import APIClient
from paperless_testing.dirs import PaperlessDirs
class GetReaderPluginMixin:
@contextmanager
def get_reader(self, filepath: Path) -> Generator[BarcodePlugin, None, None]:
reader = BarcodePlugin(
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
DocumentMetadataOverrides(),
FakeProgressManager(filepath.name, None),
self.dirs.scratch_dir,
"task-id",
)
reader.setup()
yield reader
reader.cleanup()
class TestBarcode(
DirectoriesMixin,
FileSystemAssertsMixin,
SampleDirMixin,
GetReaderPluginMixin,
TestCase,
):
def test_scan_file_for_separating_barcodes(self) -> None:
"""
GIVEN:
- PDF containing barcodes
WHEN:
- File is scanned for barcodes
THEN:
- Correct page index located
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {0: False})
@override_settings(
CONSUMER_BARCODE_TIFF_SUPPORT=True,
)
def test_scan_tiff_for_separating_barcodes(self) -> None:
"""
GIVEN:
- TIFF image containing barcodes
WHEN:
- Consume task returns
THEN:
- The file was split
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.tiff"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertDictEqual(separator_page_numbers, {1: False})
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
@pytest.mark.usefixtures("fake_progress_manager")
def test_asn_barcode_duplicate_in_trash_fails(self) -> None:
"""
GIVEN:
- A document with ASN barcode 123 is in the trash
WHEN:
- A file with the same barcode ASN is consumed
THEN:
- The ASN check is re-run and consumption fails
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-123.pdf"
first_doc = Document.objects.create(
title="First ASN 123",
content="",
checksum="asn123first",
mime_type="application/pdf",
archive_serial_number=123,
)
first_doc.delete()
dupe_asn = settings.SCRATCH_DIR / "barcode-39-asn-123-second.pdf"
shutil.copy(test_file, dupe_asn)
with self.assertRaisesRegex(ConsumerError, r"ASN 123.*trash"):
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dupe_asn,
),
None,
)
@override_settings(
CONSUMER_BARCODE_TIFF_SUPPORT=True,
)
def test_scan_tiff_with_alpha_for_separating_barcodes(self) -> None:
"""
GIVEN:
- TIFF image containing barcodes
WHEN:
- Consume task returns
THEN:
- The file was split
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle-alpha.tiff"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertDictEqual(separator_page_numbers, {1: False})
def test_scan_file_for_separating_barcodes_none_present(self) -> None:
"""
GIVEN:
- File with no barcodes
WHEN:
- File is scanned
THEN:
- No barcodes detected
- No pages to split on
"""
test_file = self.SAMPLE_DIR / "simple.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {})
def test_scan_file_for_separating_barcodes_middle_page(self) -> None:
"""
GIVEN:
- PDF file containing a separator on page 1 (zero indexed)
WHEN:
- File is scanned for barcodes
THEN:
- Barcode is detected on page 1 (zero indexed)
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {1: False})
def test_scan_file_for_separating_barcodes_multiple_pages(self) -> None:
"""
GIVEN:
- PDF file containing a separator on pages 2 and 5 (zero indexed)
WHEN:
- File is scanned for barcodes
THEN:
- Barcode is detected on pages 2 and 5 (zero indexed)
"""
test_file = self.BARCODE_SAMPLE_DIR / "several-patcht-codes.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {2: False, 5: False})
def test_scan_file_for_separating_barcodes_hard_to_detect(self) -> None:
"""
GIVEN:
- PDF file containing a separator on page 1 (zero indexed)
- The barcode is upside down, fuzzy or distorted
WHEN:
- File is scanned for barcodes
THEN:
- Barcode is detected on page 1 (zero indexed)
"""
for test_file in [
"patch-code-t-middle-reverse.pdf",
"patch-code-t-middle-distorted.pdf",
"patch-code-t-middle-fuzzy.pdf",
]:
test_file = self.BARCODE_SAMPLE_DIR / test_file
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {1: False})
def test_scan_file_for_separating_barcodes_unreadable(self) -> None:
"""
GIVEN:
- PDF file containing a separator on page 1 (zero indexed)
- The barcode is not readable
WHEN:
- File is scanned for barcodes
THEN:
- Barcode is detected on page 1 (zero indexed)
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle-unreadable.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {})
def test_scan_file_for_separating_barcodes_fax_decode(self) -> None:
"""
GIVEN:
- A PDF containing an image encoded as CCITT Group 4 encoding
WHEN:
- Barcode processing happens with the file
THEN:
- The barcode is still detected
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-fax-image.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {1: False})
def test_scan_file_for_separating_qr_barcodes(self) -> None:
"""
GIVEN:
- PDF file containing a separator on page 0 (zero indexed)
- The barcode is a QR code
WHEN:
- File is scanned for barcodes
THEN:
- Barcode is detected on page 0 (zero indexed)
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-qr.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {0: False})
@override_settings(CONSUMER_BARCODE_STRING="CUSTOM BARCODE")
def test_scan_file_for_separating_custom_barcodes(self) -> None:
"""
GIVEN:
- PDF file containing a separator on page 0 (zero indexed)
- The barcode separation value is customized
WHEN:
- File is scanned for barcodes
THEN:
- Barcode is detected on page 0 (zero indexed)
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-custom.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {0: False})
@override_settings(CONSUMER_BARCODE_STRING="CUSTOM BARCODE")
def test_scan_file_for_separating_custom_qr_barcodes(self) -> None:
"""
GIVEN:
- PDF file containing a separator on page 0 (zero indexed)
- The barcode separation value is customized
- The barcode is a QR code
WHEN:
- File is scanned for barcodes
THEN:
- Barcode is detected on page 0 (zero indexed)
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-qr-custom.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {0: False})
@override_settings(CONSUMER_BARCODE_STRING="CUSTOM BARCODE")
def test_scan_file_for_separating_custom_128_barcodes(self) -> None:
"""
GIVEN:
- PDF file containing a separator on page 0 (zero indexed)
- The barcode separation value is customized
- The barcode is a 128 code
WHEN:
- File is scanned for barcodes
THEN:
- Barcode is detected on page 0 (zero indexed)
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-128-custom.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {0: False})
def test_scan_file_for_separating_wrong_qr_barcodes(self) -> None:
"""
GIVEN:
- PDF file containing a separator on page 0 (zero indexed)
- The barcode value is customized
- The separation value is NOT customized
WHEN:
- File is scanned for barcodes
THEN:
- No split pages are detected
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-custom.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {})
@override_settings(CONSUMER_BARCODE_STRING="ADAR-NEXTDOC")
def test_scan_file_qr_barcodes_was_problem(self) -> None:
"""
GIVEN:
- Input PDF with certain QR codes that aren't detected at current size
WHEN:
- The input file is scanned for barcodes
THEN:
- QR codes are detected
"""
test_file = self.BARCODE_SAMPLE_DIR / "many-qr-codes.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertGreater(len(reader.barcodes), 0)
self.assertDictEqual(separator_page_numbers, {1: False})
def test_scan_file_for_separating_barcodes_password(self) -> None:
"""
GIVEN:
- Password protected PDF
WHEN:
- File is scanned for barcode
THEN:
- Scanning handles the exception without crashing
"""
test_file = self.SAMPLE_DIR / "password-is-test.pdf"
with self.assertLogs("paperless.barcodes", level="WARNING") as cm:
with self.get_reader(test_file) as reader:
reader.detect()
warning = cm.output[0]
expected_str = "WARNING:paperless.barcodes:File is likely password protected, not checking for barcodes"
self.assertTrue(warning.startswith(expected_str))
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {})
def test_separate_pages(self) -> None:
"""
GIVEN:
- Input PDF 2 pages after separation
WHEN:
- The input file separated at the barcode
THEN:
- Two new documents are produced
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
with self.get_reader(test_file) as reader:
documents = reader.separate_pages({1: False})
self.assertEqual(reader.pdf_file, test_file)
self.assertEqual(len(documents), 2)
def test_separate_pages_double_code(self) -> None:
"""
GIVEN:
- Input PDF with two patch code pages in a row
WHEN:
- The input file is split
THEN:
- Only two files are output
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-double.pdf"
with self.get_reader(test_file) as reader:
documents = reader.separate_pages({1: False, 2: False})
self.assertEqual(len(documents), 2)
@override_settings(CONSUMER_ENABLE_BARCODES=True)
def test_separate_pages_no_list(self) -> None:
"""
GIVEN:
- Input file to separate
WHEN:
- No separation pages are provided
THEN:
- Nothing happens
"""
test_file = self.SAMPLE_DIR / "simple.pdf"
with self.get_reader(test_file) as reader:
try:
reader.run()
except StopConsumeTaskError:
self.fail("Barcode reader split pages unexpectedly")
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_BARCODE_TIFF_SUPPORT=True,
)
def test_consume_barcode_unsupported_jpg_file(self) -> None:
"""
GIVEN:
- JPEG image as input
WHEN:
- Consume task returns
THEN:
- Barcode reader reported warning
- Consumption continued with the file
"""
test_file = self.SAMPLE_DIR / "simple.jpg"
with self.get_reader(test_file) as reader:
self.assertFalse(reader.able_to_run)
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_ENABLE_ASN_BARCODE=True,
)
def test_separate_pages_by_asn_barcodes_and_patcht(self) -> None:
"""
GIVEN:
- Input PDF with a patch code on page 3 and ASN barcodes on pages 1,5,6,9,11
WHEN:
- Input file is split on barcodes
THEN:
- Correct number of files produced, split correctly by correct pages
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-asn-2.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(
reader.pdf_file,
test_file,
)
self.assertDictEqual(
separator_page_numbers,
{
2: False,
4: True,
5: True,
8: True,
10: True,
},
)
document_list = reader.separate_pages(separator_page_numbers)
self.assertEqual(len(document_list), 6)
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_ENABLE_ASN_BARCODE=True,
)
def test_separate_pages_by_asn_barcodes(self) -> None:
"""
GIVEN:
- Input PDF with ASN barcodes on pages 1,3,4,7,9
WHEN:
- Input file is split on barcodes
THEN:
- Correct number of files produced, split correctly by correct pages
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-asn-1.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(
separator_page_numbers,
{
2: True,
3: True,
6: True,
8: True,
},
)
document_list = reader.separate_pages(separator_page_numbers)
self.assertEqual(len(document_list), 5)
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_ENABLE_ASN_BARCODE=True,
CONSUMER_BARCODE_RETAIN_SPLIT_PAGES=True,
)
def test_separate_pages_by_asn_barcodes_and_patcht_retain_pages(self) -> None:
"""
GIVEN:
- Input PDF with a patch code on page 3 and ASN barcodes on pages 1,5,6,9,11
- Retain split pages is enabled
WHEN:
- Input file is split on barcodes
THEN:
- Correct number of files produced, split correctly by correct pages, and the split pages are retained
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-asn-2.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(
reader.pdf_file,
test_file,
)
self.assertDictEqual(
separator_page_numbers,
{
2: True,
4: True,
5: True,
8: True,
10: True,
},
)
def test_barcode_config(self) -> None:
"""
GIVEN:
- Barcode app config is set (settings are not)
WHEN:
- Document with barcode is processed
THEN:
- The barcode config is used
"""
app_config = ApplicationConfiguration.objects.first()
assert app_config is not None
app_config.barcodes_enabled = True
app_config.barcode_string = "CUSTOM BARCODE"
app_config.save()
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-custom.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertEqual(reader.pdf_file, test_file)
self.assertDictEqual(separator_page_numbers, {0: False})
class TestBarcodeNewConsume(
DirectoriesMixin,
FileSystemAssertsMixin,
SampleDirMixin,
ConsumeTaskMixin,
TestCase,
):
@override_settings(CONSUMER_ENABLE_BARCODES=True)
@pytest.mark.usefixtures("fake_progress_manager")
def test_consume_barcode_file(self) -> None:
"""
GIVEN:
- Incoming file with at 1 barcode producing 2 documents
- Document includes metadata override information
WHEN:
- The document is split
THEN:
- Two new consume tasks are created
- Metadata overrides are preserved for the new consume
- The document source is unchanged (for consume templates)
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t-middle.pdf"
temp_copy = self.dirs.scratch_dir / test_file.name
shutil.copy(test_file, temp_copy)
overrides = DocumentMetadataOverrides(tag_ids=[1, 2, 9])
self.assertEqual(
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=temp_copy,
),
overrides,
),
{"reason": "Barcode splitting complete!"},
)
# 2 new document consume tasks created
self.assertEqual(self.consume_file_mock.call_count, 2)
self.assertIsNotFile(temp_copy)
# Check the split files exist
# Check the original_path is set
# Check the source is unchanged
# Check the overrides are unchanged
for (
new_input_doc,
new_doc_overrides,
) in self.get_all_consume_task_call_args():
self.assertIsFile(new_input_doc.original_file)
self.assertEqual(new_input_doc.original_path, temp_copy)
self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder)
self.assertEqual(overrides, new_doc_overrides)
class TestAsnBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, TestCase):
@contextmanager
def get_reader(self, filepath: Path) -> BarcodePlugin:
reader = BarcodePlugin(
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
DocumentMetadataOverrides(),
FakeProgressManager(filepath.name, None),
self.dirs.scratch_dir,
"task-id",
)
reader.setup()
yield reader
reader.cleanup()
@override_settings(CONSUMER_ASN_BARCODE_PREFIX="CUSTOM-PREFIX-")
def test_scan_file_for_asn_custom_prefix(self) -> None:
"""
GIVEN:
- PDF containing an ASN barcode with custom prefix
- The ASN value is 123
WHEN:
- File is scanned for barcodes
THEN:
- The ASN is located
- The ASN integer value is correct
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with self.get_reader(test_file) as reader:
asn = reader.asn
self.assertEqual(reader.pdf_file, test_file)
self.assertEqual(asn, 123)
def test_scan_file_for_asn_barcode(self) -> None:
"""
GIVEN:
- PDF containing an ASN barcode
- The ASN value is 123
WHEN:
- File is scanned for barcodes
THEN:
- The ASN is located
- The ASN integer value is correct
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-123.pdf"
with self.get_reader(test_file) as reader:
asn = reader.asn
self.assertEqual(reader.pdf_file, test_file)
self.assertEqual(asn, 123)
def test_scan_file_for_asn_not_found(self) -> None:
"""
GIVEN:
- PDF without an ASN barcode
WHEN:
- File is scanned for barcodes
THEN:
- No ASN is retrieved from the document
"""
test_file = self.BARCODE_SAMPLE_DIR / "patch-code-t.pdf"
with self.get_reader(test_file) as reader:
asn = reader.asn
self.assertEqual(reader.pdf_file, test_file)
self.assertEqual(asn, None)
def test_scan_file_for_asn_barcode_invalid(self) -> None:
"""
GIVEN:
- PDF containing an ASN barcode
- The ASN value is XYZXYZ
WHEN:
- File is scanned for barcodes
THEN:
- The ASN is located
- The ASN value is not used
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-invalid.pdf"
with self.get_reader(test_file) as reader:
asn = reader.asn
self.assertEqual(reader.pdf_file, test_file)
self.assertEqual(reader.pdf_file, test_file)
self.assertEqual(asn, None)
@override_settings(CONSUMER_ENABLE_ASN_BARCODE=True)
@pytest.mark.usefixtures("fake_progress_manager")
def test_consume_barcode_file_asn_assignment(self) -> None:
"""
GIVEN:
- PDF containing an ASN barcode
- The ASN value is 123
WHEN:
- File is scanned for barcodes
THEN:
- The ASN is located
- The ASN integer value is correct
- The ASN is provided as the override value to the consumer
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-123.pdf"
dst = settings.SCRATCH_DIR / "barcode-39-asn-123.pdf"
shutil.copy(test_file, dst)
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dst,
),
None,
)
document = Document.objects.first()
assert document is not None
self.assertEqual(document.archive_serial_number, 123)
def test_scan_file_for_qrcode_without_upscale(self) -> None:
"""
GIVEN:
- A printed and scanned PDF document with a rather small QR code
WHEN:
- ASN barcode detection is run with default settings
THEN:
- ASN 123 is detected
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-qr-asn-000123-upscale-dpi.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
self.assertEqual(len(reader.barcodes), 1)
self.assertEqual(reader.asn, 123)
@override_settings(CONSUMER_BARCODE_DPI=600)
@override_settings(CONSUMER_BARCODE_UPSCALE=1.5)
def test_scan_file_for_qrcode_with_upscale(self) -> None:
"""
GIVEN:
- A printed and scanned PDF document with a rather small QR code
WHEN:
- ASN barcode detection is run with 600dpi and an upscale factor of 1.5
THEN:
- ASN 123 is detected
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-qr-asn-000123-upscale-dpi.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
self.assertEqual(len(reader.barcodes), 1)
self.assertEqual(reader.asn, 123)
class TestTagBarcode(DirectoriesMixin, SampleDirMixin, GetReaderPluginMixin, TestCase):
@contextmanager
def get_reader(self, filepath: Path) -> BarcodePlugin:
reader = BarcodePlugin(
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=filepath),
DocumentMetadataOverrides(),
FakeProgressManager(filepath.name, None),
self.dirs.scratch_dir,
"task-id",
)
reader.setup()
yield reader
reader.cleanup()
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"},
)
def test_barcode_without_tag_match(self) -> None:
"""
GIVEN:
- Barcode that does not match any TAG mapping pattern
- TAG mapping configured for "TAG:" prefix only
WHEN:
- is_tag property is checked on an ASN barcode
THEN:
- Returns False
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-123.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
self.assertGreater(
len(reader.barcodes),
0,
"Should have detected at least one barcode",
)
asn_barcode = reader.barcodes[0]
self.assertFalse(
asn_barcode.is_tag,
f"ASN barcode '{asn_barcode.value}' should not match TAG: pattern",
)
@override_settings(CONSUMER_ENABLE_TAG_BARCODE=True)
def test_scan_file_without_matching_barcodes(self) -> None:
"""
GIVEN:
- PDF containing tag barcodes but none with matching prefix (default "TAG:")
WHEN:
- File is scanned for barcodes
THEN:
- No TAG has been created
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with self.get_reader(test_file) as reader:
reader.run()
tags = reader.metadata.tag_ids
self.assertEqual(tags, None)
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=False,
CONSUMER_TAG_BARCODE_MAPPING={"CUSTOM-PREFIX-(.*)": "\\g<1>"},
)
def test_scan_file_with_matching_barcode_but_function_disabled(self) -> None:
"""
GIVEN:
- PDF containing a tag barcode with matching custom prefix
- The tag barcode functionality is disabled
WHEN:
- File is scanned for barcodes
THEN:
- No TAG has been created
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with self.get_reader(test_file) as reader:
reader.run()
tags = reader.metadata.tag_ids
self.assertEqual(tags, None)
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_MAPPING={"CUSTOM-PREFIX-(.*)": "\\g<1>"},
)
def test_scan_file_for_tag_custom_prefix(self) -> None:
"""
GIVEN:
- PDF containing a tag barcode with custom prefix
- The barcode mapping accepts this prefix and removes it from the mapped tag value
- The created tag is the non-prefixed values
WHEN:
- File is scanned for barcodes
THEN:
- The TAG is located
- One TAG has been created
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with self.get_reader(test_file) as reader:
reader.metadata.tag_ids = [99]
reader.run()
self.assertEqual(reader.pdf_file, test_file)
tags = reader.metadata.tag_ids
self.assertEqual(len(tags), 2)
self.assertEqual(tags[0], 99)
self.assertEqual(Tag.objects.get(name__iexact="00123").pk, tags[1])
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_MAPPING={"ASN(.*)": "\\g<1>"},
CONSUMER_ENABLE_ASN_BARCODE=False,
)
def test_scan_file_for_many_custom_tags(self) -> None:
"""
GIVEN:
- PDF containing multiple tag barcode with custom prefix
- The barcode mapping accepts this prefix and removes it from the mapped tag value
- The created tags are the non-prefixed values
WHEN:
- File is scanned for barcodes
THEN:
- The TAG is located
- File Tags have been created
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-asn-1.pdf"
with self.get_reader(test_file) as reader:
reader.run()
tags = reader.metadata.tag_ids
self.assertEqual(len(tags), 5)
self.assertEqual(Tag.objects.get(name__iexact="00123").pk, tags[0])
self.assertEqual(Tag.objects.get(name__iexact="00124").pk, tags[1])
self.assertEqual(Tag.objects.get(name__iexact="00125").pk, tags[2])
self.assertEqual(Tag.objects.get(name__iexact="00126").pk, tags[3])
self.assertEqual(Tag.objects.get(name__iexact="00127").pk, tags[4])
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_MAPPING={"CUSTOM-PREFIX-(.*)": "\\g<3>"},
)
def test_scan_file_for_tag_raises_value_error(self) -> None:
"""
GIVEN:
- Any error occurs during tag barcode processing
THEN:
- The processing should be skipped and not break the import
"""
test_file = self.BARCODE_SAMPLE_DIR / "barcode-39-asn-custom-prefix.pdf"
with self.get_reader(test_file) as reader:
reader.run()
# expect error to be caught and logged only
tags = reader.metadata.tag_ids
self.assertEqual(tags, None)
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_SPLIT=True,
CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"},
)
def test_split_on_tag_barcodes(self) -> None:
"""
GIVEN:
- PDF containing barcodes with TAG: prefix
- Tag barcode splitting is enabled with TAG: mapping
WHEN:
- File is processed
THEN:
- Splits should occur at pages with TAG barcodes
- Tags should NOT be assigned when tag splitting is enabled (they're assigned during re-consumption)
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-tag-basic.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_page_numbers = reader.get_separation_pages()
self.assertDictEqual(separator_page_numbers, {1: True, 3: True})
tags = reader.metadata.tag_ids
self.assertIsNone(tags)
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_SPLIT=False,
CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"},
)
def test_no_split_when_tag_split_disabled(self) -> None:
"""
GIVEN:
- PDF containing TAG barcodes (TAG:invoice, TAG:receipt)
- Tag barcode splitting is disabled
WHEN:
- File is processed
THEN:
- No separation pages are identified
- Tags are still extracted and assigned
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-tag-basic.pdf"
with self.get_reader(test_file) as reader:
reader.run()
separator_page_numbers = reader.get_separation_pages()
self.assertDictEqual(separator_page_numbers, {})
tags = reader.metadata.tag_ids
self.assertEqual(len(tags), 2)
@override_settings(
CONSUMER_ENABLE_BARCODES=True,
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_SPLIT=True,
CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"},
CELERY_TASK_ALWAYS_EAGER=True,
OCR_MODE="auto",
)
@pytest.mark.usefixtures("fake_progress_manager")
def test_consume_barcode_file_tag_split_and_assignment(self) -> None:
"""
GIVEN:
- PDF containing TAG barcodes on pages 2 and 4 (TAG:invoice, TAG:receipt)
- Tag barcode splitting is enabled
WHEN:
- File is consumed
THEN:
- PDF is split into 3 documents at barcode pages
- Each split document has the appropriate TAG barcodes extracted and assigned
- Document 1: page 1 (no tags)
- Document 2: pages 2-3 with TAG:invoice
- Document 3: pages 4-5 with TAG:receipt
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-tag-basic.pdf"
dst = settings.SCRATCH_DIR / "split-by-tag-basic.pdf"
shutil.copy(test_file, dst)
result = tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=dst,
),
None,
)
self.assertEqual(result, {"reason": "Barcode splitting complete!"})
documents = Document.objects.all().order_by("id")
self.assertEqual(documents.count(), 3)
doc1 = documents[0]
self.assertEqual(doc1.tags.count(), 0)
doc2 = documents[1]
self.assertEqual(doc2.tags.count(), 1)
_tag_1 = doc2.tags.first()
assert _tag_1 is not None
self.assertEqual(_tag_1.name, "invoice")
doc3 = documents[2]
self.assertEqual(doc3.tags.count(), 1)
_tag_2 = doc3.tags.first()
assert _tag_2 is not None
self.assertEqual(_tag_2.name, "receipt")
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_SPLIT=True,
CONSUMER_TAG_BARCODE_MAPPING={"ASN(.*)": "ASN_\\g<1>", "TAG:(.*)": "\\g<1>"},
)
def test_split_by_mixed_asn_tag_backwards_compat(self) -> None:
"""
GIVEN:
- PDF with mixed ASN and TAG barcodes
- Mapping that treats ASN barcodes as tags (backwards compatibility)
- ASN12345 on page 1, TAG:personal on page 3, ASN13456 on page 5, TAG:business on page 7
WHEN:
- File is consumed
THEN:
- Both ASN and TAG barcodes trigger splits
- Split points are at pages 3, 5, and 7 (page 1 never splits)
- 4 separate documents are produced
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-tag-mixed-asn.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_pages = reader.get_separation_pages()
self.assertDictEqual(separator_pages, {2: True, 4: True, 6: True})
document_list = reader.separate_pages(separator_pages)
self.assertEqual(len(document_list), 4)
@override_settings(
CONSUMER_ENABLE_TAG_BARCODE=True,
CONSUMER_TAG_BARCODE_SPLIT=True,
CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"},
)
def test_split_by_tag_multiple_per_page(self) -> None:
"""
GIVEN:
- PDF with multiple TAG barcodes on same page
- TAG:invoice and TAG:expense on page 2, TAG:receipt on page 4
WHEN:
- File is processed
THEN:
- Pages with barcodes trigger splits
- Split points at pages 2 and 4
- 3 separate documents are produced
"""
test_file = self.BARCODE_SAMPLE_DIR / "split-by-tag-multiple-per-page.pdf"
with self.get_reader(test_file) as reader:
reader.detect()
separator_pages = reader.get_separation_pages()
self.assertDictEqual(separator_pages, {1: True, 3: True})
document_list = reader.separate_pages(separator_pages)
self.assertEqual(len(document_list), 3)
SAMPLE_VALUES = [
{"page": 1, "value": "javascript:alert(1)", "format": "QRCode"},
{"page": 2, "value": "https://example.com/invoice/4711", "format": "QRCode"},
]
@pytest.fixture
def samples_dir() -> Path:
return Path(__file__).parent / "samples"
@pytest.fixture
def barcode_samples_dir(samples_dir: Path) -> Path:
return samples_dir / "barcodes"
@pytest.fixture
def store_barcodes(settings: Settings) -> None:
settings.CONSUMER_STORE_BARCODE_VALUES = True
@pytest.fixture
def barcode_reader(
paperless_dirs: PaperlessDirs,
) -> Generator[Callable[[Path], BarcodePlugin], None, None]:
readers: list[BarcodePlugin] = []
def make(path: Path) -> BarcodePlugin:
reader = BarcodePlugin(
ConsumableDocument(DocumentSource.ConsumeFolder, original_file=path),
DocumentMetadataOverrides(),
FakeProgressManager(path.name, None),
paperless_dirs.scratch_dir,
"task-id",
)
reader.setup()
readers.append(reader)
return reader
yield make
for reader in readers:
reader.cleanup()
@pytest.fixture
def consume_sample(
paperless_dirs: PaperlessDirs,
barcode_samples_dir: Path,
fake_progress_manager: type[FakeProgressManager],
settings: Settings,
) -> Callable[..., Document]:
settings.CELERY_TASK_ALWAYS_EAGER = True
settings.OCR_MODE = "auto"
def consume(name: str, *, root_document_id: int | None = None) -> Document:
dst = paperless_dirs.scratch_dir / name
shutil.copy(barcode_samples_dir / name, dst)
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ApiUpload
if root_document_id
else DocumentSource.ConsumeFolder,
original_file=dst,
root_document_id=root_document_id,
),
None,
)
return Document.objects.latest("id")
return consume
def _stored(document: Document) -> list[dict]:
return list(document.barcodes.values("page", "value", "format"))
def test_formats_cover_zxing() -> None:
"""
DocumentBarcode.Format matches the concrete formats of zxing-cpp, so a
zxing-cpp update that adds or removes one fails here
"""
members = zxingcpp.BarcodeFormat.__members__
# skip NONE and the groups like AllLinear, including their aliases
seen = {int(m) for n, m in members.items() if n == "NONE" or n.startswith("All")}
concrete = set()
for name, member in members.items():
# aliases such as DataBarExpanded come after the name zxing reports
if int(member) not in seen:
seen.add(int(member))
concrete.add(name)
assert set(DocumentBarcode.Format.values) == concrete
@pytest.mark.django_db
class TestBarcodeValues:
@pytest.mark.parametrize(
("filename", "mime_type", "tiff_support", "expected"),
[
pytest.param("simple.jpg", "image/jpeg", True, None, id="jpeg"),
pytest.param("simple.tiff", "image/tiff", False, None, id="tiff-off"),
pytest.param("simple.tiff", "image/tiff", True, [], id="tiff-on"),
],
)
def test_read_values_file_types(
self,
paperless_dirs: PaperlessDirs,
samples_dir: Path,
settings: Settings,
filename: str,
mime_type: str,
tiff_support: bool, # noqa: FBT001
expected: list | None,
) -> None:
"""
Files the scan doesn't support return None, so stored barcodes are
kept instead of being replaced with an empty list
"""
settings.CONSUMER_BARCODE_TIFF_SUPPORT = tiff_support
values = read_barcode_values(
samples_dir / filename,
mime_type,
BarcodeConfig(),
paperless_dirs.scratch_dir,
)
assert values == expected
def test_values_detected(
self,
barcode_reader: Callable[[Path], BarcodePlugin],
barcode_samples_dir: Path,
store_barcodes: None,
) -> None:
reader = barcode_reader(barcode_samples_dir / "barcode-qr-url.pdf")
assert reader.able_to_run
reader.run()
assert reader.metadata.barcodes == SAMPLE_VALUES
def test_values_disabled(
self,
barcode_reader: Callable[[Path], BarcodePlugin],
barcode_samples_dir: Path,
settings: Settings,
) -> None:
settings.CONSUMER_ENABLE_ASN_BARCODE = True
reader = barcode_reader(barcode_samples_dir / "barcode-qr-url.pdf")
reader.run()
assert reader.metadata.barcodes is None
def test_consume_and_reprocess(
self,
consume_sample: Callable[..., Document],
admin_client: APIClient,
store_barcodes: None,
) -> None:
"""
GIVEN:
- PDF with a QR code on each of its two pages
WHEN:
- File is consumed, the values are lost, and the document is reprocessed
THEN:
- The barcodes are stored, shown in the API and searchable
- Reprocessing reads them again
"""
document = consume_sample("barcode-qr-url.pdf")
assert _stored(document) == SAMPLE_VALUES
response = admin_client.get(f"/api/documents/{document.pk}/metadata/")
assert response.status_code == status.HTTP_200_OK
assert response.data["barcodes"] == SAMPLE_VALUES
response = admin_client.get(f"/api/documents/{document.pk}/")
assert "barcodes" not in response.data
response = admin_client.get("/api/documents/?query=barcodes:invoice")
assert [x["id"] for x in response.data["results"]] == [document.pk]
response = admin_client.get("/api/documents/?query=invoice")
assert response.data["results"] == []
document.barcodes.all().delete()
modified = Document.objects.get(pk=document.pk).modified
tasks.update_document_content_maybe_archive_file(document.pk)
assert _stored(document) == SAMPLE_VALUES
assert Document.objects.get(pk=document.pk).modified > modified
@pytest.mark.parametrize(
"read_values",
[
pytest.param({"side_effect": RuntimeError("broken")}, id="scan-fails"),
pytest.param({"return_value": None}, id="not-scannable"),
],
)
def test_reprocess_keeps_values(
self,
consume_sample: Callable[..., Document],
mocker: MockerFixture,
store_barcodes: None,
read_values: dict,
) -> None:
"""
GIVEN:
- A document with stored barcodes
WHEN:
- It is reprocessed, but the barcodes can't be read
THEN:
- The stored barcodes are kept
"""
document = consume_sample("barcode-qr-url.pdf")
mocker.patch("documents.tasks.read_barcode_values", **read_values)
tasks.update_document_content_maybe_archive_file(document.pk)
assert _stored(document) == SAMPLE_VALUES
def test_reprocess_tiff_support_off_keeps_values(
self,
consume_sample: Callable[..., Document],
settings: Settings,
store_barcodes: None,
) -> None:
"""
GIVEN:
- A TIFF document with barcodes stored while TIFF support was on
WHEN:
- TIFF support is turned off and the document is reprocessed
THEN:
- The stored barcodes are kept
"""
settings.CONSUMER_BARCODE_TIFF_SUPPORT = True
document = consume_sample("patch-code-t-middle.tiff")
stored = _stored(document)
assert stored
settings.CONSUMER_BARCODE_TIFF_SUPPORT = False
tasks.update_document_content_maybe_archive_file(document.pk)
assert _stored(document) == stored
def test_reprocess_values_disabled(
self,
consume_sample: Callable[..., Document],
settings: Settings,
store_barcodes: None,
) -> None:
document = consume_sample("barcode-qr-url.pdf")
settings.CONSUMER_STORE_BARCODE_VALUES = False
assert tasks._read_barcodes_for_reprocess(document) is None
def test_consume_version_stores_own_values(
self,
consume_sample: Callable[..., Document],
admin_client: APIClient,
store_barcodes: None,
) -> None:
"""
GIVEN:
- A document with stored barcodes
WHEN:
- A new version with a different barcode is consumed, like after
rotating or removing pages
THEN:
- The version keeps its own barcodes, the original ones are kept
- The metadata and the search use those of the newest version
"""
root = consume_sample("barcode-qr-url.pdf")
version = consume_sample("barcode-128-custom.pdf", root_document_id=root.pk)
assert version.root_document == root
assert _stored(version) == [
{"page": 1, "value": "CUSTOM BARCODE", "format": "Code128"},
]
assert root.barcodes.count() == 2
assert [x.value for x in root.get_effective_barcodes()] == ["CUSTOM BARCODE"]
response = admin_client.get(f"/api/documents/{root.pk}/metadata/")
assert [x["value"] for x in response.data["barcodes"]] == ["CUSTOM BARCODE"]
response = admin_client.get('/api/documents/?query=barcodes:"custom barcode"')
assert [x["id"] for x in response.data["results"]] == [root.pk]
response = admin_client.get("/api/documents/?query=barcodes:invoice")
assert response.data["results"] == []
def test_consume_version_scan_fails(
self,
consume_sample: Callable[..., Document],
mocker: MockerFixture,
store_barcodes: None,
) -> None:
"""
A failed scan of a new version is logged and doesn't stop consumption
"""
root = consume_sample("barcode-qr-url.pdf")
mocker.patch(
"documents.consumer.read_barcode_values",
side_effect=RuntimeError("broken"),
)
version = consume_sample("barcode-128-custom.pdf", root_document_id=root.pk)
assert version.root_document == root
assert not version.barcodes.exists()