mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-16 04:57:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
252652b161 | ||
|
|
d4a57204eb | ||
|
|
c50c6f5517 |
No files matched your search
+1
-1
@@ -43,7 +43,7 @@ dependencies = [
|
||||
"drf-spectacular~=0.30",
|
||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.32.0",
|
||||
"filelock>=3.32,<4.1",
|
||||
"flower>=2.0.1,<2.3",
|
||||
"gotenberg-client[httpx]~=1.0",
|
||||
"httpx-oauth~=0.17",
|
||||
|
||||
@@ -130,12 +130,14 @@ class DocumentMetadataOverrides:
|
||||
get_users_with_perms(
|
||||
doc,
|
||||
only_with_perms_in=["view_document"],
|
||||
with_group_users=False,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
overrides.change_users = list(
|
||||
get_users_with_perms(
|
||||
doc,
|
||||
only_with_perms_in=["change_document"],
|
||||
with_group_users=False,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
overrides.custom_fields = {
|
||||
|
||||
@@ -3,6 +3,7 @@ from itertools import islice
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
@@ -70,6 +71,10 @@ from paperless.models import ApplicationConfiguration
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
# Derived names replace the original's extension with these suffixes
|
||||
THUMBNAIL_SUFFIX: Final[str] = "-thumbnail.webp"
|
||||
ARCHIVE_SUFFIX: Final[str] = "-archive.pdf"
|
||||
|
||||
|
||||
def serialize_queryset_batched(
|
||||
queryset: "QuerySet[Any]",
|
||||
@@ -512,7 +517,12 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
counter=filename_counter,
|
||||
)
|
||||
else:
|
||||
base_name = document.get_public_filename(counter=filename_counter)
|
||||
# Leave room for the longer of the derived name suffixes
|
||||
base_name = document.get_public_filename(
|
||||
counter=filename_counter,
|
||||
max_bytes=Document.MAX_PUBLIC_FILENAME_BYTES
|
||||
- max(len(THUMBNAIL_SUFFIX), len(ARCHIVE_SUFFIX)),
|
||||
)
|
||||
|
||||
# The thumbnail and archive names are derived from the stem, so
|
||||
# uniqueness must hold without the original's extension
|
||||
@@ -541,7 +551,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
document_dict[EXPORTER_FILE_NAME] = original_arc
|
||||
|
||||
if not self.no_thumbnail:
|
||||
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
|
||||
thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX)
|
||||
if self.use_folder_prefix:
|
||||
thumbnail_name = Path("thumbnails") / thumbnail_name
|
||||
thumbnail_arc = thumbnail_name.as_posix()
|
||||
@@ -550,7 +560,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
thumbnail_arc = None
|
||||
|
||||
if not self.no_archive and document.has_archive_version:
|
||||
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
|
||||
archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX)
|
||||
if self.use_folder_prefix:
|
||||
archive_name = Path("archive") / archive_name
|
||||
archive_arc = archive_name.as_posix()
|
||||
|
||||
+28
-7
@@ -157,6 +157,8 @@ class StoragePath(MatchingModel):
|
||||
|
||||
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
||||
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
||||
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
|
||||
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
|
||||
|
||||
correspondent = models.ForeignKey(
|
||||
Correspondent,
|
||||
@@ -484,28 +486,47 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
def archive_file(self):
|
||||
return Path(self.archive_path).open("rb")
|
||||
|
||||
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
|
||||
def get_public_filename(
|
||||
self,
|
||||
*,
|
||||
archive=False,
|
||||
counter=0,
|
||||
suffix=None,
|
||||
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
|
||||
) -> str:
|
||||
"""
|
||||
Returns a sanitized filename for the document, not including any paths.
|
||||
|
||||
The name fits in max_bytes (UTF-8). A title that is too long is
|
||||
shortened, so the counter, suffix and extension are always kept.
|
||||
"""
|
||||
# Root owns metadata for all versions
|
||||
context_document = (
|
||||
self.root_document if self.root_document_id is not None else self
|
||||
)
|
||||
result = str(context_document)
|
||||
|
||||
tail = ""
|
||||
if counter:
|
||||
result += f"_{counter:02}"
|
||||
tail += f"_{counter:02}"
|
||||
|
||||
if suffix:
|
||||
result += suffix
|
||||
tail += suffix
|
||||
|
||||
if archive:
|
||||
result += ".pdf"
|
||||
tail += ".pdf"
|
||||
else:
|
||||
result += self.file_type
|
||||
tail += self.file_type
|
||||
|
||||
return pathvalidate.sanitize_filename(result, replacement_text="-")
|
||||
# sanitize_filename truncates from the end, which would cut off the
|
||||
# counter and extension, so shorten the title before appending them
|
||||
head_bytes = max_bytes - len(tail.encode())
|
||||
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
|
||||
|
||||
return pathvalidate.sanitize_filename(
|
||||
head + tail,
|
||||
replacement_text="-",
|
||||
max_len=max_bytes,
|
||||
)
|
||||
|
||||
@property
|
||||
def file_type(self):
|
||||
|
||||
@@ -21,6 +21,7 @@ from guardian.core import ObjectPermissionChecker
|
||||
from guardian.models import GroupObjectPermission
|
||||
from guardian.models import UserObjectPermission
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from guardian.shortcuts import remove_perm
|
||||
from rest_framework.permissions import BasePermission
|
||||
@@ -648,6 +649,41 @@ def annotate_document_count_for_related_queryset(
|
||||
)
|
||||
|
||||
|
||||
def get_objects_for_user_owner_aware(
|
||||
user: User | None,
|
||||
perms: str | list[str],
|
||||
Model: Any,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
) -> QuerySet[Any]:
|
||||
"""
|
||||
Returns objects the user owns, are unowned, or has explicit perms.
|
||||
When include_deleted is True, soft-deleted items are also included.
|
||||
|
||||
Legacy slow path (guardian-backed, O(n) style permission resolution).
|
||||
Most queryset-filtering call sites have migrated onto
|
||||
``PermittedObjectsFilter``/``permitted_object_ids()``, but this function
|
||||
is kept because production callers still remain. Several callers remain
|
||||
across ``documents/``, ``paperless_mail/``, and ``paperless_ai/`` --
|
||||
grep for this function name before removing it.
|
||||
"""
|
||||
manager = (
|
||||
Model.global_objects
|
||||
if include_deleted and hasattr(Model, "global_objects")
|
||||
else Model.objects
|
||||
)
|
||||
|
||||
objects_owned = manager.filter(owner=user)
|
||||
objects_unowned = manager.filter(owner__isnull=True)
|
||||
objects_with_perms = get_objects_for_user(
|
||||
user=user,
|
||||
perms=perms,
|
||||
klass=manager.all(),
|
||||
accept_global_perms=False,
|
||||
)
|
||||
return objects_owned | objects_unowned | objects_with_perms
|
||||
|
||||
|
||||
def has_perms_owner_aware(user, perms, obj):
|
||||
"""
|
||||
Legacy slow path (guardian-backed) single-object permission check.
|
||||
|
||||
@@ -52,7 +52,7 @@ from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowRun
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.utils import compute_checksum
|
||||
@@ -81,11 +81,15 @@ DRF_DATETIME_FIELD = serializers.DateTimeField()
|
||||
|
||||
|
||||
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
|
||||
inbox_tags = restrict_queryset_to_visible(
|
||||
Tag.objects.filter(is_inbox_tag=True),
|
||||
document.owner,
|
||||
"view_tag",
|
||||
)
|
||||
if document.owner is not None:
|
||||
tags = get_objects_for_user_owner_aware(
|
||||
document.owner,
|
||||
"documents.view_tag",
|
||||
Tag,
|
||||
)
|
||||
else:
|
||||
tags = Tag.objects.all()
|
||||
inbox_tags = tags.filter(is_inbox_tag=True)
|
||||
document.add_nested_tags(inbox_tags)
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ from documents.tests.utils import GetConsumerMixin
|
||||
from paperless_mail.models import MailRule
|
||||
from paperless_testing.assertions import FileSystemAssertsMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import TagFactory
|
||||
from paperless_testing.factories import DocumentFactory
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
from paperless_testing.permissions import grant_object
|
||||
@@ -516,65 +516,6 @@ class TestConsumer(
|
||||
self.assertTrue(group_checker.has_perm("view_document", document))
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testInboxTagsScopedToOwner(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Inbox tags that are owned by the document owner, unowned, owned by
|
||||
another user but shared with the document owner, and owned by
|
||||
another user and not shared
|
||||
- A non-inbox tag owned by the document owner
|
||||
WHEN:
|
||||
- A document is consumed with that owner
|
||||
THEN:
|
||||
- The document gets the owned, unowned and shared inbox tags only
|
||||
"""
|
||||
owner = UserFactory()
|
||||
other = UserFactory()
|
||||
owned = TagFactory(is_inbox_tag=True, owner=owner)
|
||||
unowned = TagFactory(is_inbox_tag=True, owner=None)
|
||||
shared = TagFactory(is_inbox_tag=True, owner=other)
|
||||
grant_object(owner, shared, "view_tag")
|
||||
TagFactory(is_inbox_tag=True, owner=other)
|
||||
TagFactory(is_inbox_tag=False, owner=owner)
|
||||
|
||||
with self.get_consumer(
|
||||
self.get_test_file(),
|
||||
DocumentMetadataOverrides(owner_id=owner.pk),
|
||||
) as consumer:
|
||||
consumer.run()
|
||||
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
|
||||
self.assertCountEqual(document.tags.all(), [owned, unowned, shared])
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testInboxTagsUnownedDocument(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Inbox tags owned by two different users, an unowned inbox tag and
|
||||
a non-inbox tag
|
||||
WHEN:
|
||||
- A document is consumed with no owner
|
||||
THEN:
|
||||
- The document gets every inbox tag
|
||||
"""
|
||||
inbox_tags = [
|
||||
TagFactory(is_inbox_tag=True, owner=UserFactory()),
|
||||
TagFactory(is_inbox_tag=True, owner=UserFactory()),
|
||||
TagFactory(is_inbox_tag=True, owner=None),
|
||||
]
|
||||
TagFactory(is_inbox_tag=False)
|
||||
|
||||
with self.get_consumer(self.get_test_file()) as consumer:
|
||||
consumer.run()
|
||||
|
||||
document = Document.objects.first()
|
||||
assert document is not None
|
||||
|
||||
self.assertCountEqual(document.tags.all(), inbox_tags)
|
||||
self._assert_first_last_send_progress()
|
||||
|
||||
def testNotAFile(self) -> None:
|
||||
with self.assertRaisesMessage(ConsumerError, "File not found"):
|
||||
with self.get_consumer(Path("non-existing-file")) as consumer:
|
||||
@@ -1744,6 +1685,32 @@ class TestMetadataOverrides(TestCase):
|
||||
self.assertEqual(base.actor_id, 2)
|
||||
self.assertEqual(base.version_label, "v2")
|
||||
|
||||
def test_from_document_copies_user_and_group_grants_separately(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document with view and change granted directly to one user and
|
||||
to a group with another user as a member
|
||||
WHEN:
|
||||
- Overrides are built from the document
|
||||
THEN:
|
||||
- The user lists hold only the directly granted user
|
||||
- The group lists hold the group
|
||||
"""
|
||||
direct_user = UserFactory()
|
||||
group_member = UserFactory()
|
||||
group = Group.objects.create(name="group")
|
||||
group_member.groups.add(group)
|
||||
document = DocumentFactory()
|
||||
grant_object(direct_user, document, "view_document", "change_document")
|
||||
grant_object(group, document, "view_document", "change_document")
|
||||
|
||||
overrides = DocumentMetadataOverrides.from_document(document)
|
||||
|
||||
self.assertEqual(overrides.view_users, [direct_user.pk])
|
||||
self.assertEqual(overrides.change_users, [direct_user.pk])
|
||||
self.assertEqual(overrides.view_groups, [group.pk])
|
||||
self.assertEqual(overrides.change_groups, [group.pk])
|
||||
|
||||
|
||||
class TestBarcodeApplyDetectedASN(TestCase):
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
from faker import Faker
|
||||
@@ -257,3 +258,77 @@ def test_suggestion_content() -> None:
|
||||
)
|
||||
doc.content = content_over_limit
|
||||
assert doc.suggestion_content == expected_cropped_content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPublicFilenameLength:
|
||||
@pytest.mark.parametrize(
|
||||
("title", "archive", "extension"),
|
||||
[
|
||||
pytest.param("a" * 300, False, ".jpg", id="ascii-original"),
|
||||
pytest.param("a" * 300, True, ".pdf", id="ascii-archive"),
|
||||
pytest.param("ä" * 200, False, ".jpg", id="multibyte-original"),
|
||||
],
|
||||
)
|
||||
def test_long_title_keeps_extension_within_limit(
|
||||
self,
|
||||
title: str,
|
||||
*,
|
||||
archive: bool,
|
||||
extension: str,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose title alone is longer than 255 bytes
|
||||
WHEN:
|
||||
- The public file name is generated
|
||||
THEN:
|
||||
- The name fits in 255 bytes and still ends with the extension
|
||||
"""
|
||||
doc = Document(mime_type="image/jpeg", title=title, created=date(2020, 12, 25))
|
||||
|
||||
name = doc.get_public_filename(archive=archive)
|
||||
|
||||
assert len(name.encode()) <= 255
|
||||
assert name.endswith(extension)
|
||||
|
||||
def test_long_title_counters_give_distinct_names(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose title alone is longer than 255 bytes
|
||||
WHEN:
|
||||
- Public file names are generated with increasing counters
|
||||
THEN:
|
||||
- Each counter produces a distinct name that keeps the counter
|
||||
"""
|
||||
doc = Document(
|
||||
mime_type="application/pdf",
|
||||
title="ä" * 200,
|
||||
created=date(2020, 12, 25),
|
||||
)
|
||||
|
||||
names = [doc.get_public_filename(counter=counter) for counter in range(3)]
|
||||
|
||||
assert len(set(names)) == 3
|
||||
assert names[1].endswith("_01.pdf")
|
||||
assert all(len(name.encode()) <= 255 for name in names)
|
||||
|
||||
def test_max_bytes_bounds_name(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document with a long title
|
||||
WHEN:
|
||||
- The public file name is generated with a smaller byte limit
|
||||
THEN:
|
||||
- The name fits in that limit and keeps the counter and extension
|
||||
"""
|
||||
doc = Document(
|
||||
mime_type="application/pdf",
|
||||
title="a" * 300,
|
||||
created=date(2020, 12, 25),
|
||||
)
|
||||
|
||||
name = doc.get_public_filename(counter=1, max_bytes=100)
|
||||
|
||||
assert len(name.encode()) <= 100
|
||||
assert name.endswith("_01.pdf")
|
||||
@@ -1537,3 +1537,67 @@ class TestExportDerivedFileNameCollisions:
|
||||
assert (
|
||||
target / record[EXPORTER_ARCHIVE_NAME]
|
||||
).read_text() == f"archive {pk}"
|
||||
|
||||
def test_long_titles_export_within_file_name_limit(
|
||||
self,
|
||||
paperless_dirs: PaperlessDirs,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Two documents with the same created date and the same title
|
||||
- The title alone is longer than 255 bytes and contains umlauts
|
||||
WHEN:
|
||||
- The exporter runs
|
||||
THEN:
|
||||
- The export finishes with distinct original, thumbnail and archive names
|
||||
- Every exported file name fits in 255 bytes
|
||||
- Each exported file holds its own document's content
|
||||
"""
|
||||
for pk in (1, 2):
|
||||
(paperless_dirs.originals_dir / f"{pk:07}.pdf").write_bytes(
|
||||
f"original {pk}".encode(),
|
||||
)
|
||||
(paperless_dirs.archive_dir / f"{pk:07}.pdf").write_bytes(
|
||||
f"archive {pk}".encode(),
|
||||
)
|
||||
(paperless_dirs.thumbnail_dir / f"{pk:07}.webp").write_bytes(
|
||||
f"thumbnail {pk}".encode(),
|
||||
)
|
||||
DocumentFactory(
|
||||
pk=pk,
|
||||
title="Bestellbestätigung " * 15,
|
||||
created=date(2026, 10, 15),
|
||||
filename=f"{pk:07}.pdf",
|
||||
archive_filename=f"{pk:07}.pdf",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
target = tmp_path / "export"
|
||||
target.mkdir()
|
||||
call_command(
|
||||
"document_exporter",
|
||||
str(target),
|
||||
"--no-progress-bar",
|
||||
skip_checks=True,
|
||||
)
|
||||
|
||||
manifest = json.loads((target / "manifest.json").read_text())
|
||||
records = {r["pk"]: r for r in manifest if r["model"] == "documents.document"}
|
||||
|
||||
for key in (
|
||||
EXPORTER_FILE_NAME,
|
||||
EXPORTER_THUMBNAIL_NAME,
|
||||
EXPORTER_ARCHIVE_NAME,
|
||||
):
|
||||
assert records[1][key] != records[2][key]
|
||||
|
||||
for pk in (1, 2):
|
||||
record = records[pk]
|
||||
for key, content in (
|
||||
(EXPORTER_FILE_NAME, f"original {pk}"),
|
||||
(EXPORTER_THUMBNAIL_NAME, f"thumbnail {pk}"),
|
||||
(EXPORTER_ARCHIVE_NAME, f"archive {pk}"),
|
||||
):
|
||||
assert len(Path(record[key]).name.encode()) <= 255
|
||||
assert (target / record[key]).read_text() == content
|
||||
+31
-33
@@ -175,12 +175,12 @@ from documents.permissions import ViewDocumentsPermissions
|
||||
from documents.permissions import annotate_document_count_by_ids
|
||||
from documents.permissions import annotate_document_count_for_related_queryset
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import has_global_statistics_permission
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import has_system_status_permission
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import user_is_unrestricted
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
@@ -3836,51 +3836,49 @@ class GlobalSearchView(PassUserMixin):
|
||||
if doc_id in docs_by_id
|
||||
][:OBJECT_LIMIT]
|
||||
saved_views = (
|
||||
restrict_queryset_to_visible(
|
||||
SavedView.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_savedview",
|
||||
)
|
||||
SavedView,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_savedview")
|
||||
else []
|
||||
)
|
||||
saved_views = saved_views[:OBJECT_LIMIT]
|
||||
tags = (
|
||||
restrict_queryset_to_visible(
|
||||
Tag.objects.filter(name__icontains=query),
|
||||
request.user,
|
||||
"view_tag",
|
||||
get_objects_for_user_owner_aware(request.user, "view_tag", Tag).filter(
|
||||
name__icontains=query,
|
||||
)
|
||||
if request.user.has_perm("documents.view_tag")
|
||||
else []
|
||||
)
|
||||
tags = tags[:OBJECT_LIMIT]
|
||||
correspondents = (
|
||||
restrict_queryset_to_visible(
|
||||
Correspondent.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_correspondent",
|
||||
)
|
||||
Correspondent,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_correspondent")
|
||||
else []
|
||||
)
|
||||
correspondents = correspondents[:OBJECT_LIMIT]
|
||||
document_types = (
|
||||
restrict_queryset_to_visible(
|
||||
DocumentType.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_documenttype",
|
||||
)
|
||||
DocumentType,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_documenttype")
|
||||
else []
|
||||
)
|
||||
document_types = document_types[:OBJECT_LIMIT]
|
||||
storage_paths = (
|
||||
restrict_queryset_to_visible(
|
||||
StoragePath.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_storagepath",
|
||||
)
|
||||
StoragePath,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_storagepath")
|
||||
else []
|
||||
)
|
||||
@@ -3898,21 +3896,21 @@ class GlobalSearchView(PassUserMixin):
|
||||
)
|
||||
groups = groups[:OBJECT_LIMIT]
|
||||
mail_rules = (
|
||||
restrict_queryset_to_visible(
|
||||
MailRule.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_mailrule",
|
||||
)
|
||||
MailRule,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("paperless_mail.view_mailrule")
|
||||
else []
|
||||
)
|
||||
mail_rules = mail_rules[:OBJECT_LIMIT]
|
||||
mail_accounts = (
|
||||
restrict_queryset_to_visible(
|
||||
MailAccount.objects.filter(name__icontains=query),
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_mailaccount",
|
||||
)
|
||||
MailAccount,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("paperless_mail.view_mailaccount")
|
||||
else []
|
||||
)
|
||||
@@ -4028,33 +4026,33 @@ class StatisticsView(GenericAPIView[Any]):
|
||||
tags = (
|
||||
Tag.objects.all()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(Tag.objects.all(), user, "view_tag")
|
||||
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
|
||||
).only("id", "is_inbox_tag")
|
||||
correspondent_count = (
|
||||
Correspondent.objects.count()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(
|
||||
Correspondent.objects.all(),
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_correspondent",
|
||||
"documents.view_correspondent",
|
||||
Correspondent,
|
||||
).count()
|
||||
)
|
||||
document_type_count = (
|
||||
DocumentType.objects.count()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(
|
||||
DocumentType.objects.all(),
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_documenttype",
|
||||
"documents.view_documenttype",
|
||||
DocumentType,
|
||||
).count()
|
||||
)
|
||||
storage_path_count = (
|
||||
StoragePath.objects.count()
|
||||
if can_view_global_stats
|
||||
else restrict_queryset_to_visible(
|
||||
StoragePath.objects.all(),
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"view_storagepath",
|
||||
"documents.view_storagepath",
|
||||
StoragePath,
|
||||
).count()
|
||||
)
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ from django.utils.translation import gettext as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import restrict_queryset_to_visible
|
||||
from documents.serialisers import CorrespondentField
|
||||
from documents.serialisers import DocumentTypeField
|
||||
from documents.serialisers import OwnedObjectSerializer
|
||||
@@ -68,10 +68,10 @@ class AccountField(serializers.PrimaryKeyRelatedField[MailAccount]):
|
||||
if user is None:
|
||||
return MailAccount.objects.none()
|
||||
|
||||
return restrict_queryset_to_visible(
|
||||
MailAccount.objects.all(),
|
||||
return get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"change_mailaccount",
|
||||
MailAccount,
|
||||
).order_by("-id")
|
||||
|
||||
|
||||
|
||||
@@ -1272,11 +1272,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.32.6"
|
||||
version = "4.0.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/46/126b1831dca12060d4a8296bf9c4fe5c93c4f22197fa239cb0cc82042bba/filelock-3.32.6.tar.gz", hash = "sha256:a3f55a18af3652a94d8f47d6055df434f254ca1d02ef2524850c6d249ca2512c", size = 225172, upload-time = "2026-09-08T22:57:11.528Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/06/4f138f618dbea66803291274f228f01daf29f306fe8b96bc30dab765df75/filelock-3.32.6-py3-none-any.whl", hash = "sha256:3f16ecd0117feae0dfc147e8c62eb5daeccd8bd800378c3ddf416de9b4feb6b1", size = 100189, upload-time = "2026-09-08T22:57:10.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3184,7 +3184,7 @@ requires-dist = [
|
||||
{ name = "drf-spectacular", specifier = "~=0.30" },
|
||||
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
|
||||
{ name = "drf-writable-nested", specifier = "~=0.7.1" },
|
||||
{ name = "filelock", specifier = "~=3.32.0" },
|
||||
{ name = "filelock", specifier = ">=3.32,<4.1" },
|
||||
{ name = "flower", specifier = ">=2.0.1,<2.3" },
|
||||
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
|
||||
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
|
||||
|
||||
Reference in new issue
Block a user