Compare commits

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-24 10:36:59 -07:00
Trenton HandGitHub d76dbf5366 Documentation: Add the NumPy CPU baseline increase to the migration guide (#13269) 2026-07-24 09:18:07 -07:00
Trenton HandGitHub b66182cd7b Fix: Makes the email date aware as soon as possible during parsing (#13266) 2026-07-24 15:39:25 +00:00
Trenton HandGitHub 3fe6562c57 Fix: Handle a plain string as Celery sometimes provides for the traceback (#13267) 2026-07-24 15:27:57 +00:00
Trenton HandGitHub 737d568f80 Fix: Emit the torch index into the requirements.txt for people still using it (#13265) 2026-07-24 08:07:52 -07:00
Matthias MastandGitHub b992f9fc05 Fix: handle notes without a user when building the search index (#13260) 2026-07-24 06:39:11 -07:00
54 changed files with 432 additions and 241 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ jobs:
# ---- Prepare Release ----
- name: Generate requirements file
run: |
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
uv export --quiet --no-dev --all-extras --emit-index-url --format requirements-txt --output-file requirements.txt
- name: Compile messages
env:
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
+1 -1
View File
@@ -50,7 +50,7 @@ repos:
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20
rev: v0.16.0
hooks:
- id: ruff-check
- id: ruff-format
+1 -1
View File
@@ -61,7 +61,7 @@ def replace_with_symlinks(
total_duplicates = 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
original_file = file_list[0]
duplicates = file_list[1:]
+61 -3
View File
@@ -133,7 +133,7 @@ PAPERLESS_DB_OPTIONS="sslmode=require,sslrootcert=/certs/ca.pem,pool.max_size=10
## OCR and Archive File Generation Settings
The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment.
The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** - old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment.
### Removed settings
@@ -166,7 +166,7 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
# v2: skip OCR when text present, always archive
PAPERLESS_OCR_MODE=skip
# v3: equivalent (auto is the new default)
# No change needed auto is the default
# No change needed - auto is the default
# v2: skip OCR when text present, skip archive too
PAPERLESS_OCR_MODE=skip_noarchive
@@ -189,7 +189,7 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
If you use the **remote OCR parser** (Azure AI), note that it always produces a
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
has no effect for documents handled by the remote parser the archive is produced
has no effect for documents handled by the remote parser - the archive is produced
unconditionally by the remote engine.
## Search Index (Whoosh -> Tantivy)
@@ -327,6 +327,64 @@ behind a reverse proxy may need to set
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
or both, to avoid `403 Forbidden` errors on login.
## Minimum CPU Requirements (NumPy Baseline)
Starting with NumPy 2.4.0, official `manylinux` x86_64 wheels are compiled with a minimum
CPU baseline of `x86-64-v2`, which requires SSE3, SSSE3, SSE4.1, SSE4.2, POPCNT, and
CMPXCHG16B. This is a [deliberate upstream change](https://github.com/numpy/numpy/issues/27851)
citing that these instructions have been present in over 99.7% of CPUs since 2008
([Intel](<https://en.wikipedia.org/wiki/Nehalem_(microarchitecture)>)) or 2011
([AMD](<https://en.wikipedia.org/wiki/Bulldozer_(microarchitecture)>)).
NumPy is a dependency of the document classifier (via scikit-learn), so any CPU that
predates SSE4.2 support will crash with `SIGILL` (illegal instruction) when the classifier
is loaded or trained, regardless of whether AI features are enabled.
This differs from NumPy's optional SIMD dispatch (e.g. AVX2, AVX512), which is detected and
selected safely at runtime - the `x86-64-v2` requirement above is a hard floor baked into the
wheel, with no runtime fallback.
### Affected hardware
CPUs older than roughly 2008 (Intel) or 2011 (AMD) that lack SSE4.2 support. This is more
likely to affect low-power or embedded hardware - e.g. early Atom, Celeron, or pre-Bulldozer
AMD chips - than typical desktop or server hardware from the last decade.
Check for SSE4.2 support with:
```bash
grep -o -m1 sse4_2 /proc/cpuinfo
```
If this prints nothing, your CPU is affected.
### Symptoms
The Celery worker (and potentially the web server) repeatedly crashes and restarts with a
`SIGILL` error, typically visible in `dmesg`/`journalctl` as a `trap invalid opcode` inside
`_multiarray_umath...so`. Because the classifier is trained on a periodic schedule
(hourly, by default), affected instances see intermittent, hard-to-reproduce document
consumption failures whenever that scheduled task runs and takes down the worker process
mid-task.
### Action Required (for affected hardware only)
There is no way to make the classifier itself work on such CPUs - it requires an unofficial
NumPy build with `cpu-baseline=none`, which is not something we can ship. The practical
path forward is to stop the classifier from ever loading or training, which avoids
importing NumPy at all:
```bash
PAPERLESS_TRAIN_TASK_CRON=disable
```
This disables the periodic classifier training task (see
[`PAPERLESS_TRAIN_TASK_CRON`](configuration.md#PAPERLESS_TRAIN_TASK_CRON)). Automatic
matching based on the classifier (suggested correspondents, document types, tags, and
storage paths from trained rules) will no longer be available, but rule-based matching is
unaffected, and document consumption itself will no longer be at risk of crashing the
worker.
## Database Migrations
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
+11 -2
View File
@@ -105,7 +105,7 @@ docs = [
]
lint = [
"prek~=0.3.10",
"ruff~=0.15.20",
"ruff~=0.16.0",
]
testing = [
"daphne",
@@ -184,12 +184,16 @@ line-ending = "lf"
[tool.ruff.lint]
# https://docs.astral.sh/ruff/rules/
extend-select = [
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
"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
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
"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
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"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
]
ignore = [
"BLE001", # blind except: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"DJ001",
"G004", # f-strings in logging: accepted style in this codebase
"PLC0415",
"RUF012",
"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
per-file-ignores."*/migrations/*.py" = [
+4 -3
View File
@@ -839,8 +839,9 @@ class ConsumerPlugin(
self.log.debug(f"Creation date from parse_date: {create_date}")
else:
stats = Path(self.input_doc.original_file).stat()
create_date = timezone.make_aware(
datetime.datetime.fromtimestamp(stats.st_mtime),
create_date = datetime.datetime.fromtimestamp(
stats.st_mtime,
tz=datetime.UTC,
)
self.log.debug(f"Creation date from st_mtime: {create_date}")
@@ -954,7 +955,7 @@ class ConsumerPlugin(
try:
copy_basic_file_stats(source, target)
except Exception: # pragma: no cover
pass
self.log.debug("Unable to copy file stats from %s to %s", source, target)
class ConsumerPreflightPlugin(
+4 -4
View File
@@ -1,4 +1,3 @@
import datetime as dt
import logging
import os
import shutil
@@ -6,6 +5,7 @@ from pathlib import Path
from typing import Final
from django.conf import settings
from django.utils import timezone
from pikepdf import Pdf
from documents.consumer import ConsumerError
@@ -78,7 +78,7 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
stats = staging.stat()
# if the file is older than the timeout, we don't consider
# 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")
staging.unlink()
else:
@@ -99,7 +99,7 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
"two uploaded files don't belong to the same double-"
"sided scan. Please retry, starting with the odd "
"numbered pages again.",
)
) from None
# Merged file has the same path, but without the
# double-sided subdir. Therefore, it is also in the
# consumption dir and will be picked up for processing
@@ -134,7 +134,7 @@ class CollatePlugin(NoCleanupPluginMixin, NoSetupPluginMixin, ConsumeTaskPlugin)
shutil.move(pdf_file, staging)
# update access to modification time so we know if the file
# is outdated when another file gets uploaded
timestamp = dt.datetime.now().timestamp()
timestamp = timezone.now().timestamp()
os.utime(staging, (timestamp, timestamp))
logger.info(
"Got scan with odd numbered pages of double-sided scan, moved it to %s",
+5 -5
View File
@@ -357,7 +357,7 @@ def handle_validation_prefix(func: Callable):
try:
return func(*args, **kwargs)
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
old_sig = inspect.signature(func)
@@ -468,7 +468,7 @@ class CustomFieldQueryParser:
except json.JSONDecodeError:
raise serializers.ValidationError(
{self._validation_prefix: [_("Value must be valid JSON.")]},
)
) from None
return (
self._parse_expr(expr, validation_prefix=self._validation_prefix),
self._annotations,
@@ -596,7 +596,7 @@ class CustomFieldQueryParser:
except CustomField.DoesNotExist:
raise serializers.ValidationError(
[_("{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.name] = custom_field
return custom_field
@@ -729,7 +729,7 @@ class CustomFieldQueryParser:
)
# 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:
# The result should be an empty set in this case.
return Q(id__in=[])
@@ -1065,7 +1065,7 @@ class DocumentsOrderingFilter(OrderingFilter):
except CustomField.DoesNotExist:
raise serializers.ValidationError(
{self.prefix + str(custom_field_id): [_("Custom field not found")]},
)
) from None
annotation = None
match field.data_type:
@@ -7,6 +7,7 @@ from itertools import islice
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import Self
from allauth.mfa.models import Authenticator
from allauth.socialaccount.models import SocialAccount
@@ -154,7 +155,7 @@ class StreamingManifestWriter:
return
self._tmp_path.rename(self._path)
def __enter__(self) -> "StreamingManifestWriter":
def __enter__(self) -> Self:
self.open()
return self
@@ -480,7 +481,7 @@ class Command(CryptMixin, PaperlessCommand):
}
# 3. Export files from each document
for index, document_dict in enumerate(
for _, document_dict in enumerate(
self.track(
document_manifest,
description="Exporting documents...",
@@ -133,11 +133,14 @@ def _build_suggestion_table(
else:
doc_cell = Text(f"{doc} [{doc.pk}]")
tag_parts: list[str] = []
for tag in sorted(suggestion.tags_to_add, key=lambda t: t.name):
tag_parts.append(f"[green]+{tag.name}[/green]")
for tag in sorted(suggestion.tags_to_remove, key=lambda t: t.name):
tag_parts.append(f"[red]-{tag.name}[/red]")
tag_parts: list[str] = [
f"[green]+{tag.name}[/green]"
for tag in sorted(suggestion.tags_to_add, key=lambda t: t.name)
]
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("-")
table.add_row(
+4 -5
View File
@@ -300,7 +300,7 @@ def consumable_document_matches_workflow(
]:
reason = (
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
@@ -310,8 +310,7 @@ def consumable_document_matches_workflow(
and document.mailrule_id != trigger.filter_mailrule.pk
):
reason = (
f"Document mail rule {document.mailrule_id}"
f" != {trigger.filter_mailrule.pk}",
f"Document mail rule {document.mailrule_id} != {trigger.filter_mailrule.pk}"
)
trigger_matched = False
@@ -326,7 +325,7 @@ def consumable_document_matches_workflow(
):
reason = (
f"Document filename {document.original_file.name} does not match"
f" {trigger.filter_filename.lower()}",
f" {trigger.filter_filename.lower()}"
)
trigger_matched = False
@@ -349,7 +348,7 @@ def consumable_document_matches_workflow(
):
reason = (
f"Document path {document.original_file}"
f" does not match {trigger.filter_path}",
f" does not match {trigger.filter_path}"
)
trigger_matched = False
+3 -3
View File
@@ -373,7 +373,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
If the queryset already annotated ``effective_content``, that value is used.
"""
if 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:
return self.content
@@ -1214,8 +1214,8 @@ class CustomFieldInstance(SoftDeleteModel):
def get_value_field_name(cls, data_type: CustomField.FieldDataType):
try:
return cls.TYPE_TO_DATA_STORE_NAME_MAP[data_type]
except KeyError: # pragma: no cover
raise NotImplementedError(data_type)
except KeyError as exc: # pragma: no cover
raise NotImplementedError(data_type) from exc
@property
def value(self):
+1 -1
View File
@@ -110,7 +110,7 @@ def run_convert(
args += ["-define", "pdf:use-cropbox=true"] if use_cropbox else []
args += [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:
run_subprocess(args, environment, logger)
+1 -2
View File
@@ -67,8 +67,7 @@ class DateParserPluginBase(ABC):
Subclasses can override this to release resources.
"""
# Default implementation does nothing.
# Returning None implies exceptions are propagated.
return
def _parse_string(
self,
+14 -4
View File
@@ -204,12 +204,12 @@ class WriteBatch:
try:
self._lock.acquire(timeout=self._lock_timeout)
break
except filelock.Timeout:
except filelock.Timeout as exc:
if attempt == _LOCK_RETRY_ATTEMPTS - 1:
raise SearchIndexLockError(
f"Could not acquire index lock after {_LOCK_RETRY_ATTEMPTS} "
f"attempts (timeout={self._lock_timeout}s each)",
)
) from exc
sleep_s = random.uniform(
0,
min(_LOCK_BACKOFF_CAP, _LOCK_BACKOFF_BASE * (2**attempt)),
@@ -475,7 +475,13 @@ class TantivyBackend:
note_texts: list[str] = []
for note in document.notes.all():
num_notes += 1
doc.add_json("notes", {"note": note.note, "user": note.user.username})
doc.add_json(
"notes",
{
"note": note.note,
"user": note.user.username if note.user else None,
},
)
note_texts.append(note.note)
if note_texts:
doc.add_text("notes_text", " ".join(note_texts))
@@ -696,7 +702,11 @@ class TantivyBackend:
result_ids = cast("list[int]", searcher.fast_field_values("id", result_addrs))
addr_by_id: dict[int, tuple[float, tantivy.DocAddress]] = {
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
+27 -19
View File
@@ -203,7 +203,7 @@ class MatchingModelSerializer(serializers.ModelSerializer[Any]):
logger.debug(f"Invalid regular expression: {e!s}")
raise serializers.ValidationError(
"Invalid regular expression, see log for details.",
)
) from None
return match
@@ -432,7 +432,7 @@ class OwnedObjectSerializer(
return set()
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)
def get_pks_for_permission_type(model):
@@ -926,7 +926,9 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
try:
value_int = int(data["value"])
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
MinValueValidator(-2147483648)(value_int)
MaxValueValidator(2147483647)(value_int)
@@ -958,7 +960,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInsta
except Exception:
raise serializers.ValidationError(
f"Value must be an id of an element in {select_options}",
)
) from None
elif field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
if not (isinstance(data["value"], list) or data["value"] is None):
raise serializers.ValidationError(
@@ -1149,7 +1151,7 @@ class DocumentSerializer(
def to_representation(self, instance):
doc = super().to_representation(instance)
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:
doc["content"] = doc.get("content")[0:550]
return doc
@@ -1512,7 +1514,7 @@ class SavedViewSerializer(OwnedObjectSerializer):
)
)
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]
ret = super().to_internal_value(normalized_data)
@@ -1816,7 +1818,7 @@ class BulkEditSerializer(
logger.exception(f"Error validating custom fields: {e}")
raise serializers.ValidationError(
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(
isinstance(i, int) for i in ids
):
@@ -1884,7 +1886,7 @@ class BulkEditSerializer(
try:
Tag.objects.get(id=tag_id)
except Tag.DoesNotExist:
raise serializers.ValidationError("Tag does not exist")
raise serializers.ValidationError("Tag does not exist") from None
else:
raise serializers.ValidationError("tag not specified")
@@ -1897,7 +1899,9 @@ class BulkEditSerializer(
try:
DocumentType.objects.get(id=document_type_id)
except DocumentType.DoesNotExist:
raise serializers.ValidationError("Document type does not exist")
raise serializers.ValidationError(
"Document type does not exist",
) from None
else:
raise serializers.ValidationError("document_type not specified")
@@ -1909,7 +1913,9 @@ class BulkEditSerializer(
try:
Correspondent.objects.get(id=correspondent_id)
except Correspondent.DoesNotExist:
raise serializers.ValidationError("Correspondent does not exist")
raise serializers.ValidationError(
"Correspondent does not exist",
) from None
else:
raise serializers.ValidationError("correspondent not specified")
@@ -1923,7 +1929,7 @@ class BulkEditSerializer(
except StoragePath.DoesNotExist:
raise serializers.ValidationError(
"Storage path does not exist",
)
) from None
else:
raise serializers.ValidationError("storage path not specified")
@@ -1978,7 +1984,7 @@ class BulkEditSerializer(
):
raise serializers.ValidationError("invalid rotation degrees")
except ValueError:
raise serializers.ValidationError("invalid rotation degrees")
raise serializers.ValidationError("invalid rotation degrees") from None
def _validate_source_mode(self, parameters) -> None:
source_mode = parameters.get(
@@ -2008,7 +2014,7 @@ class BulkEditSerializer(
pages.append([int(doc)])
parameters["pages"] = pages
except ValueError:
raise serializers.ValidationError("invalid pages specified")
raise serializers.ValidationError("invalid pages specified") from None
if "delete_originals" in parameters:
if not isinstance(parameters["delete_originals"], bool):
@@ -2278,14 +2284,14 @@ class PostDocumentSerializer(serializers.Serializer[dict[str, Any]]):
raise serializers.ValidationError(
_("Custom field id must be an integer: %(id)s")
% {"id": field_id},
)
) from None
try:
field = CustomField.objects.get(id=field_id_int)
except CustomField.DoesNotExist:
raise serializers.ValidationError(
_("Custom field with id %(id)s does not exist")
% {"id": field_id_int},
)
) from None
custom_field_serializer.validate(
{
"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.",
),
)
) from None
if CustomField.objects.filter(id__in=ids).count() != len(set(ids)):
raise serializers.ValidationError(
_("Some custom fields don't exist or were specified twice."),
@@ -2413,7 +2419,9 @@ class EmailSerializer(DocumentListSerializer):
for address in address_list:
email_validator(address)
except ValidationError:
raise serializers.ValidationError(f"Invalid email address: {address}")
raise serializers.ValidationError(
f"Invalid email address: {address}",
) from None
return ",".join(address_list)
@@ -2859,7 +2867,7 @@ class ShareLinkBundleSerializer(OwnedObjectSerializer):
return share_link_bundle
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):
@@ -3207,7 +3215,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
except (ValueError, KeyError) as e:
raise serializers.ValidationError(
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
)
) from None
if (
"type" in attrs
+12 -2
View File
@@ -636,7 +636,7 @@ def update_filename_and_move_files(
# so this is not the end of the world.
# B: if moving the original file failed, nothing has changed
# anyway.
pass
logger.debug("Unable to restore previous file locations", exc_info=True)
# restore old values on the instance
instance.filename = old_filename
@@ -1264,7 +1264,17 @@ def task_failure_handler(
"error_message": str(exception) if exception else "Unknown error",
}
if traceback:
tb_str = "".join(_tb.format_tb(traceback))
# billiard/celery pass a pre-formatted string instead of a real
# traceback object when the worker process itself died (e.g.
# WorkerLostError from a SIGILL) since there's no live traceback
# to walk in that case.
tb_str = (
traceback
if isinstance(traceback, str)
else "".join(
_tb.format_tb(traceback),
)
)
result_data["traceback"] = tb_str[:5000]
now = timezone.now()
@@ -29,9 +29,7 @@ class SimpleCommand(PaperlessCommand):
def handle(self, *args, **options):
items = list(range(5))
results = []
for item in self.track(items, description="Processing..."):
results.append(item * 2)
results = [item * 2 for item in self.track(items, description="Processing...")]
self.stdout.write(f"Results: {results}")
@@ -57,13 +55,13 @@ class MultiprocessCommand(PaperlessCommand):
def handle(self, *args, **options):
items = list(range(5))
results = []
for result in self.process_parallel(
_double_value,
items,
description="Processing...",
):
results.append(result)
results = list(
self.process_parallel(
_double_value,
items,
description="Processing...",
),
)
successes = sum(1 for r in results if r.success)
self.stdout.write(f"Successes: {successes}")
@@ -844,6 +844,23 @@ class TestFieldHandling:
f"Expected 1, got {len(ids)}. Note content should be searchable via notes.note: prefix."
)
def test_notes_without_user_are_indexed(self, backend: TantivyBackend) -> None:
"""Notes whose user was deleted (SET_NULL) must not break indexing."""
doc = Document.objects.create(
title="Doc with orphaned note",
content="test",
checksum="NT2",
pk=81,
)
Note.objects.create(document=doc, note="Orphaned note", user=None)
backend.add_or_update(doc)
ids = backend.search_ids("notes.note:orphaned", user=None)
assert len(ids) == 1, (
f"Expected 1, got {len(ids)}. Notes without a user should still be indexed."
)
class TestHighlightHits:
"""Test highlight_hits returns proper HTML strings, not raw Snippet objects."""
+5 -3
View File
@@ -341,9 +341,11 @@ class TestTranslateQuery:
("tag:foo,type:bar", "tag:foo AND document_type:bar"),
(
"created:[2020 TO 2021],added:[2022 TO 2023]",
"created:[2020-01-01T00:00:00Z TO 2022-01-01T00:00:00Z]"
" AND "
"added:[2022-01-01T00:00:00Z TO 2024-01-01T00:00:00Z]",
(
"created:[2020-01-01T00:00:00Z TO 2022-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:foo,bar", "correspondent:foo,bar"),
@@ -6,7 +6,6 @@ import zipfile
from django.contrib.auth.models import User
from django.test import override_settings
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
@@ -33,21 +32,21 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
filename="docA.pdf",
mime_type="application/pdf",
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(
title="document A",
filename="docA2.pdf",
mime_type="application/pdf",
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(
title="document B",
filename="docB.jpg",
mime_type="image/jpeg",
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_checksum="D",
)
+2 -2
View File
@@ -957,8 +957,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
for correspondent in response.data[field]:
self.assertEqual(correspondent["document_count"], 0)
self.assertCountEqual(
map(lambda c: c["id"], response.data[field]),
map(lambda c: c["id"], Entity.objects.values("id")),
(c["id"] for c in response.data[field]),
(c["id"] for c in Entity.objects.values("id")),
)
def test_api_selection_data(self) -> None:
@@ -1,5 +1,5 @@
import datetime
import json
from datetime import date
from unittest import mock
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(
f"/api/documents/{doc.id}/",
@@ -618,7 +618,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
data_type=CustomField.FieldDataType.DATE,
)
date_value = date.today()
date_value = datetime.datetime.now(tz=datetime.UTC).date()
resp = self.client.patch(
f"/api/documents/{doc.id}/",
+1 -1
View File
@@ -265,7 +265,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
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(
f"/api/documents/{doc.pk}/",
{"created": created_datetime},
+2 -2
View File
@@ -18,8 +18,8 @@ class MockOpenIDProvider:
def get_brands(self):
default_servers = [
dict(id="yahoo", name="Yahoo", openid_url="http://me.yahoo.com"),
dict(id="hyves", name="Hyves", openid_url="http://hyves.nl"),
{"id": "yahoo", "name": "Yahoo", "openid_url": "http://me.yahoo.com"},
{"id": "hyves", "name": "Hyves", "openid_url": "http://hyves.nl"},
]
return default_servers
+30 -14
View File
@@ -700,7 +700,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
pk=3,
checksum="C",
# 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
# Django converts dates to UTC
@@ -1022,25 +1022,25 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
title="invoice",
content="the thing i bought at a shop and paid with bank account",
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(
title="bank statement 1",
content="things i paid for in august",
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(
title="bank statement 3",
content="things i paid for in september",
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(
title="Quarterly Report",
content="quarterly revenue profit margin earnings growth",
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.add_or_update(d1)
@@ -1159,7 +1159,7 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
d4.tags.add(t2)
d5 = Document.objects.create(
checksum="5",
added=timezone.make_aware(datetime.datetime(2020, 7, 13)),
added=datetime.datetime(2020, 7, 13, tzinfo=datetime.UTC),
content="test",
original_filename="doc5.pdf",
)
@@ -1269,14 +1269,18 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
d4.id,
search_query(
"&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(
d4.id,
search_query(
"&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,
search_query(
"&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(
d4.id,
search_query(
"&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,
search_query(
"&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(
d5.id,
search_query(
"&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,
search_query(
"&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,
search_query(
"&added__date__gt="
+ datetime.datetime(2020, 1, 2).strftime("%Y-%m-%d"),
+ datetime.datetime(2020, 1, 2, tzinfo=datetime.UTC).strftime(
"%Y-%m-%d",
),
),
)
+4 -3
View File
@@ -777,7 +777,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
sig.set.return_value.apply_async.side_effect = Exception("boom")
mock_consume_file.return_value = sig
with self.assertRaises(Exception):
with self.assertRaisesRegex(Exception, "boom"):
bulk_edit.merge(doc_ids, delete_originals=True)
self.doc1.refresh_from_db()
@@ -1060,6 +1060,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
for call, expected_id in zip(
mock_consume_delay.call_args_list,
doc_ids,
strict=False,
):
task_kwargs = call.kwargs["kwargs"]
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")
mock_chord.return_value = sig
with self.assertRaises(Exception):
with self.assertRaisesRegex(Exception, "boom"):
bulk_edit.edit_pdf(doc_ids, operations, delete_original=True)
self.doc2.refresh_from_db()
@@ -1430,7 +1431,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
{"page": 9999}, # invalid page, forces error during PDF load
]
with self.assertLogs("paperless.bulk_edit", level="ERROR"):
with self.assertRaises(Exception):
with self.assertRaises(ValueError):
bulk_edit.edit_pdf(doc_ids, operations)
mock_group.assert_not_called()
mock_consume_file.assert_not_called()
+2 -2
View File
@@ -782,8 +782,8 @@ class TestClassifier(DirectoriesMixin, TestCase):
load_classifier(raise_exception=True)
Path(settings.MODEL_FILE).touch()
mock_load.side_effect = Exception()
with self.assertRaises(Exception):
mock_load.side_effect = RuntimeError()
with self.assertRaises(RuntimeError):
load_classifier(raise_exception=True)
+4 -4
View File
@@ -59,7 +59,7 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
def create_staging_file(self, src="double-sided-odd.pdf", datetime=None) -> None:
shutil.copy(self.SAMPLE_DIR / src, self.staging_file)
if datetime is None:
datetime = dt.datetime.now()
datetime = dt.datetime.now(tz=dt.UTC)
os.utime(str(self.staging_file), (datetime.timestamp(),) * 2)
def test_odd_numbered_moved_to_staging(self) -> None:
@@ -79,8 +79,8 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertIsFile(self.staging_file)
self.assertAlmostEqual(
dt.datetime.fromtimestamp(self.staging_file.stat().st_mtime),
dt.datetime.now(),
dt.datetime.fromtimestamp(self.staging_file.stat().st_mtime, tz=dt.UTC),
dt.datetime.now(tz=dt.UTC),
delta=dt.timedelta(seconds=5),
)
self.assertIn("Received odd numbered pages", msg["reason"])
@@ -124,7 +124,7 @@ class TestDoubleSided(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
"""
self.create_staging_file(
datetime=dt.datetime.now()
datetime=dt.datetime.now(tz=dt.UTC)
- dt.timedelta(minutes=TIMEOUT_MINUTES, seconds=1),
)
msg = self.consume_file("double-sided-odd.pdf")
+20 -14
View File
@@ -12,7 +12,6 @@ from django.contrib.auth.models import User
from django.db import DatabaseError
from django.test import TestCase
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 delete_empty_directories
@@ -452,7 +451,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
FILENAME_FORMAT="{created_year}-{created_month}-{created_day}",
)
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(
title="doc1",
mime_type="application/pdf",
@@ -469,7 +468,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
FILENAME_FORMAT="{added_year}-{added_month}-{added_day}",
)
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(
title="doc1",
mime_type="application/pdf",
@@ -482,7 +481,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
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"))
@@ -1266,7 +1265,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
def test_short_names_added(self) -> None:
doc = Document.objects.create(
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",
pk=2,
checksum="2",
@@ -1505,7 +1504,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create(
title="Does Matter",
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",
pk=2,
checksum="2",
@@ -1577,7 +1576,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc = Document.objects.create(
title="scan_017562",
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",
checksum="test-checksum",
storage_path=sp,
@@ -1606,7 +1605,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create(
title="Does Matter",
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",
pk=2,
checksum="2",
@@ -1641,7 +1640,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create(
title="Does Matter",
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",
pk=2,
checksum="2",
@@ -1673,7 +1672,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create(
title="Some Title",
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",
pk=2,
checksum="2",
@@ -1778,7 +1777,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc_a = Document.objects.create(
title="Some Title",
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",
pk=2,
checksum="2",
@@ -1792,8 +1791,15 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
CustomFieldInstance.objects.create(
document=doc_a,
field=CustomField.objects.get(name="Invoice Date"),
value_date=timezone.make_aware(
datetime.datetime(2024, 10, 1, 7, 36, 51, 153),
value_date=datetime.datetime(
2024,
10,
1,
7,
36,
51,
153,
tzinfo=datetime.UTC,
),
)
@@ -1833,7 +1839,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase):
doc = Document.objects.create(
title="Some Title! With @ Special # Characters",
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",
pk=2,
checksum="2",
+1 -1
View File
@@ -160,7 +160,7 @@ class TestDateLocalization:
)
def test_localize_date_raises_type_error_for_invalid_input(
self,
invalid_value: None | list[object] | dict[Any, Any] | Literal[1698330605],
invalid_value: list[object] | dict[Any, Any] | Literal[1698330605] | None,
) -> None:
with pytest.raises(TypeError) as excinfo:
localize_date(invalid_value, "medium", "en_US")
+1 -1
View File
@@ -287,7 +287,7 @@ class TestViews(DirectoriesMixin, TestCase):
"change": {"users": [], "groups": []},
}
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:
"""
+8 -1
View File
@@ -2799,7 +2799,14 @@ class TestWorkflows(
doc = Document.objects.create(
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:
"""
+3 -2
View File
@@ -129,11 +129,12 @@ def util_call_with_backoff(
status_codes.append(cause_exec.response.status_code)
warnings.warn(
f"HTTP Exception for {cause_exec.request.url} - {cause_exec}",
stacklevel=2,
)
else:
warnings.warn(f"Unexpected error: {e}")
warnings.warn(f"Unexpected error: {e}", stacklevel=2)
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
+7 -5
View File
@@ -21,20 +21,22 @@ def uri_validator(value: str, allowed_schemes: set[str] | None = None) -> None:
parts = urlparse(value)
if not parts.scheme:
raise ValidationError(
_(f"Unable to parse URI {value}, missing scheme"),
_("Unable to parse URI %(value)s, missing scheme") % {"value": value},
params={"value": value},
)
elif not parts.netloc and not parts.path:
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},
)
if allowed_schemes and parts.scheme not in allowed_schemes:
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},
)
@@ -42,7 +44,7 @@ def uri_validator(value: str, allowed_schemes: set[str] | None = None) -> None:
raise
except Exception as e:
raise ValidationError(
_(f"Unable to parse URI {value}"),
_("Unable to parse URI %(value)s") % {"value": value},
params={"value": value},
) from e
+40 -43
View File
@@ -7,11 +7,11 @@ import tempfile
import zipfile
from collections import defaultdict
from collections import deque
from datetime import UTC
from datetime import datetime
from datetime import timedelta
from http import HTTPStatus
from pathlib import Path
from time import mktime
from time import sleep
from typing import TYPE_CHECKING
from typing import Any
@@ -61,7 +61,6 @@ from django.http import StreamingHttpResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
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 gettext_lazy as _
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,
)
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(
user,
@@ -1146,7 +1145,7 @@ class DocumentViewSet(
"root_document",
).get(pk=pk)
except Document.DoesNotExist:
raise Http404
raise Http404 from None
root_doc = get_root_document(doc)
if request.user is not None and not has_perms_owner_aware(
@@ -1309,7 +1308,7 @@ class DocumentViewSet(
"root_document",
).get(id=pk)
except Document.DoesNotExist:
raise Http404
raise Http404 from None
root_doc = get_root_document(
request_doc,
@@ -1425,7 +1424,7 @@ class DocumentViewSet(
try:
lang = detect(doc.content)
except Exception:
pass
logger.debug("Unable to detect language for document %s", doc.pk)
meta["lang"] = lang
return Response(meta)
@@ -1551,7 +1550,6 @@ class DocumentViewSet(
"document %s: %s",
doc.pk,
exc,
exc_info=True,
)
raise ValidationError({"ai": [_("Invalid AI configuration.")]}) from exc
except LLMTimeoutError as exc:
@@ -1559,7 +1557,6 @@ class DocumentViewSet(
"AI backend timed out while generating suggestions for document %s: %s",
doc.pk,
exc,
exc_info=True,
)
return Response(
{"ai": [_("AI backend request timed out.")]},
@@ -1636,7 +1633,7 @@ class DocumentViewSet(
disposition="inline",
)
except FileNotFoundError:
raise Http404
raise Http404 from None
@action(methods=["get"], detail=True, filter_backends=[])
@method_decorator(cache_control(no_cache=True))
@@ -1661,14 +1658,14 @@ class DocumentViewSet(
return FileResponse(handle, content_type="image/webp")
except FileNotFoundError:
raise Http404
raise Http404 from None
@action(methods=["get"], detail=True)
def download(self, request, pk=None):
try:
return self.file_response(pk, request, "attachment")
except (FileNotFoundError, Document.DoesNotExist):
raise Http404
raise Http404 from None
@action(
methods=["get", "post", "delete"],
@@ -1693,7 +1690,7 @@ class DocumentViewSet(
):
return HttpResponseForbidden("Insufficient permissions to view notes")
except Document.DoesNotExist:
raise Http404
raise Http404 from None
serializer = self.get_serializer(doc)
@@ -1764,7 +1761,7 @@ class DocumentViewSet(
try:
note_id_int = int(note_id)
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)
if settings.AUDIT_LOG_ENABLED:
LogEntry.objects.log_create(
@@ -1808,7 +1805,7 @@ class DocumentViewSet(
"Insufficient permissions to add share link",
)
except Document.DoesNotExist:
raise Http404
raise Http404 from None
if request.method == "GET":
now = timezone.now()
@@ -1836,7 +1833,7 @@ class DocumentViewSet(
"Insufficient permissions",
)
except Document.DoesNotExist: # pragma: no cover
raise Http404
raise Http404 from None
# documents
entries = [
@@ -1857,28 +1854,28 @@ class DocumentViewSet(
]
# custom fields
for entry in LogEntry.objects.get_for_objects(
doc.custom_fields.all(),
).select_related("actor"):
entries.append(
{
"id": entry.id,
"timestamp": entry.timestamp,
"action": entry.get_action_display(),
"changes": {
"custom_fields": {
"type": "custom_field",
"field": str(entry.object_repr).split(":")[0].strip(),
"value": str(entry.object_repr).split(":")[1].strip(),
},
entries.extend(
{
"id": entry.id,
"timestamp": entry.timestamp,
"action": entry.get_action_display(),
"changes": {
"custom_fields": {
"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))
@@ -1989,13 +1986,13 @@ class DocumentViewSet(
):
return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist:
raise Http404
raise Http404 from None
try:
doc_name, doc_data = serializer.validated_data.get("document")
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)
@@ -2040,7 +2037,7 @@ class DocumentViewSet(
"root_document",
).get(pk=pk)
except Document.DoesNotExist:
raise Http404
raise Http404 from None
return get_root_document(root_doc)
def _get_version_doc_for_root(self, root_doc: Document, version_id) -> Document:
@@ -2049,7 +2046,7 @@ class DocumentViewSet(
pk=version_id,
)
except Document.DoesNotExist:
raise Http404
raise Http404 from None
if (
version_doc.id != root_doc.id
@@ -2623,7 +2620,7 @@ class LogViewSet(ViewSet):
try:
limit = int(limit_param)
except (TypeError, ValueError):
raise ValidationError({"limit": "Must be a positive integer"})
raise ValidationError({"limit": "Must be a positive integer"}) from None
if limit < 1:
raise ValidationError({"limit": "Must be a positive integer"})
else:
@@ -3220,7 +3217,7 @@ class PostDocumentView(GenericAPIView[Any]):
cf = serializer.validated_data.get("custom_fields")
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)
@@ -4032,7 +4029,7 @@ class UiSettingsView(GenericAPIView[Any]):
user_resp["last_name"] = user.last_name
# 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(
{
"user": user_resp,
@@ -5122,7 +5119,7 @@ class SystemStatusView(PassUserMixin):
index_dir = settings.INDEX_DIR
mtimes = [p.stat().st_mtime for p in index_dir.iterdir() if p.is_file()]
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:
index_status = "ERROR"
+6 -6
View File
@@ -9,11 +9,11 @@ from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
django_asgi_app = get_asgi_application()
from channels.auth import AuthMiddlewareStack # noqa: E402
from channels.routing import ProtocolTypeRouter # noqa: E402
from channels.routing import URLRouter # noqa: E402
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter
from channels.routing import URLRouter
from paperless.urls import websocket_urlpatterns # noqa: E402
from paperless.urls import websocket_urlpatterns
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.info(f"[init] Paperless-ngx version: v{__full_version_str__}")
+1 -1
View File
@@ -17,7 +17,7 @@ class AutoLoginMiddleware(MiddlewareMixin):
def process_request(self, request: HttpRequest) -> None:
# Dont use auto-login with token request
if request.path.startswith("/api/token/") and request.method == "POST":
return None
return
try:
request.user = User.objects.get(username=settings.AUTO_LOGIN_USERNAME)
auth.login(
+16 -14
View File
@@ -84,10 +84,11 @@ def binaries_check(app_configs: Any, **kwargs: Any) -> list[Error]:
binaries = (settings.CONVERT_BINARY, "tesseract", "gs")
check_messages = []
for binary in binaries:
if shutil.which(binary) is None:
check_messages.append(Warning(error.format(binary), hint))
check_messages = [
Warning(error.format(binary), hint)
for binary in binaries
if shutil.which(binary) is None
]
return check_messages
@@ -241,7 +242,7 @@ def check_v3_minimum_upgrade_version(
return []
logger = logging.getLogger(__name__)
last_applied = sorted(applied)[-1] if applied else "(none)"
last_applied = max(applied) if applied else "(none)"
logger.error(
"V3 upgrade check failed: last applied documents migration is %r. "
"Expected '1075_workflowaction_order' (v2.20.15). "
@@ -355,6 +356,7 @@ def get_tesseract_langs():
proc = subprocess.run(
[shutil.which("tesseract"), "--list-langs"],
capture_output=True,
check=False,
)
# 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("+")]
for lang in specified_langs:
if lang not in installed_langs:
errs.append(
Error(
f"The selected ocr language {lang} is "
f"not installed. Paperless cannot OCR your documents "
f"without it. Please fix PAPERLESS_OCR_LANGUAGE.",
),
)
errs.extend(
Error(
f"The selected ocr language {lang} is "
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
+8 -9
View File
@@ -272,10 +272,7 @@ class MailDocumentParser:
logger.debug("Building formatted text from email")
self._text = build_formatted_text(mail)
if is_naive(mail.date):
self._date = make_aware(mail.date)
else:
self._date = mail.date
self._date = mail.date
logger.debug("Creating a PDF from the email")
if self._mailrule_id:
@@ -502,6 +499,9 @@ class MailDocumentParser:
f"Could not parse {filepath}: {err}",
) from err
if is_naive(parsed.date):
parsed.date = make_aware(parsed.date)
return parsed
def tika_parse(self, html: str) -> str:
@@ -658,11 +658,10 @@ class MailDocumentParser:
if data["bcc"]:
data["bcc_label"] = "BCC"
att = []
for a in mail.attachments:
att.append(
f"{a.filename} ({naturalsize(a.size, binary=True, format='%.2f')})",
)
att = [
f"{a.filename} ({naturalsize(a.size, binary=True, format='%.2f')})"
for a in mail.attachments
]
data["attachments"] = clean_html(", ".join(att))
if data["attachments"]:
data["attachments_label"] = "Attachments"
+11 -9
View File
@@ -294,7 +294,7 @@ if _CHANNELS_BACKEND.startswith("channels_redis."):
###############################################################################
EMAIL_HOST: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST", "localhost")
EMAIL_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_PASSWORD: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_PASSWORD", "")
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",
)
SESSION_EXPIRE_AT_BROWSER_CLOSE = not ACCOUNT_SESSION_REMEMBER
SESSION_COOKIE_AGE = int(
os.getenv("PAPERLESS_SESSION_COOKIE_AGE", 60 * 60 * 24 * 7 * 3),
SESSION_COOKIE_AGE = get_int_from_env(
"PAPERLESS_SESSION_COOKIE_AGE",
60 * 60 * 24 * 7 * 3,
)
# https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-SESSION_ENGINE
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
@@ -389,7 +390,6 @@ if AUTO_LOGIN_USERNAME:
def _parse_remote_user_settings() -> str:
global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK
enable = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER")
enable_api = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API")
if enable or enable_api:
@@ -448,7 +448,6 @@ if ALLOWED_HOSTS != ["*"]:
def _parse_paperless_url():
global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS
url = os.getenv("PAPERLESS_URL")
if url:
CSRF_TRUSTED_ORIGINS.append(url)
@@ -597,8 +596,8 @@ USE_TZ = True
LOGGING_DIR.mkdir(parents=True, exist_ok=True)
LOGROTATE_MAX_SIZE = os.getenv("PAPERLESS_LOGROTATE_MAX_SIZE", 1024 * 1024)
LOGROTATE_MAX_BACKUPS = os.getenv("PAPERLESS_LOGROTATE_MAX_BACKUPS", 20)
LOGROTATE_MAX_SIZE = get_int_from_env("PAPERLESS_LOGROTATE_MAX_SIZE", 1024 * 1024)
LOGROTATE_MAX_BACKUPS = get_int_from_env("PAPERLESS_LOGROTATE_MAX_BACKUPS", 20)
LOGGING = {
"version": 1,
@@ -794,9 +793,12 @@ IGNORABLE_FILES: Final[list[str]] = [
"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")
+1 -1
View File
@@ -334,7 +334,7 @@ def parse_dateparser_languages(languages: str | None) -> list[str]:
language_list = languages.split("+") if languages else []
# There is an unfixed issue in zh-Hant and zh-Hans locales in the dateparser lib.
# 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:
logger.warning(
f"Chinese locale detected: {language}. dateparser might fail to parse"
@@ -145,6 +145,41 @@ class TestEmailFileParsing:
assert parsed_msg.text == "This is just a simple Text Mail.\n"
assert parsed_msg.to == ("some@one.de",)
def test_parse_file_to_message_makes_naive_date_aware(
self,
mocker: MockerFixture,
mail_parser: MailDocumentParser,
simple_txt_email_file: Path,
) -> None:
"""
GIVEN:
- An email whose parsed date is naive (no tzinfo)
WHEN:
- The .eml file is parsed into a MailMessage
THEN:
- The resulting message date is made timezone-aware
"""
mock_message = mocker.Mock(
from_values="mail@someserver.de",
date=datetime.datetime(2022, 10, 12, 21, 40, 43), # noqa: DTZ001
)
mocker.patch(
"paperless.parsers.mail.MailMessage.from_bytes",
return_value=mock_message,
)
parsed_msg = mail_parser.parse_file_to_message(simple_txt_email_file)
assert timezone.is_aware(parsed_msg.date)
assert parsed_msg.date.replace(tzinfo=None) == datetime.datetime( # noqa: DTZ001
2022,
10,
12,
21,
40,
43,
)
class TestEmailMetadataExtraction:
"""
@@ -114,17 +114,17 @@ def test_cache_hit_when_enabled() -> None:
assert settings.CACHALOT_TIMEOUT == 1
# 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_db_cache()
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)
# Doing the same request again should hit the cache, not the DB
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)
# 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
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)
# 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
for _ in range(3):
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)
# Invalidate the cache at the end of test
+1 -1
View File
@@ -193,7 +193,7 @@ def reject_dangerous_svg(file: UploadedFile) -> None:
tree = etree.parse(file, parser)
root = tree.getroot()
except etree.XMLSyntaxError:
raise ValidationError("Invalid SVG file.")
raise ValidationError("Invalid SVG file.") from None
for element in root.iter():
tag: str = etree.QName(element.tag).localname.lower()
+2 -2
View File
@@ -15,9 +15,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
application = get_wsgi_application()
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.info(f"[init] Paperless-ngx version: v{__full_version_str__}")
+4 -2
View File
@@ -129,8 +129,10 @@ def build_llm_index_text(doc: Document) -> str:
f"Notes: {','.join([str(c.note) for c in Note.objects.filter(document=doc)])}",
]
for instance in doc.custom_fields.all():
lines.append(f"Custom Field - {instance.field.name}: {instance}")
lines.extend(
f"Custom Field - {instance.field.name}: {instance}"
for instance in doc.custom_fields.all()
)
lines.append("\nContent:\n")
lines.append(doc.content or "")
+1 -1
View File
@@ -156,7 +156,7 @@ def test_get_ai_document_classification_failure(mock_run_llm_query, mock_documen
mock_run_llm_query.side_effect = Exception("LLM query failed")
# assert raises an exception
with pytest.raises(Exception):
with pytest.raises(ValueError, match="Unsupported LLM backend"):
get_ai_document_classification(mock_document)
@@ -21,5 +21,6 @@ class TestLazyAiImports:
capture_output=True,
text=True,
cwd=_SRC_DIR,
check=False,
)
assert result.returncode == 0, result.stdout + result.stderr
+2 -1
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from types import TracebackType
from typing import Any
from typing import Literal
from typing import Self
import sqlite_vec
from llama_index.core.bridge.pydantic import PrivateAttr
@@ -199,7 +200,7 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
"""Close the underlying SQLite connection (idempotent)."""
self._conn.close()
def __enter__(self) -> "PaperlessSqliteVecVectorStore":
def __enter__(self) -> Self:
return self
def __exit__(
+3 -4
View File
@@ -5,7 +5,6 @@ import ssl
import tempfile
import traceback
import unicodedata
from datetime import date
from datetime import timedelta
from fnmatch import fnmatch
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.
"""
maximum_age = date.today() - timedelta(days=rule.maximum_age)
maximum_age = timezone.now().date() - timedelta(days=rule.maximum_age)
criterias = {}
if rule.maximum_age > 0:
criterias["date_gte"] = maximum_age
@@ -663,8 +662,8 @@ class MailAccountHandler(LoggingMixin):
self.log.info(f"Located folder: {folder_info.name}")
except Exception as e:
self.log.error(
"Exception during folder listing, unable to provide list folders: "
+ str(e),
"Exception during folder listing, unable to provide list folders: %s",
e,
)
raise MailError(
+6 -4
View File
@@ -353,9 +353,10 @@ class MailMocker(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
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,
expected_call_args,
strict=False,
):
consume_tasks = mock_kwargs["consume_tasks"]
@@ -365,6 +366,7 @@ class MailMocker(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
for consume_task, expected_signature in zip(
consume_tasks,
expected_signatures,
strict=False,
):
input_doc = consume_task.kwargs["input_doc"]
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.
"""
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"]
rule = kwargs["rule"]
apply_mail_action([], rule.pk, message.uid, message.subject, message.date)
@@ -406,7 +408,7 @@ def assert_eventually_equals(
deadline = time.time() + timeout
while time.time() < deadline:
if getter_fn() == expected_value:
return None
return
time.sleep(interval)
actual = getter_fn()
raise AssertionError(f"Expected {expected_value}, but got {actual}")
@@ -1517,7 +1519,7 @@ class TestMail(
if message.from_ == "amazon@amazon.de":
raise ValueError("Does not compute.")
else:
return None
return
m.side_effect = get_correspondent_fake
@@ -184,7 +184,12 @@ class TestMailMessageGpgDecryptor(TestMail):
EMAIL_GNUPG_HOME=empty_gpg_home,
):
message_decryptor = MailMessageDecryptor()
self.assertRaises(Exception, message_decryptor.run, encrypted_message)
self.assertRaisesRegex(
Exception,
"Decryption failed",
message_decryptor.run,
encrypted_message,
)
finally:
# Clean up the temporary GPG home used only by this test
try:
+1 -2
View File
@@ -1,4 +1,3 @@
import datetime
import logging
from datetime import timedelta
from http import HTTPStatus
@@ -86,7 +85,7 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
@action(methods=["post"], detail=False)
def test(self, request):
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.is_valid(raise_exception=True)
existing_account = None