mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-26 13:54:56 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507889f06a | ||
|
|
4971be9b37 | ||
|
|
219cda569c | ||
|
|
de227d0f71 | ||
|
|
5cdc82ae35 | ||
|
|
976eb3beaa | ||
|
|
6f1673e0ae |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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" = [
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}/",
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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__}")
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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__}")
|
||||||
|
|||||||
@@ -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 "")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user