diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 2b8169f24..89c8a96ea 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -44,7 +44,6 @@ include-labels: - 'notable' exclude-labels: - 'skip-changelog' -filter-by-commitish: true category-template: '### $TITLE' change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))' change-title-escapes: '\<*_&#@' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6abd71dfd..f8eb6c832 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -617,7 +617,6 @@ jobs: version: ${{ steps.get_version.outputs.version }} prerelease: ${{ steps.get_version.outputs.prerelease }} publish: true # ensures release is not marked as draft - committish: ${{ github.sha }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload release archive diff --git a/docker/management_script.sh b/docker/management_script.sh index 1fa31c372..6d5e84549 100755 --- a/docker/management_script.sh +++ b/docker/management_script.sh @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py management_command "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py management_command "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py management_command "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/convert_mariadb_uuid b/docker/rootfs/usr/local/bin/convert_mariadb_uuid index 806a98f3b..7adb0a1af 100755 --- a/docker/rootfs/usr/local/bin/convert_mariadb_uuid +++ b/docker/rootfs/usr/local/bin/convert_mariadb_uuid @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py convert_mariadb_uuid "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py convert_mariadb_uuid "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py convert_mariadb_uuid "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/createsuperuser b/docker/rootfs/usr/local/bin/createsuperuser index f931952ba..b91cee3c5 100755 --- a/docker/rootfs/usr/local/bin/createsuperuser +++ b/docker/rootfs/usr/local/bin/createsuperuser @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py createsuperuser "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py createsuperuser "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py createsuperuser "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/decrypt_documents b/docker/rootfs/usr/local/bin/decrypt_documents index 4da1549ee..65d035b70 100755 --- a/docker/rootfs/usr/local/bin/decrypt_documents +++ b/docker/rootfs/usr/local/bin/decrypt_documents @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py decrypt_documents "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py decrypt_documents "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py decrypt_documents "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/document_archiver b/docker/rootfs/usr/local/bin/document_archiver index 383acfcc6..4200aa7aa 100755 --- a/docker/rootfs/usr/local/bin/document_archiver +++ b/docker/rootfs/usr/local/bin/document_archiver @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_archiver "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_archiver "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_archiver "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/document_create_classifier b/docker/rootfs/usr/local/bin/document_create_classifier index 72dc33d6f..518551a4b 100755 --- a/docker/rootfs/usr/local/bin/document_create_classifier +++ b/docker/rootfs/usr/local/bin/document_create_classifier @@ -5,10 +5,17 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_create_classifier "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_create_classifier "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_create_classifier "$@" else echo "Unknown user." + exit 1 +fi +er "$@" +elif [[ $(id -un) == "paperless" ]]; then + s6-setuidgid paperless python3 manage.py document_create_classifier "$@" fi diff --git a/docker/rootfs/usr/local/bin/document_exporter b/docker/rootfs/usr/local/bin/document_exporter index 7f48215d7..a82d70a16 100755 --- a/docker/rootfs/usr/local/bin/document_exporter +++ b/docker/rootfs/usr/local/bin/document_exporter @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_exporter "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_exporter "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_exporter "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/document_fuzzy_match b/docker/rootfs/usr/local/bin/document_fuzzy_match index 5b9548557..b97c2a9ba 100755 --- a/docker/rootfs/usr/local/bin/document_fuzzy_match +++ b/docker/rootfs/usr/local/bin/document_fuzzy_match @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_fuzzy_match "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_fuzzy_match "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_fuzzy_match "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/document_importer b/docker/rootfs/usr/local/bin/document_importer index 2286e89f7..dbfb40a57 100755 --- a/docker/rootfs/usr/local/bin/document_importer +++ b/docker/rootfs/usr/local/bin/document_importer @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_importer "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_importer "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_importer "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/document_index b/docker/rootfs/usr/local/bin/document_index index 2d518b5c5..b05f765da 100755 --- a/docker/rootfs/usr/local/bin/document_index +++ b/docker/rootfs/usr/local/bin/document_index @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_index "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_index "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_index "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/document_renamer b/docker/rootfs/usr/local/bin/document_renamer index 326317a73..720edc0d8 100755 --- a/docker/rootfs/usr/local/bin/document_renamer +++ b/docker/rootfs/usr/local/bin/document_renamer @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_renamer "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_renamer "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_renamer "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/document_retagger b/docker/rootfs/usr/local/bin/document_retagger index 3bab3e790..6cbe03c19 100755 --- a/docker/rootfs/usr/local/bin/document_retagger +++ b/docker/rootfs/usr/local/bin/document_retagger @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_retagger "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_retagger "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_retagger "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/document_sanity_checker b/docker/rootfs/usr/local/bin/document_sanity_checker index 5c0c29ef2..8fff13a52 100755 --- a/docker/rootfs/usr/local/bin/document_sanity_checker +++ b/docker/rootfs/usr/local/bin/document_sanity_checker @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_sanity_checker "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_sanity_checker "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_sanity_checker "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/document_thumbnails b/docker/rootfs/usr/local/bin/document_thumbnails index c1000c31a..3c0f0de4c 100755 --- a/docker/rootfs/usr/local/bin/document_thumbnails +++ b/docker/rootfs/usr/local/bin/document_thumbnails @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py document_thumbnails "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py document_thumbnails "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py document_thumbnails "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/mail_fetcher b/docker/rootfs/usr/local/bin/mail_fetcher index 2ae1d1dfb..762b850b9 100755 --- a/docker/rootfs/usr/local/bin/mail_fetcher +++ b/docker/rootfs/usr/local/bin/mail_fetcher @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py mail_fetcher "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py mail_fetcher "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py mail_fetcher "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/manage_superuser b/docker/rootfs/usr/local/bin/manage_superuser index 9f7f37ecf..8f550cd1a 100755 --- a/docker/rootfs/usr/local/bin/manage_superuser +++ b/docker/rootfs/usr/local/bin/manage_superuser @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py manage_superuser "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py manage_superuser "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py manage_superuser "$@" else echo "Unknown user." + exit 1 fi diff --git a/docker/rootfs/usr/local/bin/prune_audit_logs b/docker/rootfs/usr/local/bin/prune_audit_logs index b9142e98e..8a3ab3299 100755 --- a/docker/rootfs/usr/local/bin/prune_audit_logs +++ b/docker/rootfs/usr/local/bin/prune_audit_logs @@ -5,10 +5,13 @@ set -e cd "${PAPERLESS_SRC_DIR}" -if [[ $(id -u) == 0 ]]; then +if [[ -n "${USER_IS_NON_ROOT}" ]]; then + python3 manage.py prune_audit_logs "$@" +elif [[ $(id -u) == 0 ]]; then s6-setuidgid paperless python3 manage.py prune_audit_logs "$@" elif [[ $(id -un) == "paperless" ]]; then python3 manage.py prune_audit_logs "$@" else echo "Unknown user." + exit 1 fi diff --git a/pyproject.toml b/pyproject.toml index d4d24a7e8..e1693ead0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paperless-ngx" -version = "2.20.5" +version = "2.20.6" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" readme = "README.md" requires-python = ">=3.10" @@ -238,7 +238,7 @@ lint.isort.force-single-line = true [tool.codespell] write-changes = true -ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober" +ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish" skip = "src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/documents/tests/samples/*,*.po,*.json" [tool.pytest.ini_options] diff --git a/src-ui/package.json b/src-ui/package.json index 6d9046f65..dbd26cb8c 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -1,6 +1,6 @@ { "name": "paperless-ngx-ui", - "version": "2.20.5", + "version": "2.20.6", "scripts": { "preinstall": "npx only-allow pnpm", "ng": "ng", diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts index 813c81148..86f0f0469 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.spec.ts @@ -229,6 +229,21 @@ describe('ManagementListComponent', () => { expect(reloadSpy).toHaveBeenCalled() }) + it('should use the all list length for collection size when provided', fakeAsync(() => { + jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce( + of({ + count: 1, + all: [1, 2, 3], + results: tags.slice(0, 1), + }) + ) + + component.reloadData() + tick(100) + + expect(component.collectionSize).toBe(3) + })) + it('should support quick filter for objects', () => { const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const filterButton = fixture.debugElement.queryAll(By.css('button'))[9] diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index b1af1f1d1..44160fcdf 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -171,7 +171,7 @@ export abstract class ManagementListComponent tap((c) => { this.unfilteredData = c.results this.data = this.filterData(c.results) - this.collectionSize = c.count + this.collectionSize = c.all?.length ?? c.count }), delay(100) ) diff --git a/src-ui/src/app/components/manage/saved-views/saved-views.component.html b/src-ui/src/app/components/manage/saved-views/saved-views.component.html index 10487fec8..2f5ef3338 100644 --- a/src-ui/src/app/components/manage/saved-views/saved-views.component.html +++ b/src-ui/src/app/components/manage/saved-views/saved-views.component.html @@ -51,6 +51,7 @@ @if (displayFields) { } + Note: ordering is not preserved diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index 9ebf29d16..3ce1d16cc 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -6,7 +6,7 @@ export const environment = { apiVersion: '9', // match src/paperless/settings.py appTitle: 'Paperless-ngx', tag: 'prod', - version: '2.20.5', + version: '2.20.6', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/src/documents/data_models.py b/src/documents/data_models.py index 2623a6138..8e2ff2e92 100644 --- a/src/documents/data_models.py +++ b/src/documents/data_models.py @@ -115,7 +115,7 @@ class DocumentMetadataOverrides: ).values_list("id", flat=True), ) overrides.custom_fields = { - custom_field.id: custom_field.value + custom_field.field.id: custom_field.value for custom_field in doc.custom_fields.all() } diff --git a/src/documents/index.py b/src/documents/index.py index ea26ea926..8afc31fe9 100644 --- a/src/documents/index.py +++ b/src/documents/index.py @@ -602,7 +602,7 @@ def rewrite_natural_date_keywords(query_string: str) -> str: case "this year": start = datetime(local_now.year, 1, 1, 0, 0, 0, tzinfo=tz) - end = datetime.combine(today, time.max, tzinfo=tz) + end = datetime(local_now.year, 12, 31, 23, 59, 59, tzinfo=tz) case "previous week": days_since_monday = local_now.weekday() diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 0648aa0b3..75e73d878 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -40,6 +40,7 @@ from guardian.utils import get_group_obj_perms_model from guardian.utils import get_user_obj_perms_model from rest_framework import fields from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied from rest_framework.fields import SerializerMethodField from rest_framework.filters import OrderingFilter @@ -436,6 +437,19 @@ class OwnedObjectSerializer( return instance def update(self, instance, validated_data): + user = getattr(self, "user", None) + is_superuser = user.is_superuser if user is not None else False + is_owner = instance.owner == user if user is not None else False + is_unowned = instance.owner is None + + if ( + ("owner" in validated_data and validated_data["owner"] != instance.owner) + or "set_permissions" in validated_data + ) and not (is_superuser or is_owner or is_unowned): + raise PermissionDenied( + _("Insufficient permissions."), + ) + if "set_permissions" in validated_data: self._set_permissions(validated_data["set_permissions"], instance) self.validate_unique_together(validated_data, instance) @@ -580,30 +594,34 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer): ), ) def get_children(self, obj): - filter_q = self.context.get("document_count_filter") - request = self.context.get("request") - if filter_q is None: - user = getattr(request, "user", None) if request else None - filter_q = get_document_count_filter_for_user(user) - self.context["document_count_filter"] = filter_q + children_map = self.context.get("children_map") + if children_map is not None: + children = children_map.get(obj.pk, []) + else: + filter_q = self.context.get("document_count_filter") + request = self.context.get("request") + if filter_q is None: + user = getattr(request, "user", None) if request else None + filter_q = get_document_count_filter_for_user(user) + self.context["document_count_filter"] = filter_q - children_queryset = ( - obj.get_children_queryset() - .select_related("owner") - .annotate(document_count=Count("documents", filter=filter_q)) - ) + children = ( + obj.get_children_queryset() + .select_related("owner") + .annotate(document_count=Count("documents", filter=filter_q)) + ) - view = self.context.get("view") - ordering = ( - OrderingFilter().get_ordering(request, children_queryset, view) - if request and view - else None - ) - ordering = ordering or (Lower("name"),) - children_queryset = children_queryset.order_by(*ordering) + view = self.context.get("view") + ordering = ( + OrderingFilter().get_ordering(request, children, view) + if request and view + else None + ) + ordering = ordering or (Lower("name"),) + children = children.order_by(*ordering) serializer = TagSerializer( - children_queryset, + children, many=True, user=self.user, full_perms=self.full_perms, diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index f40ef157f..700f56568 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -1216,6 +1216,17 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + def test_upload_insufficient_permissions(self): + self.client.force_authenticate(user=User.objects.create_user("testuser2")) + + with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f: + response = self.client.post( + "/api/documents/post_document/", + {"document": f}, + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_upload_empty_metadata(self): self.consume_file_mock.return_value = celery.result.AsyncResult( id=str(uuid.uuid4()), diff --git a/src/documents/tests/test_api_permissions.py b/src/documents/tests/test_api_permissions.py index bc81dabe9..31b860745 100644 --- a/src/documents/tests/test_api_permissions.py +++ b/src/documents/tests/test_api_permissions.py @@ -441,6 +441,59 @@ class TestApiAuth(DirectoriesMixin, APITestCase): self.assertTrue(checker.has_perm("change_document", doc)) self.assertIn("change_document", get_perms(group1, doc)) + def test_document_permissions_change_requires_owner(self): + owner = User.objects.create_user(username="owner") + editor = User.objects.create_user(username="editor") + editor.user_permissions.add( + *Permission.objects.all(), + ) + + doc = Document.objects.create( + title="Ownered doc", + content="sensitive", + checksum="abc123", + mime_type="application/pdf", + owner=owner, + ) + + assign_perm("view_document", editor, doc) + assign_perm("change_document", editor, doc) + + self.client.force_authenticate(editor) + response = self.client.patch( + f"/api/documents/{doc.pk}/", + json.dumps( + { + "set_permissions": { + "view": { + "users": [editor.id], + "groups": [], + }, + "change": { + "users": None, + "groups": None, + }, + }, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + self.client.force_authenticate(editor) + response = self.client.patch( + f"/api/documents/{doc.pk}/", + json.dumps( + { + "owner": editor.id, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_dynamic_permissions_fields(self): user1 = User.objects.create_user(username="user1") user1.user_permissions.add(*Permission.objects.filter(codename="view_document")) diff --git a/src/documents/tests/test_index.py b/src/documents/tests/test_index.py index 3167bb762..ef6b535f7 100644 --- a/src/documents/tests/test_index.py +++ b/src/documents/tests/test_index.py @@ -180,7 +180,7 @@ class TestRewriteNaturalDateKeywords(SimpleTestCase): ( "added:this year", datetime(2025, 7, 15, 12, 0, 0, tzinfo=timezone.utc), - ("added:[20250101", "TO 20250715"), + ("added:[20250101", "TO 20251231"), ), ( "added:previous year", diff --git a/src/documents/views.py b/src/documents/views.py index d5910497f..5a0f83699 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -448,8 +448,47 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): def get_serializer_context(self): context = super().get_serializer_context() context["document_count_filter"] = self.get_document_count_filter() + if hasattr(self, "_children_map"): + context["children_map"] = self._children_map return context + def list(self, request, *args, **kwargs): + """ + Build a children map once to avoid per-parent queries in the serializer. + """ + queryset = self.filter_queryset(self.get_queryset()) + ordering = OrderingFilter().get_ordering(request, queryset, self) or ( + Lower("name"), + ) + queryset = queryset.order_by(*ordering) + + all_tags = list(queryset) + descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()} + + if descendant_pks: + filter_q = self.get_document_count_filter() + children_source = list( + Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags}) + .select_related("owner") + .annotate(document_count=Count("documents", filter=filter_q)) + .order_by(*ordering), + ) + else: + children_source = all_tags + + children_map = {} + for tag in children_source: + children_map.setdefault(tag.tn_parent_id, []).append(tag) + self._children_map = children_map + + page = self.paginate_queryset(queryset) + serializer = self.get_serializer(page, many=True) + response = self.get_paginated_response(serializer.data) + if descendant_pks: + # Include children in the "all" field, if needed + response.data["all"] = [tag.pk for tag in children_source] + return response + def perform_update(self, serializer): old_parent = self.get_object().get_parent() tag = serializer.save() @@ -1060,7 +1099,7 @@ class DocumentViewSet( ): return HttpResponseForbidden("Insufficient permissions to delete notes") - note = Note.objects.get(id=int(request.GET.get("id"))) + note = Note.objects.get(id=int(request.GET.get("id")), document=doc) if settings.AUDIT_LOG_ENABLED: LogEntry.objects.log_create( instance=doc, @@ -1664,6 +1703,8 @@ class PostDocumentView(GenericAPIView): parser_classes = (parsers.MultiPartParser,) def post(self, request, *args, **kwargs): + if not request.user.has_perm("documents.add_document"): + return HttpResponseForbidden("Insufficient permissions") serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/src/paperless/version.py b/src/paperless/version.py index aeeee68e0..ec6eaed08 100644 --- a/src/paperless/version.py +++ b/src/paperless/version.py @@ -1,6 +1,6 @@ from typing import Final -__version__: Final[tuple[int, int, int]] = (2, 20, 5) +__version__: Final[tuple[int, int, int]] = (2, 20, 6) # Version string like X.Y.Z __full_version_str__: Final[str] = ".".join(map(str, __version__)) # Version string like X.Y diff --git a/uv.lock b/uv.lock index ac7763525..b09a025e0 100644 --- a/uv.lock +++ b/uv.lock @@ -2115,7 +2115,7 @@ wheels = [ [[package]] name = "paperless-ngx" -version = "2.20.5" +version = "2.20.6" source = { virtual = "." } dependencies = [ { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },