From 28b70e288ac2cc7fc21164819a933d2ff67b31db Mon Sep 17 00:00:00 2001 From: Florian Huber Date: Thu, 2 Nov 2023 15:31:34 +0000 Subject: [PATCH] Model changes and Barcode consumer logic --- src/documents/barcodes.py | 39 ++++++++++++++++++---- src/documents/consumer.py | 10 +++--- src/documents/models.py | 69 +++++++++++++++++++++++++++++++++------ 3 files changed, 98 insertions(+), 20 deletions(-) diff --git a/src/documents/barcodes.py b/src/documents/barcodes.py index cac02e3e9..5058a2740 100644 --- a/src/documents/barcodes.py +++ b/src/documents/barcodes.py @@ -17,9 +17,19 @@ from documents.converters import convert_from_tiff_to_pdf from documents.data_models import DocumentSource from documents.utils import copy_basic_file_stats from documents.utils import copy_file_with_basic_stats +from documents.models import ASNPrefix +from documents.models import ASN logger = logging.getLogger("paperless.barcodes") +ASNPrefixes = list(ASNPrefix.objects.all()) + +@receiver(post_save, sender=ASNPrefix) +@receiver(post_delete, sender=ASNPrefix) +def update_asn_prefix_list(sender, instance, **kwargs): + global ASNPrefixes + ASNPrefixes = list(ASNPrefix.objects.all()) + @dataclass(frozen=True) class Barcode: @@ -44,7 +54,10 @@ class Barcode: Returns True if the barcode value matches the configured ASN prefix, False otherwise """ - return self.value.startswith(settings.CONSUMER_ASN_BARCODE_PREFIX) + for ASNPrefix in ASNPrefixes: + if self.value.startswith(ASNPrefix.prefix): + return True + return False class BarcodeReader: @@ -78,7 +91,7 @@ class BarcodeReader: return self.mime in self.SUPPORTED_FILE_MIMES @property - def asn(self) -> Optional[int]: + def asn(self) -> Optional[ASN]: """ Search the parsed barcodes for any ASNs. The first barcode that starts with CONSUMER_ASN_BARCODE_PREFIX @@ -99,18 +112,32 @@ class BarcodeReader: if asn_text: logger.debug(f"Found ASN Barcode: {asn_text}") # remove the prefix and remove whitespace - asn_text = asn_text[len(settings.CONSUMER_ASN_BARCODE_PREFIX) :].strip() + + + asn_prefix = None + asn_prefix_len = 0 + for prefix in ASNPrefixes: + if asn_text.startswith(prefix.prefix) and len(prefix.prefix) > asn_prefix_len: + asn_prefix = prefix + asn_prefix_len = len(prefix.prefix) + + if asn_prefix == None: + logger.warning("Failed to parse ASN Prefix") + return None + + asn_value = asn_text[asn_prefix_len :].strip() # remove non-numeric parts of the remaining string - asn_text = re.sub("[^0-9]", "", asn_text) + asn_value = re.sub("[^0-9]", "", asn_value) # now, try parsing the ASN number try: - asn = int(asn_text) + asn_number = int(asn_value) except ValueError as e: logger.warning(f"Failed to parse ASN number because: {e}") + return None - return asn + return ASN(prefix=asn_prefix,number=asn_number) @staticmethod def read_barcodes_zxing(image: Image) -> list[str]: diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 1c37ce1a3..0df4c431b 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -34,6 +34,7 @@ from documents.models import DocumentType from documents.models import FileInfo from documents.models import StoragePath from documents.models import Tag +from documents.models import ASN from documents.parsers import DocumentParser from documents.parsers import ParseError from documents.parsers import get_parser_class_for_mime_type @@ -174,8 +175,8 @@ class Consumer(LoggingMixin): # Validate the range is above zero and less than uint32_t max # otherwise, Whoosh can't handle it in the index if ( - self.override_asn < Document.ARCHIVE_SERIAL_NUMBER_MIN - or self.override_asn > Document.ARCHIVE_SERIAL_NUMBER_MAX + self.override_asn.number < Document.ARCHIVE_SERIAL_NUMBER_MIN + or self.override_asn.number > Document.ARCHIVE_SERIAL_NUMBER_MAX ): self._fail( ConsumerStatusShortMessage.ASN_RANGE, @@ -184,10 +185,11 @@ class Consumer(LoggingMixin): f"[{Document.ARCHIVE_SERIAL_NUMBER_MIN:,}, " f"{Document.ARCHIVE_SERIAL_NUMBER_MAX:,}]", ) - if Document.objects.filter(archive_serial_number=self.override_asn).exists(): + + if ASN.objects.filter(prefix=self.override_asn.prefix, number=self.override_asn.number).exists(): self._fail( ConsumerStatusShortMessage.ASN_ALREADY_EXISTS, - f"Not consuming {self.filename}: Given ASN already exists!", + f"Not consuming {self.filename}: Given ASN already exists for the Prefix {self.override_asn.prefix.prefix}!", ) def run_pre_consume_script(self): diff --git a/src/documents/models.py b/src/documents/models.py index b0d347f20..7078a46b6 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -15,6 +15,7 @@ from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.core.validators import MaxValueValidator from django.core.validators import MinValueValidator +from django.core.validators import MinLengthValidator from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -129,6 +130,58 @@ class StoragePath(MatchingModel): verbose_name = _("storage path") verbose_name_plural = _("storage paths") +class ASNPrefix(models.Model): + prefix = models.CharField(_("ASN-Prefix"), max_length=16, blank=False, primary_key=True, validators=[MinLengthValidator(2)]) + description = models.CharField(_("Description", max_length=512, blank=True)) + + def __str__(self): + return f"{self.prefix.prefix}" + +class ASN(models.Model): + prefix = models.ForeignKey( + ASNPrefix, + blank = False, + on_delete = models.CASCADE # TODO: Not shure about that one + ) + + ARCHIVE_SERIAL_NUMBER_MIN: Final[int] = 0 + ARCHIVE_SERIAL_NUMBER_MAX: Final[int] = 0xFF_FF_FF_FF + + number = models.PositiveIntegerField( + _("archive serial number"), + blank=True, + null=True, + unique=False, + db_index=True, + validators=[ + MaxValueValidator(ARCHIVE_SERIAL_NUMBER_MAX), + MinValueValidator(ARCHIVE_SERIAL_NUMBER_MIN), + ], + help_text=_( + "The position of this document in your physical document archive.", + ), + ) + + class Meta: + unique_together = ('prefix', 'number') + + def __str__(self): + return f"{self.prefix.prefix}-{self.number:06d}" + + @staticmethod + def get_max_number_for_prefix(prefix): + try: + max_number = ASN.objects.filter(prefix=prefix).aggregate(models.Max('number'))['number__max'] + return max_number if max_number is not None else 0 + except ASN.DoesNotExist: + return 0 + + @staticmethod + def get_next_ASN_for_prefix(prefix): + return ASN(prefix, ASN.get_max_number_for_prefix(prefix)+1) + + + class Document(ModelWithOwner): STORAGE_TYPE_UNENCRYPTED = "unencrypted" @@ -256,19 +309,14 @@ class Document(ModelWithOwner): help_text=_("The original name of the file when it was uploaded"), ) - ARCHIVE_SERIAL_NUMBER_MIN: Final[int] = 0 - ARCHIVE_SERIAL_NUMBER_MAX: Final[int] = 0xFF_FF_FF_FF - archive_serial_number = models.PositiveIntegerField( - _("archive serial number"), + + archive_serial_number = models.OneToOneField( + to=ASN, + on_delete=models.CASCADE, + verbose_name=_("archive serial number"), blank=True, null=True, - unique=True, - db_index=True, - validators=[ - MaxValueValidator(ARCHIVE_SERIAL_NUMBER_MAX), - MinValueValidator(ARCHIVE_SERIAL_NUMBER_MIN), - ], help_text=_( "The position of this document in your physical document archive.", ), @@ -574,6 +622,7 @@ class UiSettings(models.Model): return self.user.username + class PaperlessTask(models.Model): ALL_STATES = sorted(states.ALL_STATES) TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))