Compare commits

...
Author SHA1 Message Date
stumpylog 507889f06a Updates to ruff 0.16 and ignores, autofixes or handles all the new rules 2026-07-24 11:02:11 -07:00
stumpylog 4971be9b37 Updates the script in docker too 2026-07-24 10:41:18 -07:00
stumpylogandClaude Sonnet 4.6 219cda569c ruff: enable S324 (hashlib insecure hash functions)
Adds usedforsecurity=False to all hashlib.md5() calls, documenting
that these are used for file checksum comparison, not security.
The production call in _path_matches_checksum will be replaced with
compute_checksum() (SHA-256) in a separate branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:41:17 -07:00
stumpylogandClaude Sonnet 4.6 de227d0f71 ruff: enable PERF (perflint)
Fixes 9 violations — loop-based append replaced with comprehensions
or extend throughout production and test code:
- PERF401: list comprehensions / extend for transformed lists
- PERF402: list() around a generator for copied lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:40:35 -07:00
stumpylog 5cdc82ae35 ruff: enable DTZ (flake8-datetimez)
Fixes 44 violations — naive datetime usage replaced with tz-aware
equivalents throughout production and test code:
- datetime.now() → timezone.now() (Django) or datetime.now(tz=UTC)
- datetime.fromtimestamp() → datetime.fromtimestamp(ts, tz=UTC)
- datetime.date.today() → timezone.now().date()
- datetime.datetime(...) constructors → tzinfo=UTC in tests
- UP017 auto-converted datetime.timezone.utc → datetime.UTC (py3.11+)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

# Conflicts:
#	src/documents/views.py
#	src/paperless_mail/mail.py
2026-07-24 10:39:44 -07:00
stumpylogandClaude Sonnet 4.6 976eb3beaa ruff: enable B (flake8-bugbear)
Fixes 71 violations across production and test code:
- B904 (~50): raise-from in except blocks; from None at API/view
  boundaries, from exc where the cause is the direct origin
- B017 (9): pytest.raises(Exception) → specific type or match= arg
- B007 (5): unused loop vars renamed to _
- B027 (1): missing @abstractmethod on DateParserPluginBase.__exit__
- B028 (3): warnings.warn without stacklevel=2 in test utils
- B011 (1): assert False → raise AssertionError()
- B905 (3): zip() without strict=False
- B009 (3): getattr with constant string (auto-fixed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:37:52 -07:00
stumpylogandClaude Sonnet 4.6 6f1673e0ae ruff: enable G (logging format), ignore G004 (f-strings)
Replaces the single G201 selector with the full G group.
Fixes 2x G003 (string concat in log calls) and 2x G202 (redundant
exc_info on logger.exception). G004 (f-strings in logging) is ignored
as f-string style is accepted throughout this codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:36:59 -07:00
51 changed files with 298 additions and 233 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ repos:
- 'prettier-plugin-organize-imports@4.3.0' - 'prettier-plugin-organize-imports@4.3.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20 rev: v0.16.0
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
+1 -1
View File
@@ -61,7 +61,7 @@ def replace_with_symlinks(
total_duplicates = 0 total_duplicates = 0
space_saved = 0 space_saved = 0
for file_hash, file_list in duplicate_groups.items(): for file_list in duplicate_groups.values():
# Keep the first file as the original, replace others with symlinks # Keep the first file as the original, replace others with symlinks
original_file = file_list[0] original_file = file_list[0]
duplicates = file_list[1:] duplicates = file_list[1:]
+11 -2
View File
@@ -105,7 +105,7 @@ docs = [
] ]
lint = [ lint = [
"prek~=0.3.10", "prek~=0.3.10",
"ruff~=0.15.20", "ruff~=0.16.0",
] ]
testing = [ testing = [
"daphne", "daphne",
@@ -184,12 +184,16 @@ line-ending = "lf"
[tool.ruff.lint] [tool.ruff.lint]
# https://docs.astral.sh/ruff/rules/ # https://docs.astral.sh/ruff/rules/
extend-select = [ extend-select = [
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com "COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DTZ", # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz
"PERF", # https://docs.astral.sh/ruff/rules/#perflint-perf
"S324", # https://docs.astral.sh/ruff/rules/hashlib-insecure-hash-functions/
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj "DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe "EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt "FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly "FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g "G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"I", # https://docs.astral.sh/ruff/rules/#isort-i "I", # https://docs.astral.sh/ruff/rules/#isort-i
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn "ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp "INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
@@ -209,10 +213,15 @@ extend-select = [
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
] ]
ignore = [ ignore = [
"BLE001", # blind except: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"DJ001", "DJ001",
"G004", # f-strings in logging: accepted style in this codebase
"PLC0415", "PLC0415",
"RUF012", "RUF012",
"SIM105", "SIM105",
"TRY002", # create-your-own-exception: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"TRY201", # verbose-raise: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"TRY401", # redundant exception in logging.exception: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
] ]
# Migrations # Migrations
per-file-ignores."*/migrations/*.py" = [ per-file-ignores."*/migrations/*.py" = [
+4 -3
View File
@@ -839,8 +839,9 @@ class ConsumerPlugin(
self.log.debug(f"Creation date from parse_date: {create_date}") self.log.debug(f"Creation date from parse_date: {create_date}")
else: else:
stats = Path(self.input_doc.original_file).stat() stats = Path(self.input_doc.original_file).stat()
create_date = timezone.make_aware( create_date = datetime.datetime.fromtimestamp(
datetime.datetime.fromtimestamp(stats.st_mtime), stats.st_mtime,
tz=datetime.UTC,
) )
self.log.debug(f"Creation date from st_mtime: {create_date}") self.log.debug(f"Creation date from st_mtime: {create_date}")
@@ -954,7 +955,7 @@ class ConsumerPlugin(
try: try:
copy_basic_file_stats(source, target) copy_basic_file_stats(source, target)
except Exception: # pragma: no cover except Exception: # pragma: no cover
pass self.log.debug("Unable to copy file stats from %s to %s", source, target)
class ConsumerPreflightPlugin( class ConsumerPreflightPlugin(
+4 -4
View File
@@ -1,4 +1,3 @@
import datetime as dt
import logging import logging
import os import os
import shutil import shutil
@@ -6,6 +5,7 @@ from pathlib import Path
from typing import Final from typing import Final
from django.conf import settings from django.conf import settings
from django.utils import timezone
from pikepdf import Pdf from pikepdf import Pdf
from documents.consumer import ConsumerError from documents.consumer import ConsumerError
@@ -78,7 +78,7 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
stats = staging.stat() stats = staging.stat()
# if the file is older than the timeout, we don't consider # if the file is older than the timeout, we don't consider
# it valid # it valid
if (dt.datetime.now().timestamp() - stats.st_mtime) > TIMEOUT_SECONDS: if (timezone.now().timestamp() - stats.st_mtime) > TIMEOUT_SECONDS:
logger.warning("Outdated double sided staging file exists, deleting it") logger.warning("Outdated double sided staging file exists, deleting it")
staging.unlink() staging.unlink()
else: else:
@@ -99,7 +99,7 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
"two uploaded files don't belong to the same double-" "two uploaded files don't belong to the same double-"
"sided scan. Please retry, starting with the odd " "sided scan. Please retry, starting with the odd "
"numbered pages again.", "numbered pages again.",
) ) from None
# Merged file has the same path, but without the # Merged file has the same path, but without the
# double-sided subdir. Therefore, it is also in the # double-sided subdir. Therefore, it is also in the
# consumption dir and will be picked up for processing # consumption dir and will be picked up for processing
@@ -134,7 +134,7 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
shutil.move(pdf_file, staging) shutil.move(pdf_file, staging)
# update access to modification time so we know if the file # update access to modification time so we know if the file
# is outdated when another file gets uploaded # is outdated when another file gets uploaded
timestamp = dt.datetime.now().timestamp() timestamp = timezone.now().timestamp()
os.utime(staging, (timestamp, timestamp)) os.utime(staging, (timestamp, timestamp))
logger.info( logger.info(
"Got scan with odd numbered pages of double-sided scan, moved it to %s", "Got scan with odd numbered pages of double-sided scan, moved it to %s",
+5 -5
View File
@@ -357,7 +357,7 @@ def handle_validation_prefix(func: Callable):
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except serializers.ValidationError as e: except serializers.ValidationError as e:
raise serializers.ValidationError({validation_prefix: e.detail}) raise serializers.ValidationError({validation_prefix: e.detail}) from e
# Update the signature to include the validation_prefix argument # Update the signature to include the validation_prefix argument
old_sig = inspect.signature(func) old_sig = inspect.signature(func)
@@ -468,7 +468,7 @@ class CustomFieldQueryParser:
except json.JSONDecodeError: except json.JSONDecodeError:
raise serializers.ValidationError( raise serializers.ValidationError(
{self._validation_prefix: [_("Value must be valid JSON.")]}, {self._validation_prefix: [_("Value must be valid JSON.")]},
) ) from None
return ( return (
self._parse_expr(expr, validation_prefix=self._validation_prefix), self._parse_expr(expr, validation_prefix=self._validation_prefix),
self._annotations, self._annotations,
@@ -596,7 +596,7 @@ class CustomFieldQueryParser:
except CustomField.DoesNotExist: except CustomField.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(
[_("{name!r} is not a valid custom field.").format(name=id_or_name)], [_("{name!r} is not a valid custom field.").format(name=id_or_name)],
) ) from None
self._custom_fields[custom_field.id] = custom_field self._custom_fields[custom_field.id] = custom_field
self._custom_fields[custom_field.name] = custom_field self._custom_fields[custom_field.name] = custom_field
return custom_field return custom_field
@@ -729,7 +729,7 @@ class CustomFieldQueryParser:
) )
# Check if any of the requested IDs are missing. # Check if any of the requested IDs are missing.
missing_ids = set(value) - set(link.document_id for link in links) missing_ids = set(value) - {link.document_id for link in links}
if missing_ids: if missing_ids:
# The result should be an empty set in this case. # The result should be an empty set in this case.
return Q(id__in=[]) return Q(id__in=[])
@@ -1065,7 +1065,7 @@ class DocumentsOrderingFilter(OrderingFilter):
except CustomField.DoesNotExist: except CustomField.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(
{self.prefix + str(custom_field_id): [_("Custom field not found")]}, {self.prefix + str(custom_field_id): [_("Custom field not found")]},
) ) from None
annotation = None annotation = None
match field.data_type: match field.data_type:
@@ -7,6 +7,7 @@ from itertools import islice
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from typing import Self
from allauth.mfa.models import Authenticator from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount from allauth.socialaccount.models import SocialAccount
@@ -154,7 +155,7 @@ class StreamingManifestWriter:
return return
self._tmp_path.rename(self._path) self._tmp_path.rename(self._path)
def __enter__(self) -> "StreamingManifestWriter": def __enter__(self) -> Self:
self.open() self.open()
return self return self
@@ -480,7 +481,7 @@ class Command(CryptMixin, PaperlessCommand):
} }
# 3. Export files from each document # 3. Export files from each document
for index, document_dict in enumerate( for _, document_dict in enumerate(
self.track( self.track(
document_manifest, document_manifest,
description="Exporting documents...", description="Exporting documents...",
@@ -133,11 +133,14 @@ def _build_suggestion_table(
else: else:
doc_cell = Text(f"{doc} [{doc.pk}]") doc_cell = Text(f"{doc} [{doc.pk}]")
tag_parts: list[str] = [] tag_parts: list[str] = [
for tag in sorted(suggestion.tags_to_add, key=lambda t: t.name): f"[green]+{tag.name}[/green]"
tag_parts.append(f"[green]+{tag.name}[/green]") for tag in sorted(suggestion.tags_to_add, key=lambda t: t.name)
for tag in sorted(suggestion.tags_to_remove, key=lambda t: t.name): ]
tag_parts.append(f"[red]-{tag.name}[/red]") tag_parts.extend(
f"[red]-{tag.name}[/red]"
for tag in sorted(suggestion.tags_to_remove, key=lambda t: t.name)
)
tag_cell = Text.from_markup(", ".join(tag_parts)) if tag_parts else Text("-") tag_cell = Text.from_markup(", ".join(tag_parts)) if tag_parts else Text("-")
table.add_row( table.add_row(
+4 -5
View File
@@ -300,7 +300,7 @@ def consumable_document_matches_workflow(
]: ]:
reason = ( reason = (
f"Document source {document.source.name} not in" f"Document source {document.source.name} not in"
f" {[DocumentSource(int(x)).name for x in trigger.sources]}", f" {[DocumentSource(int(x)).name for x in trigger.sources]}"
) )
trigger_matched = False trigger_matched = False
@@ -310,8 +310,7 @@ def consumable_document_matches_workflow(
and document.mailrule_id != trigger.filter_mailrule.pk and document.mailrule_id != trigger.filter_mailrule.pk
): ):
reason = ( reason = (
f"Document mail rule {document.mailrule_id}" f"Document mail rule {document.mailrule_id} != {trigger.filter_mailrule.pk}"
f" != {trigger.filter_mailrule.pk}",
) )
trigger_matched = False trigger_matched = False
@@ -326,7 +325,7 @@ def consumable_document_matches_workflow(
): ):
reason = ( reason = (
f"Document filename {document.original_file.name} does not match" f"Document filename {document.original_file.name} does not match"
f" {trigger.filter_filename.lower()}", f" {trigger.filter_filename.lower()}"
) )
trigger_matched = False trigger_matched = False
@@ -349,7 +348,7 @@ def consumable_document_matches_workflow(
): ):
reason = ( reason = (
f"Document path {document.original_file}" f"Document path {document.original_file}"
f" does not match {trigger.filter_path}", f" does not match {trigger.filter_path}"
) )
trigger_matched = False trigger_matched = False
+3 -3
View File
@@ -373,7 +373,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
If the queryset already annotated ``effective_content``, that value is used. If the queryset already annotated ``effective_content``, that value is used.
""" """
if hasattr(self, "effective_content"): if hasattr(self, "effective_content"):
return getattr(self, "effective_content") return self.effective_content
if self.root_document_id is not None or self.pk is None: if self.root_document_id is not None or self.pk is None:
return self.content return self.content
@@ -1214,8 +1214,8 @@ class CustomFieldInstance(SoftDeleteModel):
def get_value_field_name(cls, data_type: CustomField.FieldDataType): def get_value_field_name(cls, data_type: CustomField.FieldDataType):
try: try:
return cls.TYPE_TO_DATA_STORE_NAME_MAP[data_type] return cls.TYPE_TO_DATA_STORE_NAME_MAP[data_type]
except KeyError: # pragma: no cover except KeyError as exc: # pragma: no cover
raise NotImplementedError(data_type) raise NotImplementedError(data_type) from exc
@property @property
def value(self): def value(self):
+1 -1
View File
@@ -110,7 +110,7 @@ def run_convert(
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else [] args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
args += [str(input_file), str(output_file)] args += [str(input_file), str(output_file)]
logger.debug("Execute: " + " ".join(args), extra={"group": logging_group}) logger.debug("Execute: %s", " ".join(args), extra={"group": logging_group})
try: try:
run_subprocess(args, environment, logger) run_subprocess(args, environment, logger)
+1 -2
View File
@@ -67,8 +67,7 @@ class DateParserPluginBase(ABC):
Subclasses can override this to release resources. Subclasses can override this to release resources.
""" """
# Default implementation does nothing. return
# Returning None implies exceptions are propagated.
def _parse_string( def _parse_string(
self, self,
+7 -3
View File
@@ -204,12 +204,12 @@ class WriteBatch:
try: try:
self._lock.acquire(timeout=self._lock_timeout) self._lock.acquire(timeout=self._lock_timeout)
break break
except filelock.Timeout: except filelock.Timeout as exc:
if attempt == _LOCK_RETRY_ATTEMPTS - 1: if attempt == _LOCK_RETRY_ATTEMPTS - 1:
raise SearchIndexLockError( raise SearchIndexLockError(
f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} " f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} "
f"attempts (timeout={self._lock_timeout}s each)", f"attempts (timeout={self._lock_timeout}s each)",
) ) from exc
sleep_s = random.uniform( sleep_s = random.uniform(
0, 0,
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)), min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)),
@@ -702,7 +702,11 @@ class TantivyBackend:
result_ids = cast("list[int]", searcher.fast_field_values("id", result_addrs)) result_ids = cast("list[int]", searcher.fast_field_values("id", result_addrs))
addr_by_id: dict[int, tuple[float, tantivy.DocAddress]] = { addr_by_id: dict[int, tuple[float, tantivy.DocAddress]] = {
doc_id: (score, addr) doc_id: (score, addr)
for (score, addr), doc_id in zip(batch_results.hits, result_ids) for (score, addr), doc_id in zip(
batch_results.hits,
result_ids,
strict=False,
)
} }
snippet_generator = None snippet_generator = None
+27 -19
View File
@@ -203,7 +203,7 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
logger.debug(f"Invalid regular expression: {e!s}") logger.debug(f"Invalid regular expression: {e!s}")
raise serializers.ValidationError( raise serializers.ValidationError(
"Invalid regular expression, see log for details.", "Invalid regular expression, see log for details.",
) ) from None
return match return match
@@ -432,7 +432,7 @@ class OwnedObjectSerializer(
return set() return set()
ctype = ContentType.objects.get_for_model(first_obj) ctype = ContentType.objects.get_for_model(first_obj)
object_pks = list(obj.pk for obj in objects) object_pks = [obj.pk for obj in objects]
pk_type = type(first_obj.pk) pk_type = type(first_obj.pk)
def get_pks_for_permission_type(model): def get_pks_for_permission_type(model):
@@ -926,7 +926,9 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
try: try:
value_int = int(data["value"]) value_int = int(data["value"])
except (TypeError, ValueError): except (TypeError, ValueError):
raise serializers.ValidationError("Enter a valid integer.") raise serializers.ValidationError(
"Enter a valid integer.",
) from None
# Keep values within the PostgreSQL integer range # Keep values within the PostgreSQL integer range
MinValueValidator(-2147483648)(value_int) MinValueValidator(-2147483648)(value_int)
MaxValueValidator(2147483647)(value_int) MaxValueValidator(2147483647)(value_int)
@@ -958,7 +960,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
except Exception: except Exception:
raise serializers.ValidationError( raise serializers.ValidationError(
f"Value must be an id of an element in {select_options}", f"Value must be an id of an element in {select_options}",
) ) from None
elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK: elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
if not (isinstance(data["value"], list) or data["value"] is None): if not (isinstance(data["value"], list) or data["value"] is None):
raise serializers.ValidationError( raise serializers.ValidationError(
@@ -1149,7 +1151,7 @@ class DocumentSerializer(
def to_representation(self, instance): def to_representation(self, instance):
doc = super().to_representation(instance) doc = super().to_representation(instance)
if "content" in self.fields and hasattr(instance, "effective_content"): if "content" in self.fields and hasattr(instance, "effective_content"):
doc["content"] = getattr(instance, "effective_content") or "" doc["content"] = instance.effective_content or ""
if self.truncate_content and "content" in self.fields: if self.truncate_content and "content" in self.fields:
doc["content"] = doc.get("content")[0:550] doc["content"] = doc.get("content")[0:550]
return doc return doc
@@ -1512,7 +1514,7 @@ class SavedViewSerializer(OwnedObjectSerializer):
) )
) )
except serializers.ValidationError as exc: except serializers.ValidationError as exc:
raise serializers.ValidationError({field_name: exc.detail}) raise serializers.ValidationError({field_name: exc.detail}) from exc
del normalized_data[field_name] del normalized_data[field_name]
ret = super().to_internal_value(normalized_data) ret = super().to_internal_value(normalized_data)
@@ -1816,7 +1818,7 @@ class BulkEditSerializer(
logger.exception(f"Error validating custom fields: {e}") logger.exception(f"Error validating custom fields: {e}")
raise serializers.ValidationError( raise serializers.ValidationError(
f"{name} must be a list of integers or a dict of id:value pairs, see the log for details", f"{name} must be a list of integers or a dict of id:value pairs, see the log for details",
) ) from None
elif not isinstance(custom_fields, list) or not all( elif not isinstance(custom_fields, list) or not all(
isinstance(i, int) for i in ids isinstance(i, int) for i in ids
): ):
@@ -1884,7 +1886,7 @@ class BulkEditSerializer(
try: try:
Tag.objects.get(id=tag_id) Tag.objects.get(id=tag_id)
except Tag.DoesNotExist: except Tag.DoesNotExist:
raise serializers.ValidationError("Tag does not exist") raise serializers.ValidationError("Tag does not exist") from None
else: else:
raise serializers.ValidationError("tag not specified") raise serializers.ValidationError("tag not specified")
@@ -1897,7 +1899,9 @@ class BulkEditSerializer(
try: try:
DocumentType.objects.get(id=document_type_id) DocumentType.objects.get(id=document_type_id)
except DocumentType.DoesNotExist: except DocumentType.DoesNotExist:
raise serializers.ValidationError("Document type does not exist") raise serializers.ValidationError(
"Document type does not exist",
) from None
else: else:
raise serializers.ValidationError("document_type not specified") raise serializers.ValidationError("document_type not specified")
@@ -1909,7 +1913,9 @@ class BulkEditSerializer(
try: try:
Correspondent.objects.get(id=correspondent_id) Correspondent.objects.get(id=correspondent_id)
except Correspondent.DoesNotExist: except Correspondent.DoesNotExist:
raise serializers.ValidationError("Correspondent does not exist") raise serializers.ValidationError(
"Correspondent does not exist",
) from None
else: else:
raise serializers.ValidationError("correspondent not specified") raise serializers.ValidationError("correspondent not specified")
@@ -1923,7 +1929,7 @@ class BulkEditSerializer(
except StoragePath.DoesNotExist: except StoragePath.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(
"Storage path does not exist", "Storage path does not exist",
) ) from None
else: else:
raise serializers.ValidationError("storage path not specified") raise serializers.ValidationError("storage path not specified")
@@ -1978,7 +1984,7 @@ class BulkEditSerializer(
): ):
raise serializers.ValidationError("invalid rotation degrees") raise serializers.ValidationError("invalid rotation degrees")
except ValueError: except ValueError:
raise serializers.ValidationError("invalid rotation degrees") raise serializers.ValidationError("invalid rotation degrees") from None
def _validate_source_mode(self, parameters) -> None: def _validate_source_mode(self, parameters) -> None:
source_mode = parameters.get( source_mode = parameters.get(
@@ -2008,7 +2014,7 @@ class BulkEditSerializer(
pages.append([int(doc)]) pages.append([int(doc)])
parameters["pages"] = pages parameters["pages"] = pages
except ValueError: except ValueError:
raise serializers.ValidationError("invalid pages specified") raise serializers.ValidationError("invalid pages specified") from None
if "delete_originals" in parameters: if "delete_originals" in parameters:
if not isinstance(parameters["delete_originals"], bool): if not isinstance(parameters["delete_originals"], bool):
@@ -2278,14 +2284,14 @@ class PostDocumentSerializer(serializers.Serializer[dict[str, Any]]):
raise serializers.ValidationError( raise serializers.ValidationError(
_("Custom field id must be an integer: %(id)s") _("Custom field id must be an integer: %(id)s")
% {"id": field_id}, % {"id": field_id},
) ) from None
try: try:
field = CustomField.objects.get(id=field_id_int) field = CustomField.objects.get(id=field_id_int)
except CustomField.DoesNotExist: except CustomField.DoesNotExist:
raise serializers.ValidationError( raise serializers.ValidationError(
_("Custom field with id %(id)s does not exist") _("Custom field with id %(id)s does not exist")
% {"id": field_id_int}, % {"id": field_id_int},
) ) from None
custom_field_serializer.validate( custom_field_serializer.validate(
{ {
"field": field, "field": field,
@@ -2302,7 +2308,7 @@ class PostDocumentSerializer(serializers.Serializer[dict[str, Any]]):
_( _(
"Custom fields must be a list of integers or an object mapping ids to values.", "Custom fields must be a list of integers or an object mapping ids to values.",
), ),
) ) from None
if CustomField.objects.filter(id__in=ids).count() != len(set(ids)): if CustomField.objects.filter(id__in=ids).count() != len(set(ids)):
raise serializers.ValidationError( raise serializers.ValidationError(
_("Some custom fields don't exist or were specified twice."), _("Some custom fields don't exist or were specified twice."),
@@ -2413,7 +2419,9 @@ class EmailSerializer(DocumentListSerializer):
for address in address_list: for address in address_list:
email_validator(address) email_validator(address)
except ValidationError: except ValidationError:
raise serializers.ValidationError(f"Invalid email address: {address}") raise serializers.ValidationError(
f"Invalid email address: {address}",
) from None
return ",".join(address_list) return ",".join(address_list)
@@ -2859,7 +2867,7 @@ class ShareLinkBundleSerializer(OwnedObjectSerializer):
return share_link_bundle return share_link_bundle
def get_document_count(self, obj: ShareLinkBundle) -> int: def get_document_count(self, obj: ShareLinkBundle) -> int:
return getattr(obj, "document_total") or obj.documents.count() return obj.document_total or obj.documents.count()
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin): class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
@@ -3207,7 +3215,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
except (ValueError, KeyError) as e: except (ValueError, KeyError) as e:
raise serializers.ValidationError( raise serializers.ValidationError(
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'}, {"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
) ) from None
if ( if (
"type" in attrs "type" in attrs
+1 -1
View File
@@ -636,7 +636,7 @@ def update_filename_and_move_files(
# so this is not the end of the world. # so this is not the end of the world.
# B: if moving the original file failed, nothing has changed # B: if moving the original file failed, nothing has changed
# anyway. # anyway.
pass logger.debug("Unable to restore previous file locations", exc_info=True)
# restore old values on the instance # restore old values on the instance
instance.filename = old_filename instance.filename = old_filename
@@ -29,9 +29,7 @@ class SimpleCommand(PaperlessCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
items = list(range(5)) items = list(range(5))
results = [] results = [item * 2 for item in self.track(items, description="Processing...")]
for item in self.track(items, description="Processing..."):
results.append(item * 2)
self.stdout.write(f"Results: {results}") self.stdout.write(f"Results: {results}")
@@ -57,13 +55,13 @@ class MultiprocessCommand(PaperlessCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
items = list(range(5)) items = list(range(5))
results = [] results = list(
for result in self.process_parallel( self.process_parallel(
_double_value, _double_value,
items, items,
description="Processing...", description="Processing...",
): ),
results.append(result) )
successes = sum(1 for r in results if r.success) successes = sum(1 for r in results if r.success)
self.stdout.write(f"Successes: {successes}") self.stdout.write(f"Successes: {successes}")
+5 -3
View File
@@ -341,9 +341,11 @@ class TestTranslateQuery:
("tag:foo,type:bar", "tag:foo AND document_type:bar"), ("tag:foo,type:bar", "tag:foo AND document_type:bar"),
( (
"created:[2020 TO 2021],added:[2022 TO 2023]", "created:[2020 TO 2021],added:[2022 TO 2023]",
"created:[2020-01-01T00:00:00Z TO 2022-01-01T00:00:00Z]" (
" AND " "created:[2020-01-01T00:00:00Z TO 2022-01-01T00:00:00Z]"
"added:[2022-01-01T00:00:00Z TO 2024-01-01T00:00:00Z]", " AND "
"added:[2022-01-01T00:00:00Z TO 2024-01-01T00:00:00Z]"
),
), ),
# correspondent is not multi-value: comma stays literal inside the value # correspondent is not multi-value: comma stays literal inside the value
("correspondent:foo,bar", "correspondent:foo,bar"), ("correspondent:foo,bar", "correspondent:foo,bar"),
@@ -6,7 +6,6 @@ import zipfile
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import override_settings from django.test import override_settings
from django.utils import timezone
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -33,21 +32,21 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
filename="docA.pdf", filename="docA.pdf",
mime_type="application/pdf", mime_type="application/pdf",
checksum="B", checksum="B",
created=timezone.make_aware(datetime.datetime(2021, 1, 1)), created=datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC),
) )
self.doc2b = Document.objects.create( self.doc2b = Document.objects.create(
title="document A", title="document A",
filename="docA2.pdf", filename="docA2.pdf",
mime_type="application/pdf", mime_type="application/pdf",
checksum="D", checksum="D",
created=timezone.make_aware(datetime.datetime(2021, 1, 1)), created=datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC),
) )
self.doc3 = Document.objects.create( self.doc3 = Document.objects.create(
title="document B", title="document B",
filename="docB.jpg", filename="docB.jpg",
mime_type="image/jpeg", mime_type="image/jpeg",
checksum="C", checksum="C",
created=timezone.make_aware(datetime.datetime(2020, 3, 21)), created=datetime.datetime(2020, 3, 21, tzinfo=datetime.UTC),
archive_filename="docB.pdf", archive_filename="docB.pdf",
archive_checksum="D", archive_checksum="D",
) )
+2 -2
View File
@@ -957,8 +957,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
for correspondent in response.data[field]: for correspondent in response.data[field]:
self.assertEqual(correspondent["document_count"], 0) self.assertEqual(correspondent["document_count"], 0)
self.assertCountEqual( self.assertCountEqual(
map(lambda c: c["id"], response.data[field]), (c["id"] for c in response.data[field]),
map(lambda c: c["id"], Entity.objects.values("id")), (c["id"] for c in Entity.objects.values("id")),
) )
def test_api_selection_data(self) -> None: def test_api_selection_data(self) -> None:
@@ -1,5 +1,5 @@
import datetime
import json import json
from datetime import date
from unittest import mock from unittest import mock
from unittest.mock import ANY from unittest.mock import ANY
@@ -456,7 +456,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
}, },
) )
date_value = date.today() date_value = datetime.datetime.now(tz=datetime.UTC).date()
resp = self.client.patch( resp = self.client.patch(
f"/api/documents/{doc.id}/", f"/api/documents/{doc.id}/",
@@ -618,7 +618,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
data_type=CustomField.FieldDataType.DATE, data_type=CustomField.FieldDataType.DATE,
) )
date_value = date.today() date_value = datetime.datetime.now(tz=datetime.UTC).date()
resp = self.client.patch( resp = self.client.patch(
f"/api/documents/{doc.id}/", f"/api/documents/{doc.id}/",
+1 -1
View File
@@ -265,7 +265,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
created=date(2023, 1, 1), created=date(2023, 1, 1),
) )
created_datetime = datetime.datetime(2023, 2, 1, 12, 0, 0) created_datetime = datetime.datetime(2023, 2, 1, 12, 0, 0, tzinfo=datetime.UTC)
response = self.client.patch( response = self.client.patch(
f"/api/documents/{doc.pk}/", f"/api/documents/{doc.pk}/",
{"created": created_datetime}, {"created": created_datetime},
+2 -2
View File
@@ -18,8 +18,8 @@ class MockOpenIDProvider:
def get_brands(self): def get_brands(self):
default_servers = [ default_servers = [
dict(id="yahoo", name="Yahoo", openid_url="http://me.yahoo.com"), {"id": "yahoo", "name": "Yahoo", "openid_url": "http://me.yahoo.com"},
dict(id="hyves", name="Hyves", openid_url="http://hyves.nl"), {"id": "hyves", "name": "Hyves", "openid_url": "http://hyves.nl"},
] ]
return default_servers return default_servers
+30 -14
View File
@@ -700,7 +700,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
pk=3, pk=3,
checksum="C", checksum="C",
# specific time zone aware date # specific time zone aware date
added=timezone.make_aware(datetime.datetime(2023, 12, 1)), added=datetime.datetime(2023, 12, 1, tzinfo=datetime.UTC),
) )
# refresh doc instance to ensure we operate on date objects that Django uses # refresh doc instance to ensure we operate on date objects that Django uses
# Django converts dates to UTC # Django converts dates to UTC
@@ -1022,25 +1022,25 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
title="invoice", title="invoice",
content="the thing i bought at a shop and paid with bank account", content="the thing i bought at a shop and paid with bank account",
created=datetime.date(2018, 1, 1), created=datetime.date(2018, 1, 1),
added=timezone.make_aware(datetime.datetime(2018, 1, 1)), added=datetime.datetime(2018, 1, 1, tzinfo=datetime.UTC),
) )
d2 = DocumentFactory( d2 = DocumentFactory(
title="bank statement 1", title="bank statement 1",
content="things i paid for in august", content="things i paid for in august",
created=datetime.date(2019, 3, 4), created=datetime.date(2019, 3, 4),
added=timezone.make_aware(datetime.datetime(2019, 3, 4)), added=datetime.datetime(2019, 3, 4, tzinfo=datetime.UTC),
) )
d3 = DocumentFactory( d3 = DocumentFactory(
title="bank statement 3", title="bank statement 3",
content="things i paid for in september", content="things i paid for in september",
created=datetime.date(2020, 7, 9), created=datetime.date(2020, 7, 9),
added=timezone.make_aware(datetime.datetime(2020, 7, 9)), added=datetime.datetime(2020, 7, 9, tzinfo=datetime.UTC),
) )
d4 = DocumentFactory( d4 = DocumentFactory(
title="Quarterly Report", title="Quarterly Report",
content="quarterly revenue profit margin earnings growth", content="quarterly revenue profit margin earnings growth",
created=datetime.date(2021, 11, 30), created=datetime.date(2021, 11, 30),
added=timezone.make_aware(datetime.datetime(2021, 11, 30)), added=datetime.datetime(2021, 11, 30, tzinfo=datetime.UTC),
) )
backend = get_backend() backend = get_backend()
backend.add_or_update(d1) backend.add_or_update(d1)
@@ -1159,7 +1159,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
d4.tags.add(t2) d4.tags.add(t2)
d5 = Document.objects.create( d5 = Document.objects.create(
checksum="5", checksum="5",
added=timezone.make_aware(datetime.datetime(2020, 7, 13)), added=datetime.datetime(2020, 7, 13, tzinfo=datetime.UTC),
content="test", content="test",
original_filename="doc5.pdf", original_filename="doc5.pdf",
) )
@@ -1269,14 +1269,18 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
d4.id, d4.id,
search_query( search_query(
"&created__date__lt=" "&created__date__lt="
+ datetime.datetime(2020, 9, 2).strftime("%Y-%m-%d"), + datetime.datetime(2020, 9, 2, tzinfo=datetime.UTC).strftime(
"%Y-%m-%d",
),
), ),
) )
self.assertNotIn( self.assertNotIn(
d4.id, d4.id,
search_query( search_query(
"&created__date__gt=" "&created__date__gt="
+ datetime.datetime(2020, 9, 2).strftime("%Y-%m-%d"), + datetime.datetime(2020, 9, 2, tzinfo=datetime.UTC).strftime(
"%Y-%m-%d",
),
), ),
) )
@@ -1284,14 +1288,18 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
d4.id, d4.id,
search_query( search_query(
"&created__date__lt=" "&created__date__lt="
+ datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d"), + datetime.datetime(2020, 1, 2, tzinfo=datetime.UTC).strftime(
"%Y-%m-%d",
),
), ),
) )
self.assertIn( self.assertIn(
d4.id, d4.id,
search_query( search_query(
"&created__date__gt=" "&created__date__gt="
+ datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d"), + datetime.datetime(2020, 1, 2, tzinfo=datetime.UTC).strftime(
"%Y-%m-%d",
),
), ),
) )
@@ -1299,14 +1307,18 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
d5.id, d5.id,
search_query( search_query(
"&added__date__lt=" "&added__date__lt="
+ datetime.datetime(2020, 9, 2).strftime("%Y-%m-%d"), + datetime.datetime(2020, 9, 2, tzinfo=datetime.UTC).strftime(
"%Y-%m-%d",
),
), ),
) )
self.assertNotIn( self.assertNotIn(
d5.id, d5.id,
search_query( search_query(
"&added__date__gt=" "&added__date__gt="
+ datetime.datetime(2020, 9, 2).strftime("%Y-%m-%d"), + datetime.datetime(2020, 9, 2, tzinfo=datetime.UTC).strftime(
"%Y-%m-%d",
),
), ),
) )
@@ -1314,7 +1326,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
d5.id, d5.id,
search_query( search_query(
"&added__date__lt=" "&added__date__lt="
+ datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d"), + datetime.datetime(2020, 1, 2, tzinfo=datetime.UTC).strftime(
"%Y-%m-%d",
),
), ),
) )
@@ -1322,7 +1336,9 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
d5.id, d5.id,
search_query( search_query(
"&added__date__gt=" "&added__date__gt="
+ datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d"), + datetime.datetime(2020, 1, 2, tzinfo=datetime.UTC).strftime(
"%Y-%m-%d",
),
), ),
) )
+4 -3
View File
@@ -777,7 +777,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
sig.set.return_value.apply_async.side_effect = Exception("boom") sig.set.return_value.apply_async.side_effect = Exception("boom")
mock_consume_file.return_value = sig mock_consume_file.return_value = sig
with self.assertRaises(Exception): with self.assertRaisesRegex(Exception, "boom"):
bulk_edit.merge(doc_ids, delete_originals=True) bulk_edit.merge(doc_ids, delete_originals=True)
self.doc1.refresh_from_db() self.doc1.refresh_from_db()
@@ -1060,6 +1060,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
for call, expected_id in zip( for call, expected_id in zip(
mock_consume_delay.call_args_list, mock_consume_delay.call_args_list,
doc_ids, doc_ids,
strict=False,
): ):
task_kwargs = call.kwargs["kwargs"] task_kwargs = call.kwargs["kwargs"]
self.assertEqual(task_kwargs["input_doc"].root_document_id, expected_id) self.assertEqual(task_kwargs["input_doc"].root_document_id, expected_id)
@@ -1318,7 +1319,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
sig.apply_async.side_effect = Exception("boom") sig.apply_async.side_effect = Exception("boom")
mock_chord.return_value = sig mock_chord.return_value = sig
with self.assertRaises(Exception): with self.assertRaisesRegex(Exception, "boom"):
bulk_edit.edit_pdf(doc_ids, operations, delete_original=True) bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
self.doc2.refresh_from_db() self.doc2.refresh_from_db()
@@ -1430,7 +1431,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
{"page": 9999}, # invalid page, forces error during PDF load {"page": 9999}, # invalid page, forces error during PDF load
] ]
with self.assertLogs("paperless.bulk_edit", level="ERROR"): with self.assertLogs("paperless.bulk_edit", level="ERROR"):
with self.assertRaises(Exception): with self.assertRaises(ValueError):
bulk_edit.edit_pdf(doc_ids, operations) bulk_edit.edit_pdf(doc_ids, operations)
mock_group.assert_not_called() mock_group.assert_not_called()
mock_consume_file.assert_not_called() mock_consume_file.assert_not_called()
+2 -2
View File
@@ -782,8 +782,8 @@ class TestClassifier(DirectoriesMixin, TestCase):
load_classifier(raise_exception=True) load_classifier(raise_exception=True)
Path(settings.MODEL_FILE).touch() Path(settings.MODEL_FILE).touch()
mock_load.side_effect = Exception() mock_load.side_effect = RuntimeError()
with self.assertRaises(Exception): with self.assertRaises(RuntimeError):
load_classifier(raise_exception=True) load_classifier(raise_exception=True)
+4 -4
View File
@@ -59,7 +59,7 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
def create_staging_file(self, src="double-sided-odd.pdf", datetime=None) -> None: def create_staging_file(self, src="double-sided-odd.pdf", datetime=None) -> None:
shutil.copy(self.SAMPLE_DIR / src, self.staging_file) shutil.copy(self.SAMPLE_DIR / src, self.staging_file)
if datetime is None: if datetime is None:
datetime = dt.datetime.now() datetime = dt.datetime.now(tz=dt.UTC)
os.utime(str(self.staging_file), (datetime.timestamp(),) * 2) os.utime(str(self.staging_file), (datetime.timestamp(),) * 2)
def test_odd_numbered_moved_to_staging(self) -> None: def test_odd_numbered_moved_to_staging(self) -> None:
@@ -79,8 +79,8 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertIsFile(self.staging_file) self.assertIsFile(self.staging_file)
self.assertAlmostEqual( self.assertAlmostEqual(
dt.datetime.fromtimestamp(self.staging_file.stat().st_mtime), dt.datetime.fromtimestamp(self.staging_file.stat().st_mtime, tz=dt.UTC),
dt.datetime.now(), dt.datetime.now(tz=dt.UTC),
delta=dt.timedelta(seconds=5), delta=dt.timedelta(seconds=5),
) )
self.assertIn("Received odd numbered pages", msg["reason"]) self.assertIn("Received odd numbered pages", msg["reason"])
@@ -124,7 +124,7 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
""" """
self.create_staging_file( self.create_staging_file(
datetime=dt.datetime.now() datetime=dt.datetime.now(tz=dt.UTC)
- dt.timedelta(minutes=TIMEOUT_MINUTES, seconds=1), - dt.timedelta(minutes=TIMEOUT_MINUTES, seconds=1),
) )
msg = self.consume_file("double-sided-odd.pdf") msg = self.consume_file("double-sided-odd.pdf")
+20 -14
View File
@@ -12,7 +12,6 @@ from django.contrib.auth.models import User
from django.db import DatabaseError from django.db import DatabaseError
from django.test import TestCase from django.test import TestCase
from django.test import override_settings from django.test import override_settings
from django.utils import timezone
from documents.file_handling import create_source_path_directory from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories from documents.file_handling import delete_empty_directories
@@ -452,7 +451,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
FILENAME_FORMAT="{created_year}-{created_month}-{created_day}", FILENAME_FORMAT="{created_year}-{created_month}-{created_day}",
) )
def test_created_year_month_day(self) -> None: def test_created_year_month_day(self) -> None:
d1 = timezone.make_aware(datetime.datetime(2020, 3, 6, 1, 1, 1)) d1 = datetime.datetime(2020, 3, 6, 1, 1, 1, tzinfo=datetime.UTC)
doc1 = Document.objects.create( doc1 = Document.objects.create(
title="doc1", title="doc1",
mime_type="application/pdf", mime_type="application/pdf",
@@ -469,7 +468,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
FILENAME_FORMAT="{added_year}-{added_month}-{added_day}", FILENAME_FORMAT="{added_year}-{added_month}-{added_day}",
) )
def test_added_year_month_day(self) -> None: def test_added_year_month_day(self) -> None:
d1 = timezone.make_aware(datetime.datetime(1232, 1, 9, 1, 1, 1)) d1 = datetime.datetime(1232, 1, 9, 1, 1, 1, tzinfo=datetime.UTC)
doc1 = Document.objects.create( doc1 = Document.objects.create(
title="doc1", title="doc1",
mime_type="application/pdf", mime_type="application/pdf",
@@ -482,7 +481,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertEqual(generate_filename(doc1), expected_filename) self.assertEqual(generate_filename(doc1), expected_filename)
doc1.added = timezone.make_aware(datetime.datetime(2020, 11, 16, 1, 1, 1)) doc1.added = datetime.datetime(2020, 11, 16, 1, 1, 1, tzinfo=datetime.UTC)
self.assertEqual(generate_filename(doc1), Path("2020-11-16.pdf")) self.assertEqual(generate_filename(doc1), Path("2020-11-16.pdf"))
@@ -1266,7 +1265,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
def test_short_names_added(self) -> None: def test_short_names_added(self) -> None:
doc = Document.objects.create( doc = Document.objects.create(
title="The Title", title="The Title",
added=timezone.make_aware(datetime.datetime(1984, 8, 21, 7, 36, 51, 153)), added=datetime.datetime(1984, 8, 21, 7, 36, 51, 153, tzinfo=datetime.UTC),
mime_type="application/pdf", mime_type="application/pdf",
pk=2, pk=2,
checksum="2", checksum="2",
@@ -1505,7 +1504,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create( doc_a = Document.objects.create(
title="Does Matter", title="Does Matter",
created=datetime.date(2020, 6, 25), created=datetime.date(2020, 6, 25),
added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)), added=datetime.datetime(2024, 10, 1, 7, 36, 51, 153, tzinfo=datetime.UTC),
mime_type="application/pdf", mime_type="application/pdf",
pk=2, pk=2,
checksum="2", checksum="2",
@@ -1577,7 +1576,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc = Document.objects.create( doc = Document.objects.create(
title="scan_017562", title="scan_017562",
created=datetime.date(2025, 7, 2), created=datetime.date(2025, 7, 2),
added=timezone.make_aware(datetime.datetime(2026, 3, 3, 11, 53, 16)), added=datetime.datetime(2026, 3, 3, 11, 53, 16, tzinfo=datetime.UTC),
mime_type="application/pdf", mime_type="application/pdf",
checksum="test-checksum", checksum="test-checksum",
storage_path=sp, storage_path=sp,
@@ -1606,7 +1605,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create( doc_a = Document.objects.create(
title="Does Matter", title="Does Matter",
created=datetime.date(2020, 6, 25), created=datetime.date(2020, 6, 25),
added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)), added=datetime.datetime(2024, 10, 1, 7, 36, 51, 153, tzinfo=datetime.UTC),
mime_type="application/pdf", mime_type="application/pdf",
pk=2, pk=2,
checksum="2", checksum="2",
@@ -1641,7 +1640,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create( doc_a = Document.objects.create(
title="Does Matter", title="Does Matter",
created=datetime.date(2020, 6, 25), created=datetime.date(2020, 6, 25),
added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)), added=datetime.datetime(2024, 10, 1, 7, 36, 51, 153, tzinfo=datetime.UTC),
mime_type="application/pdf", mime_type="application/pdf",
pk=2, pk=2,
checksum="2", checksum="2",
@@ -1673,7 +1672,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create( doc_a = Document.objects.create(
title="Some Title", title="Some Title",
created=datetime.date(2020, 6, 25), created=datetime.date(2020, 6, 25),
added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)), added=datetime.datetime(2024, 10, 1, 7, 36, 51, 153, tzinfo=datetime.UTC),
mime_type="application/pdf", mime_type="application/pdf",
pk=2, pk=2,
checksum="2", checksum="2",
@@ -1778,7 +1777,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create( doc_a = Document.objects.create(
title="Some Title", title="Some Title",
created=datetime.date(2020, 6, 25), created=datetime.date(2020, 6, 25),
added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)), added=datetime.datetime(2024, 10, 1, 7, 36, 51, 153, tzinfo=datetime.UTC),
mime_type="application/pdf", mime_type="application/pdf",
pk=2, pk=2,
checksum="2", checksum="2",
@@ -1792,8 +1791,15 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
CustomFieldInstance.objects.create( CustomFieldInstance.objects.create(
document=doc_a, document=doc_a,
field=CustomField.objects.get(name="Invoice Date"), field=CustomField.objects.get(name="Invoice Date"),
value_date=timezone.make_aware( value_date=datetime.datetime(
datetime.datetime(2024, 10, 1, 7, 36, 51, 153), 2024,
10,
1,
7,
36,
51,
153,
tzinfo=datetime.UTC,
), ),
) )
@@ -1833,7 +1839,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc = Document.objects.create( doc = Document.objects.create(
title="Some Title! With @ Special # Characters", title="Some Title! With @ Special # Characters",
created=datetime.date(2020, 6, 25), created=datetime.date(2020, 6, 25),
added=timezone.make_aware(datetime.datetime(2024, 10, 1, 7, 36, 51, 153)), added=datetime.datetime(2024, 10, 1, 7, 36, 51, 153, tzinfo=datetime.UTC),
mime_type="application/pdf", mime_type="application/pdf",
pk=2, pk=2,
checksum="2", checksum="2",
+1 -1
View File
@@ -160,7 +160,7 @@ class TestDateLocalization:
) )
def test_localize_date_raises_type_error_for_invalid_input( def test_localize_date_raises_type_error_for_invalid_input(
self, self,
invalid_value: None | list[object] | dict[Any, Any] | Literal[1698330605], invalid_value: list[object] | dict[Any, Any] | Literal[1698330605] | None,
) -> None: ) -> None:
with pytest.raises(TypeError) as excinfo: with pytest.raises(TypeError) as excinfo:
localize_date(invalid_value, "medium", "en_US") localize_date(invalid_value, "medium", "en_US")
+1 -1
View File
@@ -287,7 +287,7 @@ class TestViews(DirectoriesMixin, TestCase):
"change": {"users": [], "groups": []}, "change": {"users": [], "groups": []},
} }
else: else:
assert False, f"Unexpected tag found: {tag['name']}" raise AssertionError(f"Unexpected tag found: {tag['name']}")
def test_list_no_n_plus_1_queries(self) -> None: def test_list_no_n_plus_1_queries(self) -> None:
""" """
+8 -1
View File
@@ -2799,7 +2799,14 @@ class TestWorkflows(
doc = Document.objects.create( doc = Document.objects.create(
title="test", title="test",
) )
self.assertRaises(Exception, document_matches_workflow, doc, w, 99) self.assertRaisesRegex(
Exception,
"not yet supported",
document_matches_workflow,
doc,
w,
99,
)
def test_removal_action_document_updated_workflow(self) -> None: def test_removal_action_document_updated_workflow(self) -> None:
""" """
+3 -2
View File
@@ -129,11 +129,12 @@ def util_call_with_backoff(
status_codes.append(cause_exec.response.status_code) status_codes.append(cause_exec.response.status_code)
warnings.warn( warnings.warn(
f"HTTP Exception for {cause_exec.request.url} - {cause_exec}", f"HTTP Exception for {cause_exec.request.url} - {cause_exec}",
stacklevel=2,
) )
else: else:
warnings.warn(f"Unexpected error: {e}") warnings.warn(f"Unexpected error: {e}", stacklevel=2)
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
warnings.warn(f"Unexpected error: {e}") warnings.warn(f"Unexpected error: {e}", stacklevel=2)
retry_count = retry_count + 1 retry_count = retry_count + 1
+7 -5
View File
@@ -21,20 +21,22 @@ def uri_validator(value: str, allowed_schemes: set[str] | None = None) -> None:
parts = urlparse(value) parts = urlparse(value)
if not parts.scheme: if not parts.scheme:
raise ValidationError( raise ValidationError(
_(f"Unable to parse URI {value}, missing scheme"), _("Unable to parse URI %(value)s, missing scheme") % {"value": value},
params={"value": value}, params={"value": value},
) )
elif not parts.netloc and not parts.path: elif not parts.netloc and not parts.path:
raise ValidationError( raise ValidationError(
_(f"Unable to parse URI {value}, missing net location or path"), _("Unable to parse URI %(value)s, missing net location or path")
% {"value": value},
params={"value": value}, params={"value": value},
) )
if allowed_schemes and parts.scheme not in allowed_schemes: if allowed_schemes and parts.scheme not in allowed_schemes:
raise ValidationError( raise ValidationError(
_( _(
f"URI scheme '{parts.scheme}' is not allowed. Allowed schemes: {', '.join(allowed_schemes)}", "URI scheme '%(scheme)s' is not allowed. Allowed schemes: %(schemes)s",
), )
% {"scheme": parts.scheme, "schemes": ", ".join(allowed_schemes)},
params={"value": value, "scheme": parts.scheme}, params={"value": value, "scheme": parts.scheme},
) )
@@ -42,7 +44,7 @@ def uri_validator(value: str, allowed_schemes: set[str] | None = None) -> None:
raise raise
except Exception as e: except Exception as e:
raise ValidationError( raise ValidationError(
_(f"Unable to parse URI {value}"), _("Unable to parse URI %(value)s") % {"value": value},
params={"value": value}, params={"value": value},
) from e ) from e
+40 -43
View File
@@ -7,11 +7,11 @@ import tempfile
import zipfile import zipfile
from collections import defaultdict from collections import defaultdict
from collections import deque from collections import deque
from datetime import UTC
from datetime import datetime from datetime import datetime
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
from time import mktime
from time import sleep from time import sleep
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
@@ -61,7 +61,6 @@ from django.http import StreamingHttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.timezone import make_aware
from django.utils.translation import get_language from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
@@ -288,7 +287,7 @@ def _get_more_like_id(query_params: dict[str, Any], user: User | None) -> int:
pk=more_like_doc_id, pk=more_like_doc_id,
) )
except (TypeError, ValueError, Document.DoesNotExist): except (TypeError, ValueError, Document.DoesNotExist):
raise PermissionDenied(_("Invalid more_like_id")) raise PermissionDenied(_("Invalid more_like_id")) from None
if user and not has_perms_owner_aware( if user and not has_perms_owner_aware(
user, user,
@@ -1146,7 +1145,7 @@ class DocumentViewSet(
"root_document", "root_document",
).get(pk=pk) ).get(pk=pk)
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404 raise Http404 from None
root_doc = get_root_document(doc) root_doc = get_root_document(doc)
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
@@ -1309,7 +1308,7 @@ class DocumentViewSet(
"root_document", "root_document",
).get(id=pk) ).get(id=pk)
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404 raise Http404 from None
root_doc = get_root_document( root_doc = get_root_document(
request_doc, request_doc,
@@ -1425,7 +1424,7 @@ class DocumentViewSet(
try: try:
lang = detect(doc.content) lang = detect(doc.content)
except Exception: except Exception:
pass logger.debug("Unable to detect language for document %s", doc.pk)
meta["lang"] = lang meta["lang"] = lang
return Response(meta) return Response(meta)
@@ -1551,7 +1550,6 @@ class DocumentViewSet(
"document %s: %s", "document %s: %s",
doc.pk, doc.pk,
exc, exc,
exc_info=True,
) )
raise ValidationError({"ai": [_("Invalid AI configuration.")]}) from exc raise ValidationError({"ai": [_("Invalid AI configuration.")]}) from exc
except LLMTimeoutError as exc: except LLMTimeoutError as exc:
@@ -1559,7 +1557,6 @@ class DocumentViewSet(
"AI backend timed out while generating suggestions for document %s: %s", "AI backend timed out while generating suggestions for document %s: %s",
doc.pk, doc.pk,
exc, exc,
exc_info=True,
) )
return Response( return Response(
{"ai": [_("AI backend request timed out.")]}, {"ai": [_("AI backend request timed out.")]},
@@ -1636,7 +1633,7 @@ class DocumentViewSet(
disposition="inline", disposition="inline",
) )
except FileNotFoundError: except FileNotFoundError:
raise Http404 raise Http404 from None
@action(methods=["get"], detail=True, filter_backends=[]) @action(methods=["get"], detail=True, filter_backends=[])
@method_decorator(cache_control(no_cache=True)) @method_decorator(cache_control(no_cache=True))
@@ -1661,14 +1658,14 @@ class DocumentViewSet(
return FileResponse(handle, content_type="image/webp") return FileResponse(handle, content_type="image/webp")
except FileNotFoundError: except FileNotFoundError:
raise Http404 raise Http404 from None
@action(methods=["get"], detail=True) @action(methods=["get"], detail=True)
def download(self, request, pk=None): def download(self, request, pk=None):
try: try:
return self.file_response(pk, request, "attachment") return self.file_response(pk, request, "attachment")
except (FileNotFoundError, Document.DoesNotExist): except (FileNotFoundError, Document.DoesNotExist):
raise Http404 raise Http404 from None
@action( @action(
methods=["get", "post", "delete"], methods=["get", "post", "delete"],
@@ -1693,7 +1690,7 @@ class DocumentViewSet(
): ):
return HttpResponseForbidden("Insufficient permissions to view notes") return HttpResponseForbidden("Insufficient permissions to view notes")
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404 raise Http404 from None
serializer = self.get_serializer(doc) serializer = self.get_serializer(doc)
@@ -1764,7 +1761,7 @@ class DocumentViewSet(
try: try:
note_id_int = int(note_id) note_id_int = int(note_id)
except ValueError: except ValueError:
raise ValidationError({"id": "A valid integer is required."}) raise ValidationError({"id": "A valid integer is required."}) from None
note = get_object_or_404(Note, id=note_id_int, document=doc) note = get_object_or_404(Note, id=note_id_int, document=doc)
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
LogEntry.objects.log_create( LogEntry.objects.log_create(
@@ -1808,7 +1805,7 @@ class DocumentViewSet(
"Insufficient permissions to add share link", "Insufficient permissions to add share link",
) )
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404 raise Http404 from None
if request.method == "GET": if request.method == "GET":
now = timezone.now() now = timezone.now()
@@ -1836,7 +1833,7 @@ class DocumentViewSet(
"Insufficient permissions", "Insufficient permissions",
) )
except Document.DoesNotExist: # pragma: no cover except Document.DoesNotExist: # pragma: no cover
raise Http404 raise Http404 from None
# documents # documents
entries = [ entries = [
@@ -1857,28 +1854,28 @@ class DocumentViewSet(
] ]
# custom fields # custom fields
for entry in LogEntry.objects.get_for_objects( entries.extend(
doc.custom_fields.all(), {
).select_related("actor"): "id": entry.id,
entries.append( "timestamp": entry.timestamp,
{ "action": entry.get_action_display(),
"id": entry.id, "changes": {
"timestamp": entry.timestamp, "custom_fields": {
"action": entry.get_action_display(), "type": "custom_field",
"changes": { "field": str(entry.object_repr).split(":")[0].strip(),
"custom_fields": { "value": str(entry.object_repr).split(":")[1].strip(),
"type": "custom_field",
"field": str(entry.object_repr).split(":")[0].strip(),
"value": str(entry.object_repr).split(":")[1].strip(),
},
}, },
"actor": (
{"id": entry.actor.id, "username": entry.actor.username}
if entry.actor
else None
),
}, },
) "actor": (
{"id": entry.actor.id, "username": entry.actor.username}
if entry.actor
else None
),
}
for entry in LogEntry.objects.get_for_objects(
doc.custom_fields.all(),
).select_related("actor")
)
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True)) return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
@@ -1989,13 +1986,13 @@ class DocumentViewSet(
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404 raise Http404 from None
try: try:
doc_name, doc_data = serializer.validated_data.get("document") doc_name, doc_data = serializer.validated_data.get("document")
version_label = serializer.validated_data.get("version_label") version_label = serializer.validated_data.get("version_label")
t = int(mktime(datetime.now().timetuple())) t = int(timezone.now().timestamp())
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True) settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
@@ -2040,7 +2037,7 @@ class DocumentViewSet(
"root_document", "root_document",
).get(pk=pk) ).get(pk=pk)
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404 raise Http404 from None
return get_root_document(root_doc) return get_root_document(root_doc)
def _get_version_doc_for_root(self, root_doc: Document, version_id) -> Document: def _get_version_doc_for_root(self, root_doc: Document, version_id) -> Document:
@@ -2049,7 +2046,7 @@ class DocumentViewSet(
pk=version_id, pk=version_id,
) )
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404 raise Http404 from None
if ( if (
version_doc.id != root_doc.id version_doc.id != root_doc.id
@@ -2623,7 +2620,7 @@ class LogViewSet(ViewSet):
try: try:
limit = int(limit_param) limit = int(limit_param)
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValidationError({"limit": "Must be a positive integer"}) raise ValidationError({"limit": "Must be a positive integer"}) from None
if limit < 1: if limit < 1:
raise ValidationError({"limit": "Must be a positive integer"}) raise ValidationError({"limit": "Must be a positive integer"})
else: else:
@@ -3220,7 +3217,7 @@ class PostDocumentView(GenericAPIView[Any]):
cf = serializer.validated_data.get("custom_fields") cf = serializer.validated_data.get("custom_fields")
from_webui = serializer.validated_data.get("from_webui") from_webui = serializer.validated_data.get("from_webui")
t = int(mktime(datetime.now().timetuple())) t = int(timezone.now().timestamp())
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True) settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
@@ -4032,7 +4029,7 @@ class UiSettingsView(GenericAPIView[Any]):
user_resp["last_name"] = user.last_name user_resp["last_name"] = user.last_name
# strip <app_label>. # strip <app_label>.
roles = map(lambda perm: re.sub(r"^\w+.", "", perm), user.get_all_permissions()) roles = (re.sub(r"^\w+.", "", perm) for perm in user.get_all_permissions())
return Response( return Response(
{ {
"user": user_resp, "user": user_resp,
@@ -5122,7 +5119,7 @@ class SystemStatusView(PassUserMixin):
index_dir = settings.INDEX_DIR index_dir = settings.INDEX_DIR
mtimes = [p.stat().st_mtime for p in index_dir.iterdir() if p.is_file()] mtimes = [p.stat().st_mtime for p in index_dir.iterdir() if p.is_file()]
index_last_modified = ( index_last_modified = (
make_aware(datetime.fromtimestamp(max(mtimes))) if mtimes else None datetime.fromtimestamp(max(mtimes), tz=UTC) if mtimes else None
) )
except Exception as e: except Exception as e:
index_status = "ERROR" index_status = "ERROR"
+6 -6
View File
@@ -9,11 +9,11 @@ from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
django_asgi_app = get_asgi_application() django_asgi_app = get_asgi_application()
from channels.auth import AuthMiddlewareStack # noqa: E402 from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter # noqa: E402 from channels.routing import ProtocolTypeRouter
from channels.routing import URLRouter # noqa: E402 from channels.routing import URLRouter
from paperless.urls import websocket_urlpatterns # noqa: E402 from paperless.urls import websocket_urlpatterns
application = ProtocolTypeRouter( application = ProtocolTypeRouter(
{ {
@@ -22,9 +22,9 @@ application = ProtocolTypeRouter(
}, },
) )
import logging # noqa: E402 import logging
from paperless.version import __full_version_str__ # noqa: E402 from paperless.version import __full_version_str__
logger = logging.getLogger("paperless.asgi") logger = logging.getLogger("paperless.asgi")
logger.info(f"[init] Paperless-ngx version: v{__full_version_str__}") logger.info(f"[init] Paperless-ngx version: v{__full_version_str__}")
+1 -1
View File
@@ -17,7 +17,7 @@ class AutoLoginMiddleware(MiddlewareMixin):
def process_request(self, request: HttpRequest) -> None: def process_request(self, request: HttpRequest) -> None:
# Dont use auto-login with token request # Dont use auto-login with token request
if request.path.startswith("/api/token/") and request.method == "POST": if request.path.startswith("/api/token/") and request.method == "POST":
return None return
try: try:
request.user = User.objects.get(username=settings.AUTO_LOGIN_USERNAME) request.user = User.objects.get(username=settings.AUTO_LOGIN_USERNAME)
auth.login( auth.login(
+16 -14
View File
@@ -84,10 +84,11 @@ def binaries_check(app_configs: Any, **kwargs: Any) -> list[Error]:
binaries = (settings.CONVERT_BINARY, "tesseract", "gs") binaries = (settings.CONVERT_BINARY, "tesseract", "gs")
check_messages = [] check_messages = [
for binary in binaries: Warning(error.format(binary), hint)
if shutil.which(binary) is None: for binary in binaries
check_messages.append(Warning(error.format(binary), hint)) if shutil.which(binary) is None
]
return check_messages return check_messages
@@ -241,7 +242,7 @@ def check_v3_minimum_upgrade_version(
return [] return []
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
last_applied = sorted(applied)[-1] if applied else "(none)" last_applied = max(applied) if applied else "(none)"
logger.error( logger.error(
"V3 upgrade check failed: last applied documents migration is %r. " "V3 upgrade check failed: last applied documents migration is %r. "
"Expected '1075_workflowaction_order' (v2.20.15). " "Expected '1075_workflowaction_order' (v2.20.15). "
@@ -355,6 +356,7 @@ def get_tesseract_langs():
proc = subprocess.run( proc = subprocess.run(
[shutil.which("tesseract"), "--list-langs"], [shutil.which("tesseract"), "--list-langs"],
capture_output=True, capture_output=True,
check=False,
) )
# Decode bytes to string, split on newlines, trim out the header # Decode bytes to string, split on newlines, trim out the header
@@ -383,14 +385,14 @@ def check_default_language_available(app_configs: Any, **kwargs: Any) -> list[Er
specified_langs = [x.strip() for x in settings.OCR_LANGUAGE.split("+")] specified_langs = [x.strip() for x in settings.OCR_LANGUAGE.split("+")]
for lang in specified_langs: errs.extend(
if lang not in installed_langs: Error(
errs.append( f"The selected ocr language {lang} is "
Error( f"not installed. Paperless cannot OCR your documents "
f"The selected ocr language {lang} is " f"without it. Please fix PAPERLESS_OCR_LANGUAGE.",
f"not installed. Paperless cannot OCR your documents " )
f"without it. Please fix PAPERLESS_OCR_LANGUAGE.", for lang in specified_langs
), if lang not in installed_langs
) )
return errs return errs
+4 -5
View File
@@ -658,11 +658,10 @@ class MailDocumentParser:
if data["bcc"]: if data["bcc"]:
data["bcc_label"] = "BCC" data["bcc_label"] = "BCC"
att = [] att = [
for a in mail.attachments: f"{a.filename} ({naturalsize(a.size, binary=True, format='%.2f')})"
att.append( for a in mail.attachments
f"{a.filename} ({naturalsize(a.size, binary=True, format='%.2f')})", ]
)
data["attachments"] = clean_html(", ".join(att)) data["attachments"] = clean_html(", ".join(att))
if data["attachments"]: if data["attachments"]:
data["attachments_label"] = "Attachments" data["attachments_label"] = "Attachments"
+11 -9
View File
@@ -294,7 +294,7 @@ if _CHANNELS_BACKEND.startswith("channels_redis."):
############################################################################### ###############################################################################
EMAIL_HOST: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST", "localhost") EMAIL_HOST: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST", "localhost")
EMAIL_PORT: Final[int] = int(os.getenv("PAPERLESS_EMAIL_PORT", 25)) EMAIL_PORT: Final[int] = get_int_from_env("PAPERLESS_EMAIL_PORT", 25)
EMAIL_HOST_USER: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_USER", "") EMAIL_HOST_USER: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_PASSWORD", "") EMAIL_HOST_PASSWORD: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_PASSWORD", "")
DEFAULT_FROM_EMAIL: Final[str] = os.getenv("PAPERLESS_EMAIL_FROM", EMAIL_HOST_USER) DEFAULT_FROM_EMAIL: Final[str] = os.getenv("PAPERLESS_EMAIL_FROM", EMAIL_HOST_USER)
@@ -375,8 +375,9 @@ ACCOUNT_SESSION_REMEMBER = get_bool_from_env(
"True", "True",
) )
SESSION_EXPIRE_AT_BROWSER_CLOSE = not ACCOUNT_SESSION_REMEMBER SESSION_EXPIRE_AT_BROWSER_CLOSE = not ACCOUNT_SESSION_REMEMBER
SESSION_COOKIE_AGE = int( SESSION_COOKIE_AGE = get_int_from_env(
os.getenv("PAPERLESS_SESSION_COOKIE_AGE", 60 * 60 * 24 * 7 * 3), "PAPERLESS_SESSION_COOKIE_AGE",
60 * 60 * 24 * 7 * 3,
) )
# https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-SESSION_ENGINE # https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-SESSION_ENGINE
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
@@ -389,7 +390,6 @@ if AUTO_LOGIN_USERNAME:
def _parse_remote_user_settings() -> str: def _parse_remote_user_settings() -> str:
global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
enable = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER") enable = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
enable_api = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API") enable_api = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
if enable or enable_api: if enable or enable_api:
@@ -448,7 +448,6 @@ if ALLOWED_HOSTS != ["*"]:
def _parse_paperless_url(): def _parse_paperless_url():
global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS
url = os.getenv("PAPERLESS_URL") url = os.getenv("PAPERLESS_URL")
if url: if url:
CSRF_TRUSTED_ORIGINS.append(url) CSRF_TRUSTED_ORIGINS.append(url)
@@ -597,8 +596,8 @@ USE_TZ = True
LOGGING_DIR.mkdir(parents=True, exist_ok=True) LOGGING_DIR.mkdir(parents=True, exist_ok=True)
LOGROTATE_MAX_SIZE = os.getenv("PAPERLESS_LOGROTATE_MAX_SIZE", 1024 * 1024) LOGROTATE_MAX_SIZE = get_int_from_env("PAPERLESS_LOGROTATE_MAX_SIZE", 1024 * 1024)
LOGROTATE_MAX_BACKUPS = os.getenv("PAPERLESS_LOGROTATE_MAX_BACKUPS", 20) LOGROTATE_MAX_BACKUPS = get_int_from_env("PAPERLESS_LOGROTATE_MAX_BACKUPS", 20)
LOGGING = { LOGGING = {
"version": 1, "version": 1,
@@ -794,9 +793,12 @@ IGNORABLE_FILES: Final[list[str]] = [
"Thumbs.db", "Thumbs.db",
] ]
CONSUMER_POLLING_INTERVAL = float(os.getenv("PAPERLESS_CONSUMER_POLLING_INTERVAL", 0)) CONSUMER_POLLING_INTERVAL = get_float_from_env(
"PAPERLESS_CONSUMER_POLLING_INTERVAL",
0,
)
CONSUMER_STABILITY_DELAY = float(os.getenv("PAPERLESS_CONSUMER_STABILITY_DELAY", 5)) CONSUMER_STABILITY_DELAY = get_float_from_env("PAPERLESS_CONSUMER_STABILITY_DELAY", 5)
CONSUMER_DELETE_DUPLICATES = get_bool_from_env("PAPERLESS_CONSUMER_DELETE_DUPLICATES") CONSUMER_DELETE_DUPLICATES = get_bool_from_env("PAPERLESS_CONSUMER_DELETE_DUPLICATES")
+1 -1
View File
@@ -334,7 +334,7 @@ def parse_dateparser_languages(languages: str | None) -> list[str]:
language_list = languages.split("+") if languages else [] language_list = languages.split("+") if languages else []
# There is an unfixed issue in zh-Hant and zh-Hans locales in the dateparser lib. # There is an unfixed issue in zh-Hant and zh-Hans locales in the dateparser lib.
# See: https://github.com/scrapinghub/dateparser/issues/875 # See: https://github.com/scrapinghub/dateparser/issues/875
for index, language in enumerate(language_list): for _, language in enumerate(language_list):
if language.startswith("zh-") and "zh" not in language_list: if language.startswith("zh-") and "zh" not in language_list:
logger.warning( logger.warning(
f"Chinese locale detected: {language}. dateparser might fail to parse" f"Chinese locale detected: {language}. dateparser might fail to parse"
@@ -161,7 +161,7 @@ class TestEmailFileParsing:
""" """
mock_message = mocker.Mock( mock_message = mocker.Mock(
from_values="mail@someserver.de", from_values="mail@someserver.de",
date=datetime.datetime(2022, 10, 12, 21, 40, 43), date=datetime.datetime(2022, 10, 12, 21, 40, 43), # noqa: DTZ001
) )
mocker.patch( mocker.patch(
"paperless.parsers.mail.MailMessage.from_bytes", "paperless.parsers.mail.MailMessage.from_bytes",
@@ -171,7 +171,7 @@ class TestEmailFileParsing:
parsed_msg = mail_parser.parse_file_to_message(simple_txt_email_file) parsed_msg = mail_parser.parse_file_to_message(simple_txt_email_file)
assert timezone.is_aware(parsed_msg.date) assert timezone.is_aware(parsed_msg.date)
assert parsed_msg.date.replace(tzinfo=None) == datetime.datetime( assert parsed_msg.date.replace(tzinfo=None) == datetime.datetime( # noqa: DTZ001
2022, 2022,
10, 10,
12, 12,
@@ -114,17 +114,17 @@ def test_cache_hit_when_enabled() -> None:
assert settings.CACHALOT_TIMEOUT == 1 assert settings.CACHALOT_TIMEOUT == 1
# Read a table to populate the cache # Read a table to populate the cache
list(list(Tag.objects.values_list("id", flat=True))) list(Tag.objects.values_list("id", flat=True))
# Invalidate the cache then read the database, there should be DB hit # Invalidate the cache then read the database, there should be DB hit
invalidate_db_cache() invalidate_db_cache()
with CaptureQueriesContext(connection) as ctx: with CaptureQueriesContext(connection) as ctx:
list(list(Tag.objects.values_list("id", flat=True))) list(Tag.objects.values_list("id", flat=True))
assert len(ctx) assert len(ctx)
# Doing the same request again should hit the cache, not the DB # Doing the same request again should hit the cache, not the DB
with CaptureQueriesContext(connection) as ctx: with CaptureQueriesContext(connection) as ctx:
list(list(Tag.objects.values_list("id", flat=True))) list(Tag.objects.values_list("id", flat=True))
assert not len(ctx) assert not len(ctx)
# Wait the end of TTL # Wait the end of TTL
@@ -133,7 +133,7 @@ def test_cache_hit_when_enabled() -> None:
# Read the DB again. The DB should be hit because the cache has expired # Read the DB again. The DB should be hit because the cache has expired
with CaptureQueriesContext(connection) as ctx: with CaptureQueriesContext(connection) as ctx:
list(list(Tag.objects.values_list("id", flat=True))) list(Tag.objects.values_list("id", flat=True))
assert len(ctx) assert len(ctx)
# Invalidate the cache at the end of test # Invalidate the cache at the end of test
@@ -149,7 +149,7 @@ def test_cache_is_disabled_by_default() -> None:
# Read the table multiple times: the DB should always be hit without cache # Read the table multiple times: the DB should always be hit without cache
for _ in range(3): for _ in range(3):
with CaptureQueriesContext(connection) as ctx: with CaptureQueriesContext(connection) as ctx:
list(list(Tag.objects.values_list("id", flat=True))) list(Tag.objects.values_list("id", flat=True))
assert len(ctx) assert len(ctx)
# Invalidate the cache at the end of test # Invalidate the cache at the end of test
+1 -1
View File
@@ -193,7 +193,7 @@ def reject_dangerous_svg(file: UploadedFile) -> None:
tree = etree.parse(file, parser) tree = etree.parse(file, parser)
root = tree.getroot() root = tree.getroot()
except etree.XMLSyntaxError: except etree.XMLSyntaxError:
raise ValidationError("Invalid SVG file.") raise ValidationError("Invalid SVG file.") from None
for element in root.iter(): for element in root.iter():
tag: str = etree.QName(element.tag).localname.lower() tag: str = etree.QName(element.tag).localname.lower()
+2 -2
View File
@@ -15,9 +15,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
application = get_wsgi_application() application = get_wsgi_application()
import logging # noqa: E402 import logging
from paperless.version import __full_version_str__ # noqa: E402 from paperless.version import __full_version_str__
logger = logging.getLogger("paperless.wsgi") logger = logging.getLogger("paperless.wsgi")
logger.info(f"[init] Paperless-ngx version: v{__full_version_str__}") logger.info(f"[init] Paperless-ngx version: v{__full_version_str__}")
+4 -2
View File
@@ -129,8 +129,10 @@ def build_llm_index_text(doc: Document) -> str:
f"Notes: {','.join([str(c.note) for c in Note.objects.filter(document=doc)])}", f"Notes: {','.join([str(c.note) for c in Note.objects.filter(document=doc)])}",
] ]
for instance in doc.custom_fields.all(): lines.extend(
lines.append(f"Custom Field - {instance.field.name}: {instance}") f"Custom Field - {instance.field.name}: {instance}"
for instance in doc.custom_fields.all()
)
lines.append("\nContent:\n") lines.append("\nContent:\n")
lines.append(doc.content or "") lines.append(doc.content or "")
+1 -1
View File
@@ -156,7 +156,7 @@ def test_get_ai_document_classification_failure(mock_run_llm_query, mock_documen
mock_run_llm_query.side_effect = Exception("LLM query failed") mock_run_llm_query.side_effect = Exception("LLM query failed")
# assert raises an exception # assert raises an exception
with pytest.raises(Exception): with pytest.raises(ValueError, match="Unsupported LLM backend"):
get_ai_document_classification(mock_document) get_ai_document_classification(mock_document)
@@ -21,5 +21,6 @@ class TestLazyAiImports:
capture_output=True, capture_output=True,
text=True, text=True,
cwd=_SRC_DIR, cwd=_SRC_DIR,
check=False,
) )
assert result.returncode == 0, result.stdout + result.stderr assert result.returncode == 0, result.stdout + result.stderr
+2 -1
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from types import TracebackType from types import TracebackType
from typing import Any from typing import Any
from typing import Literal from typing import Literal
from typing import Self
import sqlite_vec import sqlite_vec
from llama_index.core.bridge.pydantic import PrivateAttr from llama_index.core.bridge.pydantic import PrivateAttr
@@ -199,7 +200,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
"""Close the underlying SQLite connection (idempotent).""" """Close the underlying SQLite connection (idempotent)."""
self._conn.close() self._conn.close()
def __enter__(self) -> "PaperlessSqliteVecVectorStore": def __enter__(self) -> Self:
return self return self
def __exit__( def __exit__(
+3 -4
View File
@@ -5,7 +5,6 @@ import ssl
import tempfile import tempfile
import traceback import traceback
import unicodedata import unicodedata
from datetime import date
from datetime import timedelta from datetime import timedelta
from fnmatch import fnmatch from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
@@ -395,7 +394,7 @@ def make_criterias(rule: MailRule, *, supports_gmail_labels: bool):
Returns criteria to be applied to MailBox.fetch for the given rule. Returns criteria to be applied to MailBox.fetch for the given rule.
""" """
maximum_age = date.today() - timedelta(days=rule.maximum_age) maximum_age = timezone.now().date() - timedelta(days=rule.maximum_age)
criterias = {} criterias = {}
if rule.maximum_age > 0: if rule.maximum_age > 0:
criterias["date_gte"] = maximum_age criterias["date_gte"] = maximum_age
@@ -663,8 +662,8 @@ class MailAccountHandler(LoggingMixin):
self.log.info(f"Located folder: {folder_info.name}") self.log.info(f"Located folder: {folder_info.name}")
except Exception as e: except Exception as e:
self.log.error( self.log.error(
"Exception during folder listing, unable to provide list folders: " "Exception during folder listing, unable to provide list folders: %s",
+ str(e), e,
) )
raise MailError( raise MailError(
+6 -4
View File
@@ -353,9 +353,10 @@ class MailMocker(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
len(expected_call_args), len(expected_call_args),
) )
for (mock_args, mock_kwargs), expected_signatures in zip( for (_, mock_kwargs), expected_signatures in zip(
self._queue_consumption_tasks_mock.call_args_list, self._queue_consumption_tasks_mock.call_args_list,
expected_call_args, expected_call_args,
strict=False,
): ):
consume_tasks = mock_kwargs["consume_tasks"] consume_tasks = mock_kwargs["consume_tasks"]
@@ -365,6 +366,7 @@ class MailMocker(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
for consume_task, expected_signature in zip( for consume_task, expected_signature in zip(
consume_tasks, consume_tasks,
expected_signatures, expected_signatures,
strict=False,
): ):
input_doc = consume_task.kwargs["input_doc"] input_doc = consume_task.kwargs["input_doc"]
overrides = consume_task.kwargs["overrides"] overrides = consume_task.kwargs["overrides"]
@@ -387,7 +389,7 @@ class MailMocker(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
""" """
Applies pending actions to mails by inspecting calls to the queue_consumption_tasks method. Applies pending actions to mails by inspecting calls to the queue_consumption_tasks method.
""" """
for args, kwargs in self._queue_consumption_tasks_mock.call_args_list: for _, kwargs in self._queue_consumption_tasks_mock.call_args_list:
message = kwargs["message"] message = kwargs["message"]
rule = kwargs["rule"] rule = kwargs["rule"]
apply_mail_action([], rule.pk, message.uid, message.subject, message.date) apply_mail_action([], rule.pk, message.uid, message.subject, message.date)
@@ -406,7 +408,7 @@ def assert_eventually_equals(
deadline = time.time() + timeout deadline = time.time() + timeout
while time.time() < deadline: while time.time() < deadline:
if getter_fn() == expected_value: if getter_fn() == expected_value:
return None return
time.sleep(interval) time.sleep(interval)
actual = getter_fn() actual = getter_fn()
raise AssertionError(f"Expected {expected_value}, but got {actual}") raise AssertionError(f"Expected {expected_value}, but got {actual}")
@@ -1517,7 +1519,7 @@ class TestMail(
if message.from_ == "amazon@amazon.de": if message.from_ == "amazon@amazon.de":
raise ValueError("Does not compute.") raise ValueError("Does not compute.")
else: else:
return None return
m.side_effect = get_correspondent_fake m.side_effect = get_correspondent_fake
@@ -184,7 +184,12 @@ class TestMailMessageGpgDecryptor(TestMail):
EMAIL_GNUPG_HOME=empty_gpg_home, EMAIL_GNUPG_HOME=empty_gpg_home,
): ):
message_decryptor = MailMessageDecryptor() message_decryptor = MailMessageDecryptor()
self.assertRaises(Exception, message_decryptor.run, encrypted_message) self.assertRaisesRegex(
Exception,
"Decryption failed",
message_decryptor.run,
encrypted_message,
)
finally: finally:
# Clean up the temporary GPG home used only by this test # Clean up the temporary GPG home used only by this test
try: try:
+1 -2
View File
@@ -1,4 +1,3 @@
import datetime
import logging import logging
from datetime import timedelta from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
@@ -86,7 +85,7 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
@action(methods=["post"], detail=False) @action(methods=["post"], detail=False)
def test(self, request): def test(self, request):
logger = logging.getLogger("paperless_mail") logger = logging.getLogger("paperless_mail")
request.data["name"] = datetime.datetime.now().isoformat() request.data["name"] = timezone.now().isoformat()
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
existing_account = None existing_account = None