mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-12 11:08:51 +00:00
Compare commits
5 Commits
feature-di
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57e6099b82 | ||
|
|
f4e14b821e | ||
|
|
999a61bc9e | ||
|
|
054e56941d | ||
|
|
269113e6f4 |
1
.github/workflows/ci-backend.yml
vendored
1
.github/workflows/ci-backend.yml
vendored
@@ -165,6 +165,7 @@ jobs:
|
||||
contents: read
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
2
.github/workflows/ci-release.yml
vendored
2
.github/workflows/ci-release.yml
vendored
@@ -88,6 +88,7 @@ jobs:
|
||||
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
|
||||
- name: Compile messages
|
||||
env:
|
||||
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
|
||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||
run: |
|
||||
cd src/
|
||||
@@ -96,6 +97,7 @@ jobs:
|
||||
manage.py compilemessages
|
||||
- name: Collect static files
|
||||
env:
|
||||
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
|
||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||
run: |
|
||||
cd src/
|
||||
|
||||
2
.github/workflows/translate-strings.yml
vendored
2
.github/workflows/translate-strings.yml
vendored
@@ -36,6 +36,8 @@ jobs:
|
||||
--group dev \
|
||||
--frozen
|
||||
- name: Generate backend translation strings
|
||||
env:
|
||||
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
|
||||
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -79,6 +79,7 @@ virtualenv
|
||||
/docker-compose.env
|
||||
/docker-compose.yml
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Used for development
|
||||
scripts/import-for-development
|
||||
@@ -111,4 +112,6 @@ celerybeat-schedule*
|
||||
|
||||
# ignore pnpm package store folder created when setting up the devcontainer
|
||||
.pnpm-store/
|
||||
|
||||
# Git worktree local folder
|
||||
.worktrees
|
||||
|
||||
1958
.mypy-baseline.txt
1958
.mypy-baseline.txt
File diff suppressed because it is too large
Load Diff
12316
.pyrefly-baseline.json
12316
.pyrefly-baseline.json
File diff suppressed because one or more lines are too long
@@ -1,9 +1,8 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
import tempfile
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -98,8 +97,6 @@ class StreamingManifestWriter:
|
||||
*,
|
||||
compare_json: bool = False,
|
||||
files_in_export_dir: "set[Path] | None" = None,
|
||||
zip_file: "zipfile.ZipFile | None" = None,
|
||||
zip_arcname: str | None = None,
|
||||
) -> None:
|
||||
self._path = path.resolve()
|
||||
self._tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
|
||||
@@ -107,20 +104,12 @@ class StreamingManifestWriter:
|
||||
self._files_in_export_dir: set[Path] = (
|
||||
files_in_export_dir if files_in_export_dir is not None else set()
|
||||
)
|
||||
self._zip_file = zip_file
|
||||
self._zip_arcname = zip_arcname
|
||||
self._zip_mode = zip_file is not None
|
||||
self._file = None
|
||||
self._first = True
|
||||
|
||||
def open(self) -> None:
|
||||
if self._zip_mode:
|
||||
# zipfile only allows one open write handle at a time, so buffer
|
||||
# the manifest in memory and write it atomically on close()
|
||||
self._file = io.StringIO()
|
||||
else:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._file = self._tmp_path.open("w", encoding="utf-8")
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._file = self._tmp_path.open("w", encoding="utf-8")
|
||||
self._file.write("[")
|
||||
self._first = True
|
||||
|
||||
@@ -141,18 +130,15 @@ class StreamingManifestWriter:
|
||||
if self._file is None:
|
||||
return
|
||||
self._file.write("\n]")
|
||||
if self._zip_mode:
|
||||
self._zip_file.writestr(self._zip_arcname, self._file.getvalue())
|
||||
self._file.close()
|
||||
self._file = None
|
||||
if not self._zip_mode:
|
||||
self._finalize()
|
||||
self._finalize()
|
||||
|
||||
def discard(self) -> None:
|
||||
if self._file is not None:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
if not self._zip_mode and self._tmp_path.exists():
|
||||
if self._tmp_path.exists():
|
||||
self._tmp_path.unlink()
|
||||
|
||||
def _finalize(self) -> None:
|
||||
@@ -329,13 +315,18 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
|
||||
self.files_in_export_dir: set[Path] = set()
|
||||
self.exported_files: set[str] = set()
|
||||
self.zip_file: zipfile.ZipFile | None = None
|
||||
self._zip_dirs: set[str] = set()
|
||||
|
||||
# If zipping, save the original target for later and
|
||||
# get a temporary directory for the target instead
|
||||
temp_dir = None
|
||||
self.original_target = self.target
|
||||
if self.zip_export:
|
||||
zip_name = options["zip_name"]
|
||||
self.zip_path = (self.target / zip_name).with_suffix(".zip")
|
||||
self.zip_tmp_path = self.zip_path.parent / (self.zip_path.name + ".tmp")
|
||||
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir = tempfile.TemporaryDirectory(
|
||||
dir=settings.SCRATCH_DIR,
|
||||
prefix="paperless-export",
|
||||
)
|
||||
self.target = Path(temp_dir.name).resolve()
|
||||
|
||||
if not self.target.exists():
|
||||
raise CommandError("That path doesn't exist")
|
||||
@@ -346,53 +337,30 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
if not os.access(self.target, os.W_OK):
|
||||
raise CommandError("That path doesn't appear to be writable")
|
||||
|
||||
if self.zip_export:
|
||||
if self.compare_checksums:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"--compare-checksums is ignored when --zip is used",
|
||||
),
|
||||
)
|
||||
if self.compare_json:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"--compare-json is ignored when --zip is used",
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
# Prevent any ongoing changes in the documents
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
if self.zip_export:
|
||||
self.zip_file = zipfile.ZipFile(
|
||||
self.zip_tmp_path,
|
||||
"w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
allowZip64=True,
|
||||
)
|
||||
|
||||
self.dump()
|
||||
|
||||
if self.zip_file is not None:
|
||||
self.zip_file.close()
|
||||
self.zip_file = None
|
||||
self.zip_tmp_path.rename(self.zip_path)
|
||||
# We've written everything to the temporary directory in this case,
|
||||
# now make an archive in the original target, with all files stored
|
||||
if self.zip_export and temp_dir is not None:
|
||||
shutil.make_archive(
|
||||
self.original_target / options["zip_name"],
|
||||
format="zip",
|
||||
root_dir=temp_dir.name,
|
||||
)
|
||||
|
||||
finally:
|
||||
# Ensure zip_file is closed and the incomplete .tmp is removed on failure
|
||||
if self.zip_file is not None:
|
||||
self.zip_file.close()
|
||||
self.zip_file = None
|
||||
if self.zip_export and self.zip_tmp_path.exists():
|
||||
self.zip_tmp_path.unlink()
|
||||
# Always cleanup the temporary directory, if one was created
|
||||
if self.zip_export and temp_dir is not None:
|
||||
temp_dir.cleanup()
|
||||
|
||||
def dump(self) -> None:
|
||||
# 1. Take a snapshot of what files exist in the current export folder
|
||||
# (skipped in zip mode — always write fresh, no skip/compare logic applies)
|
||||
if not self.zip_export:
|
||||
for x in self.target.glob("**/*"):
|
||||
if x.is_file():
|
||||
self.files_in_export_dir.add(x.resolve())
|
||||
for x in self.target.glob("**/*"):
|
||||
if x.is_file():
|
||||
self.files_in_export_dir.add(x.resolve())
|
||||
|
||||
# 2. Create manifest, containing all correspondents, types, tags, storage paths
|
||||
# note, documents and ui_settings
|
||||
@@ -453,8 +421,6 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
manifest_path,
|
||||
compare_json=self.compare_json,
|
||||
files_in_export_dir=self.files_in_export_dir,
|
||||
zip_file=self.zip_file,
|
||||
zip_arcname="manifest.json",
|
||||
) as writer:
|
||||
with transaction.atomic():
|
||||
for key, qs in manifest_key_to_object_query.items():
|
||||
@@ -573,12 +539,8 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
self.target,
|
||||
)
|
||||
else:
|
||||
# 5. Remove pre-existing files/dirs from target, keeping the
|
||||
# in-progress zip (.tmp) and any prior zip at the final path
|
||||
skip = {self.zip_path.resolve(), self.zip_tmp_path.resolve()}
|
||||
for item in self.target.glob("*"):
|
||||
if item.resolve() in skip:
|
||||
continue
|
||||
# 5. Remove anything in the original location (before moving the zip)
|
||||
for item in self.original_target.glob("*"):
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
@@ -748,23 +710,9 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
if self.use_folder_prefix:
|
||||
manifest_name = Path("json") / manifest_name
|
||||
manifest_name = (self.target / manifest_name).resolve()
|
||||
if not self.zip_export:
|
||||
manifest_name.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_name.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.check_and_write_json(content, manifest_name)
|
||||
|
||||
def _ensure_zip_dirs(self, arcname: str) -> None:
|
||||
"""Write directory marker entries for all parent directories of arcname.
|
||||
|
||||
Some zip viewers only show folder structure when explicit directory
|
||||
entries exist, so we add them to avoid confusing users.
|
||||
"""
|
||||
parts = Path(arcname).parts[:-1]
|
||||
for i in range(len(parts)):
|
||||
dir_arc = "/".join(parts[: i + 1]) + "/"
|
||||
if dir_arc not in self._zip_dirs:
|
||||
self._zip_dirs.add(dir_arc)
|
||||
self.zip_file.mkdir(dir_arc)
|
||||
|
||||
def check_and_write_json(
|
||||
self,
|
||||
content: list[dict] | dict,
|
||||
@@ -777,38 +725,32 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
This preserves the file timestamps when no changes are made.
|
||||
"""
|
||||
|
||||
if self.zip_export:
|
||||
arcname = str(target.resolve().relative_to(self.target))
|
||||
self._ensure_zip_dirs(arcname)
|
||||
self.zip_file.writestr(
|
||||
arcname,
|
||||
target = target.resolve()
|
||||
perform_write = True
|
||||
if target in self.files_in_export_dir:
|
||||
self.files_in_export_dir.remove(target)
|
||||
if self.compare_json:
|
||||
target_checksum = hashlib.blake2b(target.read_bytes()).hexdigest()
|
||||
src_str = json.dumps(
|
||||
content,
|
||||
cls=DjangoJSONEncoder,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
src_checksum = hashlib.blake2b(src_str.encode("utf-8")).hexdigest()
|
||||
if src_checksum == target_checksum:
|
||||
perform_write = False
|
||||
|
||||
if perform_write:
|
||||
target.write_text(
|
||||
json.dumps(
|
||||
content,
|
||||
cls=DjangoJSONEncoder,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return
|
||||
|
||||
target = target.resolve()
|
||||
json_str = json.dumps(
|
||||
content,
|
||||
cls=DjangoJSONEncoder,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
perform_write = True
|
||||
if target in self.files_in_export_dir:
|
||||
self.files_in_export_dir.remove(target)
|
||||
if self.compare_json:
|
||||
target_checksum = hashlib.blake2b(target.read_bytes()).hexdigest()
|
||||
src_checksum = hashlib.blake2b(json_str.encode("utf-8")).hexdigest()
|
||||
if src_checksum == target_checksum:
|
||||
perform_write = False
|
||||
|
||||
if perform_write:
|
||||
target.write_text(json_str, encoding="utf-8")
|
||||
|
||||
def check_and_copy(
|
||||
self,
|
||||
@@ -821,12 +763,6 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
the source attributes
|
||||
"""
|
||||
|
||||
if self.zip_export:
|
||||
arcname = str(target.resolve().relative_to(self.target))
|
||||
self._ensure_zip_dirs(arcname)
|
||||
self.zip_file.write(source, arcname=arcname)
|
||||
return
|
||||
|
||||
target = target.resolve()
|
||||
if target in self.files_in_export_dir:
|
||||
self.files_in_export_dir.remove(target)
|
||||
|
||||
@@ -100,7 +100,7 @@ logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
# https://www.django-rest-framework.org/api-guide/serializers/#example
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer[Any]):
|
||||
"""
|
||||
A ModelSerializer that takes an additional `fields` argument that
|
||||
controls which fields should be displayed.
|
||||
@@ -121,7 +121,7 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
self.fields.pop(field_name)
|
||||
|
||||
|
||||
class MatchingModelSerializer(serializers.ModelSerializer):
|
||||
class MatchingModelSerializer(serializers.ModelSerializer[Any]):
|
||||
document_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
def get_slug(self, obj) -> str:
|
||||
@@ -261,7 +261,7 @@ class SetPermissionsSerializer(serializers.DictField):
|
||||
|
||||
class OwnedObjectSerializer(
|
||||
SerializerWithPerms,
|
||||
serializers.ModelSerializer,
|
||||
serializers.ModelSerializer[Any],
|
||||
SetPermissionsMixin,
|
||||
):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
@@ -469,7 +469,7 @@ class OwnedObjectSerializer(
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class OwnedObjectListSerializer(serializers.ListSerializer):
|
||||
class OwnedObjectListSerializer(serializers.ListSerializer[Any]):
|
||||
def to_representation(self, documents):
|
||||
self.child.context["shared_object_pks"] = self.child.get_shared_object_pks(
|
||||
documents,
|
||||
@@ -682,27 +682,27 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
class CorrespondentField(serializers.PrimaryKeyRelatedField):
|
||||
class CorrespondentField(serializers.PrimaryKeyRelatedField[Correspondent]):
|
||||
def get_queryset(self):
|
||||
return Correspondent.objects.all()
|
||||
|
||||
|
||||
class TagsField(serializers.PrimaryKeyRelatedField):
|
||||
class TagsField(serializers.PrimaryKeyRelatedField[Tag]):
|
||||
def get_queryset(self):
|
||||
return Tag.objects.all()
|
||||
|
||||
|
||||
class DocumentTypeField(serializers.PrimaryKeyRelatedField):
|
||||
class DocumentTypeField(serializers.PrimaryKeyRelatedField[DocumentType]):
|
||||
def get_queryset(self):
|
||||
return DocumentType.objects.all()
|
||||
|
||||
|
||||
class StoragePathField(serializers.PrimaryKeyRelatedField):
|
||||
class StoragePathField(serializers.PrimaryKeyRelatedField[StoragePath]):
|
||||
def get_queryset(self):
|
||||
return StoragePath.objects.all()
|
||||
|
||||
|
||||
class CustomFieldSerializer(serializers.ModelSerializer):
|
||||
class CustomFieldSerializer(serializers.ModelSerializer[CustomField]):
|
||||
data_type = serializers.ChoiceField(
|
||||
choices=CustomField.FieldDataType,
|
||||
read_only=False,
|
||||
@@ -816,7 +816,7 @@ def validate_documentlink_targets(user, doc_ids):
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
|
||||
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
||||
value = ReadWriteSerializerMethodField(allow_null=True)
|
||||
|
||||
@@ -922,14 +922,14 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class BasicUserSerializer(serializers.ModelSerializer):
|
||||
class BasicUserSerializer(serializers.ModelSerializer[User]):
|
||||
# Different than paperless.serializers.UserSerializer
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "first_name", "last_name"]
|
||||
|
||||
|
||||
class NotesSerializer(serializers.ModelSerializer):
|
||||
class NotesSerializer(serializers.ModelSerializer[Note]):
|
||||
user = BasicUserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -1256,7 +1256,7 @@ class DocumentSerializer(
|
||||
list_serializer_class = OwnedObjectListSerializer
|
||||
|
||||
|
||||
class SearchResultListSerializer(serializers.ListSerializer):
|
||||
class SearchResultListSerializer(serializers.ListSerializer[Document]):
|
||||
def to_representation(self, hits):
|
||||
document_ids = [hit["id"] for hit in hits]
|
||||
# Fetch all Document objects in the list in one SQL query.
|
||||
@@ -1313,7 +1313,7 @@ class SearchResultSerializer(DocumentSerializer):
|
||||
list_serializer_class = SearchResultListSerializer
|
||||
|
||||
|
||||
class SavedViewFilterRuleSerializer(serializers.ModelSerializer):
|
||||
class SavedViewFilterRuleSerializer(serializers.ModelSerializer[SavedViewFilterRule]):
|
||||
class Meta:
|
||||
model = SavedViewFilterRule
|
||||
fields = ["rule_type", "value"]
|
||||
@@ -2401,7 +2401,7 @@ class StoragePathSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class UiSettingsViewSerializer(serializers.ModelSerializer):
|
||||
class UiSettingsViewSerializer(serializers.ModelSerializer[UiSettings]):
|
||||
settings = serializers.DictField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
@@ -2760,7 +2760,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||
return attrs
|
||||
|
||||
|
||||
class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
||||
class WorkflowTriggerSerializer(serializers.ModelSerializer[WorkflowTrigger]):
|
||||
id = serializers.IntegerField(required=False, allow_null=True)
|
||||
sources = fields.MultipleChoiceField(
|
||||
choices=WorkflowTrigger.DocumentSourceChoices.choices,
|
||||
@@ -2870,7 +2870,7 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WorkflowActionEmailSerializer(serializers.ModelSerializer):
|
||||
class WorkflowActionEmailSerializer(serializers.ModelSerializer[WorkflowActionEmail]):
|
||||
id = serializers.IntegerField(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
@@ -2884,7 +2884,9 @@ class WorkflowActionEmailSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class WorkflowActionWebhookSerializer(serializers.ModelSerializer):
|
||||
class WorkflowActionWebhookSerializer(
|
||||
serializers.ModelSerializer[WorkflowActionWebhook],
|
||||
):
|
||||
id = serializers.IntegerField(allow_null=True, required=False)
|
||||
|
||||
def validate_url(self, url):
|
||||
@@ -2905,7 +2907,7 @@ class WorkflowActionWebhookSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class WorkflowActionSerializer(serializers.ModelSerializer):
|
||||
class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
||||
id = serializers.IntegerField(required=False, allow_null=True)
|
||||
assign_correspondent = CorrespondentField(allow_null=True, required=False)
|
||||
assign_tags = TagsField(many=True, allow_null=True, required=False)
|
||||
@@ -3027,7 +3029,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class WorkflowSerializer(serializers.ModelSerializer):
|
||||
class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
||||
order = serializers.IntegerField(required=False)
|
||||
|
||||
triggers = WorkflowTriggerSerializer(many=True)
|
||||
|
||||
@@ -615,7 +615,7 @@ class TestExportImport(
|
||||
self.assertIsFile(expected_file)
|
||||
|
||||
with ZipFile(expected_file) as zip:
|
||||
# 11 files + 3 directory marker entries for the subdirectory structure
|
||||
# Extras are from the directories, which also appear in the listing
|
||||
self.assertEqual(len(zip.namelist()), 14)
|
||||
self.assertIn("manifest.json", zip.namelist())
|
||||
self.assertIn("metadata.json", zip.namelist())
|
||||
@@ -666,57 +666,6 @@ class TestExportImport(
|
||||
self.assertIn("manifest.json", zip.namelist())
|
||||
self.assertIn("metadata.json", zip.namelist())
|
||||
|
||||
def test_export_zip_atomic_on_failure(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Request to export documents to zipfile
|
||||
WHEN:
|
||||
- Export raises an exception mid-way
|
||||
THEN:
|
||||
- No .zip file is written at the final path
|
||||
- The .tmp file is cleaned up
|
||||
"""
|
||||
args = ["document_exporter", self.target, "--zip"]
|
||||
|
||||
with mock.patch.object(
|
||||
document_exporter.Command,
|
||||
"dump",
|
||||
side_effect=RuntimeError("simulated failure"),
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
call_command(*args)
|
||||
|
||||
expected_zip = self.target / f"export-{timezone.localdate().isoformat()}.zip"
|
||||
expected_tmp = (
|
||||
self.target / f"export-{timezone.localdate().isoformat()}.zip.tmp"
|
||||
)
|
||||
self.assertIsNotFile(expected_zip)
|
||||
self.assertIsNotFile(expected_tmp)
|
||||
|
||||
def test_export_zip_no_scratch_dir(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Request to export documents to zipfile
|
||||
WHEN:
|
||||
- Documents are exported
|
||||
THEN:
|
||||
- No files are written under SCRATCH_DIR during the export
|
||||
(the old workaround used a temp dir there)
|
||||
"""
|
||||
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
|
||||
shutil.copytree(
|
||||
Path(__file__).parent / "samples" / "documents",
|
||||
Path(self.dirs.media_dir) / "documents",
|
||||
)
|
||||
|
||||
scratch_before = set(settings.SCRATCH_DIR.glob("paperless-export*"))
|
||||
|
||||
args = ["document_exporter", self.target, "--zip"]
|
||||
call_command(*args)
|
||||
|
||||
scratch_after = set(settings.SCRATCH_DIR.glob("paperless-export*"))
|
||||
self.assertEqual(scratch_before, scratch_after)
|
||||
|
||||
def test_export_target_not_exists(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -291,7 +291,7 @@ class IndexView(TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
class PassUserMixin(GenericAPIView):
|
||||
class PassUserMixin(GenericAPIView[Any]):
|
||||
"""
|
||||
Pass a user object to serializer
|
||||
"""
|
||||
@@ -457,7 +457,10 @@ class PermissionsAwareDocumentCountMixin(BulkPermissionMixin, PassUserMixin):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer))
|
||||
class CorrespondentViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class CorrespondentViewSet(
|
||||
PermissionsAwareDocumentCountMixin,
|
||||
ModelViewSet[Correspondent],
|
||||
):
|
||||
model = Correspondent
|
||||
|
||||
queryset = Correspondent.objects.select_related("owner").order_by(Lower("name"))
|
||||
@@ -494,7 +497,7 @@ class CorrespondentViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(TagSerializer))
|
||||
class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
|
||||
model = Tag
|
||||
serializer_class = TagSerializer
|
||||
document_count_through = Document.tags.through
|
||||
@@ -573,7 +576,10 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
||||
class DocumentTypeViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class DocumentTypeViewSet(
|
||||
PermissionsAwareDocumentCountMixin,
|
||||
ModelViewSet[DocumentType],
|
||||
):
|
||||
model = DocumentType
|
||||
|
||||
queryset = DocumentType.objects.select_related("owner").order_by(Lower("name"))
|
||||
@@ -808,7 +814,7 @@ class DocumentViewSet(
|
||||
UpdateModelMixin,
|
||||
DestroyModelMixin,
|
||||
ListModelMixin,
|
||||
GenericViewSet,
|
||||
GenericViewSet[Document],
|
||||
):
|
||||
model = Document
|
||||
queryset = Document.objects.all()
|
||||
@@ -1952,7 +1958,7 @@ class ChatStreamingSerializer(serializers.Serializer):
|
||||
],
|
||||
name="dispatch",
|
||||
)
|
||||
class ChatStreamingView(GenericAPIView):
|
||||
class ChatStreamingView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = ChatStreamingSerializer
|
||||
|
||||
@@ -2278,7 +2284,7 @@ class LogViewSet(ViewSet):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(SavedViewSerializer))
|
||||
class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet):
|
||||
class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet[SavedView]):
|
||||
model = SavedView
|
||||
|
||||
queryset = SavedView.objects.select_related("owner").prefetch_related(
|
||||
@@ -2756,7 +2762,7 @@ class RemovePasswordDocumentsView(DocumentOperationPermissionMixin):
|
||||
},
|
||||
),
|
||||
)
|
||||
class PostDocumentView(GenericAPIView):
|
||||
class PostDocumentView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = PostDocumentSerializer
|
||||
parser_classes = (parsers.MultiPartParser,)
|
||||
@@ -2877,7 +2883,7 @@ class PostDocumentView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class SelectionDataView(GenericAPIView):
|
||||
class SelectionDataView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = DocumentListSerializer
|
||||
parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
|
||||
@@ -2981,7 +2987,7 @@ class SelectionDataView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class SearchAutoCompleteView(GenericAPIView):
|
||||
class SearchAutoCompleteView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
@@ -3262,7 +3268,7 @@ class GlobalSearchView(PassUserMixin):
|
||||
},
|
||||
),
|
||||
)
|
||||
class StatisticsView(GenericAPIView):
|
||||
class StatisticsView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
@@ -3364,7 +3370,7 @@ class StatisticsView(GenericAPIView):
|
||||
)
|
||||
|
||||
|
||||
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView):
|
||||
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = BulkDownloadSerializer
|
||||
parser_classes = (parsers.JSONParser,)
|
||||
@@ -3417,7 +3423,7 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer))
|
||||
class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[StoragePath]):
|
||||
model = StoragePath
|
||||
|
||||
queryset = StoragePath.objects.select_related("owner").order_by(
|
||||
@@ -3481,7 +3487,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
return Response(result)
|
||||
|
||||
|
||||
class UiSettingsView(GenericAPIView):
|
||||
class UiSettingsView(GenericAPIView[Any]):
|
||||
queryset = UiSettings.objects.all()
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
serializer_class = UiSettingsViewSerializer
|
||||
@@ -3579,7 +3585,7 @@ class UiSettingsView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class RemoteVersionView(GenericAPIView):
|
||||
class RemoteVersionView(GenericAPIView[Any]):
|
||||
cache_key = "remote_version_view_latest_release"
|
||||
|
||||
def get(self, request, format=None):
|
||||
@@ -3656,7 +3662,7 @@ class RemoteVersionView(GenericAPIView):
|
||||
),
|
||||
],
|
||||
)
|
||||
class TasksViewSet(ReadOnlyModelViewSet):
|
||||
class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
serializer_class = TasksViewSerializer
|
||||
filter_backends = (
|
||||
@@ -3730,7 +3736,7 @@ class TasksViewSet(ReadOnlyModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class ShareLinkViewSet(ModelViewSet, PassUserMixin):
|
||||
class ShareLinkViewSet(PassUserMixin, ModelViewSet[ShareLink]):
|
||||
model = ShareLink
|
||||
|
||||
queryset = ShareLink.objects.all()
|
||||
@@ -3747,7 +3753,7 @@ class ShareLinkViewSet(ModelViewSet, PassUserMixin):
|
||||
ordering_fields = ("created", "expiration", "document")
|
||||
|
||||
|
||||
class ShareLinkBundleViewSet(ModelViewSet, PassUserMixin):
|
||||
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||
model = ShareLinkBundle
|
||||
|
||||
queryset = ShareLinkBundle.objects.all()
|
||||
@@ -4104,7 +4110,7 @@ class BulkEditObjectsView(PassUserMixin):
|
||||
return Response({"result": "OK"})
|
||||
|
||||
|
||||
class WorkflowTriggerViewSet(ModelViewSet):
|
||||
class WorkflowTriggerViewSet(ModelViewSet[WorkflowTrigger]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = WorkflowTriggerSerializer
|
||||
@@ -4122,7 +4128,7 @@ class WorkflowTriggerViewSet(ModelViewSet):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class WorkflowActionViewSet(ModelViewSet):
|
||||
class WorkflowActionViewSet(ModelViewSet[WorkflowAction]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = WorkflowActionSerializer
|
||||
@@ -4147,7 +4153,7 @@ class WorkflowActionViewSet(ModelViewSet):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class WorkflowViewSet(ModelViewSet):
|
||||
class WorkflowViewSet(ModelViewSet[Workflow]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = WorkflowSerializer
|
||||
@@ -4165,7 +4171,7 @@ class WorkflowViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class CustomFieldViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[CustomField]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = CustomFieldSerializer
|
||||
|
||||
@@ -74,7 +74,7 @@ class PaperlessAuthTokenSerializer(AuthTokenSerializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class UserSerializer(PasswordValidationMixin, serializers.ModelSerializer):
|
||||
class UserSerializer(PasswordValidationMixin, serializers.ModelSerializer[User]):
|
||||
password = ObfuscatedPasswordField(required=False)
|
||||
user_permissions = serializers.SlugRelatedField(
|
||||
many=True,
|
||||
@@ -142,7 +142,7 @@ class UserSerializer(PasswordValidationMixin, serializers.ModelSerializer):
|
||||
return user
|
||||
|
||||
|
||||
class GroupSerializer(serializers.ModelSerializer):
|
||||
class GroupSerializer(serializers.ModelSerializer[Group]):
|
||||
permissions = serializers.SlugRelatedField(
|
||||
many=True,
|
||||
queryset=Permission.objects.exclude(content_type__app_label="admin"),
|
||||
@@ -158,7 +158,7 @@ class GroupSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountSerializer(serializers.ModelSerializer):
|
||||
class SocialAccountSerializer(serializers.ModelSerializer[SocialAccount]):
|
||||
name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@@ -176,7 +176,7 @@ class SocialAccountSerializer(serializers.ModelSerializer):
|
||||
return "Unknown App"
|
||||
|
||||
|
||||
class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer):
|
||||
class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer[User]):
|
||||
email = serializers.EmailField(allow_blank=True, required=False)
|
||||
password = ObfuscatedPasswordField(required=False, allow_null=False)
|
||||
auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")
|
||||
@@ -209,7 +209,9 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ApplicationConfigurationSerializer(serializers.ModelSerializer):
|
||||
class ApplicationConfigurationSerializer(
|
||||
serializers.ModelSerializer[ApplicationConfiguration],
|
||||
):
|
||||
user_args = serializers.JSONField(binary=True, allow_null=True)
|
||||
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
||||
llm_api_key = ObfuscatedPasswordField(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from allauth.mfa import signals
|
||||
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
|
||||
@@ -114,7 +115,7 @@ class FaviconView(View):
|
||||
return HttpResponseNotFound("favicon.ico not found")
|
||||
|
||||
|
||||
class UserViewSet(ModelViewSet):
|
||||
class UserViewSet(ModelViewSet[User]):
|
||||
_BOOL_NOT_PROVIDED = object()
|
||||
model = User
|
||||
|
||||
@@ -216,7 +217,7 @@ class UserViewSet(ModelViewSet):
|
||||
return HttpResponseNotFound("TOTP not found")
|
||||
|
||||
|
||||
class GroupViewSet(ModelViewSet):
|
||||
class GroupViewSet(ModelViewSet[Group]):
|
||||
model = Group
|
||||
|
||||
queryset = Group.objects.order_by(Lower("name"))
|
||||
@@ -229,7 +230,7 @@ class GroupViewSet(ModelViewSet):
|
||||
ordering_fields = ("name",)
|
||||
|
||||
|
||||
class ProfileView(GenericAPIView):
|
||||
class ProfileView(GenericAPIView[Any]):
|
||||
"""
|
||||
User profile view, only available when logged in
|
||||
"""
|
||||
@@ -288,7 +289,7 @@ class ProfileView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class TOTPView(GenericAPIView):
|
||||
class TOTPView(GenericAPIView[Any]):
|
||||
"""
|
||||
TOTP views
|
||||
"""
|
||||
@@ -368,7 +369,7 @@ class TOTPView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class GenerateAuthTokenView(GenericAPIView):
|
||||
class GenerateAuthTokenView(GenericAPIView[Any]):
|
||||
"""
|
||||
Generates (or re-generates) an auth token, requires a logged in user
|
||||
unlike the default DRF endpoint
|
||||
@@ -397,7 +398,7 @@ class GenerateAuthTokenView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class ApplicationConfigurationViewSet(ModelViewSet):
|
||||
class ApplicationConfigurationViewSet(ModelViewSet[ApplicationConfiguration]):
|
||||
model = ApplicationConfiguration
|
||||
|
||||
queryset = ApplicationConfiguration.objects
|
||||
@@ -450,7 +451,7 @@ class ApplicationConfigurationViewSet(ModelViewSet):
|
||||
},
|
||||
),
|
||||
)
|
||||
class DisconnectSocialAccountView(GenericAPIView):
|
||||
class DisconnectSocialAccountView(GenericAPIView[Any]):
|
||||
"""
|
||||
Disconnects a social account provider from the user account
|
||||
"""
|
||||
@@ -476,7 +477,7 @@ class DisconnectSocialAccountView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class SocialAccountProvidersView(GenericAPIView):
|
||||
class SocialAccountProvidersView(GenericAPIView[Any]):
|
||||
"""
|
||||
List of social account providers
|
||||
"""
|
||||
|
||||
@@ -57,7 +57,7 @@ class MailAccountSerializer(OwnedObjectSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class AccountField(serializers.PrimaryKeyRelatedField):
|
||||
class AccountField(serializers.PrimaryKeyRelatedField[MailAccount]):
|
||||
def get_queryset(self):
|
||||
return MailAccount.objects.all().order_by("-id")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.http import HttpResponseForbidden
|
||||
@@ -65,7 +66,7 @@ from paperless_mail.tasks import process_mail_accounts
|
||||
},
|
||||
),
|
||||
)
|
||||
class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
||||
class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
|
||||
model = MailAccount
|
||||
|
||||
queryset = MailAccount.objects.all().order_by("pk")
|
||||
@@ -159,7 +160,7 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
||||
return Response({"result": "OK"})
|
||||
|
||||
|
||||
class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin):
|
||||
class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
serializer_class = ProcessedMailSerializer
|
||||
pagination_class = StandardPagination
|
||||
@@ -187,7 +188,7 @@ class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin):
|
||||
return Response({"result": "OK", "deleted_mail_ids": mail_ids})
|
||||
|
||||
|
||||
class MailRuleViewSet(ModelViewSet, PassUserMixin):
|
||||
class MailRuleViewSet(PassUserMixin, ModelViewSet[MailRule]):
|
||||
model = MailRule
|
||||
|
||||
queryset = MailRule.objects.all().order_by("order")
|
||||
@@ -203,7 +204,7 @@ class MailRuleViewSet(ModelViewSet, PassUserMixin):
|
||||
responses={200: None},
|
||||
),
|
||||
)
|
||||
class OauthCallbackView(GenericAPIView):
|
||||
class OauthCallbackView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
|
||||
Reference in New Issue
Block a user