From bf368aadd0da1fb1cf767977e5cbd589dad12d09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 15:25:21 +0000 Subject: [PATCH 1/7] Chore(deps-dev): Bump ruff from 0.9.2 to 0.9.3 in the development group (#8928) * Chore(deps-dev): Bump ruff from 0.9.2 to 0.9.3 in the development group Bumps the development group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.9.2 to 0.9.3 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.9.2...0.9.3) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:development update-type: version-update:semver-patch dependency-group: development ... Signed-off-by: dependabot[bot] * Update .pre-commit-config.yaml --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- Pipfile.lock | 38 +++++++++++++++++++------------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 524ec771f..2ddffaf9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - 'prettier-plugin-organize-imports@4.1.0' # Python hooks - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.2 + rev: v0.9.3 hooks: - id: ruff - id: ruff-format diff --git a/Pipfile.lock b/Pipfile.lock index d9d05ea29..de4290d84 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -3983,28 +3983,28 @@ }, "ruff": { "hashes": [ - "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df", - "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d", - "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb", - "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145", - "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347", - "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d", - "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c", - "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684", - "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f", - "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", - "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a", - "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe", - "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0", - "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00", - "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247", - "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5", - "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e", - "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4" + "sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2", + "sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4", + "sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439", + "sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730", + "sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4", + "sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5", + "sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624", + "sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b", + "sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a", + "sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b", + "sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5", + "sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4", + "sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c", + "sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519", + "sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1", + "sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4", + "sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6", + "sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.9.2" + "version": "==0.9.3" }, "scipy": { "hashes": [ From c2a9ac332a5c036d997102479cbc02ff6ba9fbad Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 29 Jan 2025 07:23:44 -0800 Subject: [PATCH 2/7] Enhancement: require totp code for obtain auth token (#8936) --- src/documents/tests/test_api_permissions.py | 66 +++++++++++++++++++++ src/paperless/serialisers.py | 33 +++++++++++ src/paperless/urls.py | 4 +- src/paperless/views.py | 6 ++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/documents/tests/test_api_permissions.py b/src/documents/tests/test_api_permissions.py index 5de1887b2..3785c8f2a 100644 --- a/src/documents/tests/test_api_permissions.py +++ b/src/documents/tests/test_api_permissions.py @@ -3,6 +3,7 @@ import json from unittest import mock from allauth.mfa.models import Authenticator +from allauth.mfa.totp.internal import auth as totp_auth from django.contrib.auth.models import Group from django.contrib.auth.models import Permission from django.contrib.auth.models import User @@ -488,6 +489,71 @@ class TestApiAuth(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.assertEqual(response.data["detail"], "MFA required") + @mock.patch("allauth.mfa.totp.internal.auth.TOTP.validate_code") + def test_get_token_mfa_enabled(self, mock_validate_code): + """ + GIVEN: + - User with MFA enabled + WHEN: + - API request is made to obtain an auth token + THEN: + - MFA code is required + """ + user1 = User.objects.create_user(username="user1") + user1.set_password("password") + user1.save() + + response = self.client.post( + "/api/token/", + data={ + "username": "user1", + "password": "password", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + secret = totp_auth.generate_totp_secret() + totp_auth.TOTP.activate( + user1, + secret, + ) + + # no code + response = self.client.post( + "/api/token/", + data={ + "username": "user1", + "password": "password", + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["non_field_errors"][0], "MFA code is required") + + # invalid code + mock_validate_code.return_value = False + response = self.client.post( + "/api/token/", + data={ + "username": "user1", + "password": "password", + "code": "123456", + }, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["non_field_errors"][0], "Invalid MFA code") + + # valid code + mock_validate_code.return_value = True + response = self.client.post( + "/api/token/", + data={ + "username": "user1", + "password": "password", + "code": "123456", + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + class TestApiUser(DirectoriesMixin, APITestCase): ENDPOINT = "/api/users/" diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index d5acfe465..fb1f511f7 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -1,11 +1,14 @@ import logging from allauth.mfa.adapter import get_adapter as get_mfa_adapter +from allauth.mfa.models import Authenticator +from allauth.mfa.totp.internal.auth import TOTP from allauth.socialaccount.models import SocialAccount from django.contrib.auth.models import Group from django.contrib.auth.models import Permission from django.contrib.auth.models import User from rest_framework import serializers +from rest_framework.authtoken.serializers import AuthTokenSerializer from paperless.models import ApplicationConfiguration @@ -24,6 +27,36 @@ class ObfuscatedUserPasswordField(serializers.Field): return data +class PaperlessAuthTokenSerializer(AuthTokenSerializer): + code = serializers.CharField( + label="MFA Code", + write_only=True, + required=False, + ) + + def validate(self, attrs): + attrs = super().validate(attrs) + user = attrs.get("user") + code = attrs.get("code") + mfa_adapter = get_mfa_adapter() + if mfa_adapter.is_mfa_enabled(user): + if not code: + raise serializers.ValidationError( + "MFA code is required", + ) + authenticator = Authenticator.objects.get( + user=user, + type=Authenticator.Type.TOTP, + ) + if not TOTP(instance=authenticator).validate_code( + code, + ): + raise serializers.ValidationError( + "Invalid MFA code", + ) + return attrs + + class UserSerializer(serializers.ModelSerializer): password = ObfuscatedUserPasswordField(required=False) user_permissions = serializers.SlugRelatedField( diff --git a/src/paperless/urls.py b/src/paperless/urls.py index c528c5e2a..703a72042 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -14,7 +14,6 @@ from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic import RedirectView from django.views.static import serve -from rest_framework.authtoken import views from rest_framework.routers import DefaultRouter from documents.views import BulkDownloadView @@ -50,6 +49,7 @@ from paperless.views import DisconnectSocialAccountView from paperless.views import FaviconView from paperless.views import GenerateAuthTokenView from paperless.views import GroupViewSet +from paperless.views import PaperlessObtainAuthTokenView from paperless.views import ProfileView from paperless.views import SocialAccountProvidersView from paperless.views import TOTPView @@ -157,7 +157,7 @@ urlpatterns = [ ), path( "token/", - views.obtain_auth_token, + PaperlessObtainAuthTokenView.as_view(), ), re_path( "^profile/", diff --git a/src/paperless/views.py b/src/paperless/views.py index 03721adf2..bcabd182f 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -19,6 +19,7 @@ from django.http import HttpResponseNotFound from django.views.generic import View from django_filters.rest_framework import DjangoFilterBackend from rest_framework.authtoken.models import Token +from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.decorators import action from rest_framework.filters import OrderingFilter from rest_framework.generics import GenericAPIView @@ -35,10 +36,15 @@ from paperless.filters import UserFilterSet from paperless.models import ApplicationConfiguration from paperless.serialisers import ApplicationConfigurationSerializer from paperless.serialisers import GroupSerializer +from paperless.serialisers import PaperlessAuthTokenSerializer from paperless.serialisers import ProfileSerializer from paperless.serialisers import UserSerializer +class PaperlessObtainAuthTokenView(ObtainAuthToken): + serializer_class = PaperlessAuthTokenSerializer + + class StandardPagination(PageNumberPagination): page_size = 25 page_size_query_param = "page_size" From e0ea4a462565dad644a3563123d30bff407af889 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 30 Jan 2025 10:55:05 -0800 Subject: [PATCH 3/7] Fix: reflect doc links in bulk modify custom fields (#8962) --- src/documents/bulk_edit.py | 91 +++++++++++++++++++++++++++ src/documents/serialisers.py | 88 +------------------------- src/documents/tests/test_bulk_edit.py | 19 ++++-- 3 files changed, 108 insertions(+), 90 deletions(-) diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index de43aed87..7e2d2088e 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -12,6 +12,7 @@ from celery import shared_task from django.conf import settings from django.contrib.auth.models import User from django.db.models import Q +from django.utils import timezone from documents.data_models import ConsumableDocument from documents.data_models import DocumentMetadataOverrides @@ -177,6 +178,12 @@ def modify_custom_fields( field_id=field_id, defaults=defaults, ) + if ( + custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK + and value + ): + doc = Document.objects.get(id=doc_id) + reflect_doclinks(doc, custom_field, value) CustomFieldInstance.objects.filter( document_id__in=affected_docs, field_id__in=remove_custom_fields, @@ -447,3 +454,87 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]: logger.exception(f"Error deleting pages from document {doc.id}: {e}") return "OK" + + +def reflect_doclinks( + document: Document, + field: CustomField, + target_doc_ids: list[int], +): + """ + Add or remove 'symmetrical' links to `document` on all `target_doc_ids` + """ + + if target_doc_ids is None: + target_doc_ids = [] + + # Check if any documents are going to be removed from the current list of links and remove the symmetrical links + current_field_instance = CustomFieldInstance.objects.filter( + field=field, + document=document, + ).first() + if current_field_instance is not None and current_field_instance.value is not None: + for doc_id in current_field_instance.value: + if doc_id not in target_doc_ids: + remove_doclink( + document=document, + field=field, + target_doc_id=doc_id, + ) + + # Create an instance if target doc doesn't have this field or append it to an existing one + existing_custom_field_instances = { + custom_field.document_id: custom_field + for custom_field in CustomFieldInstance.objects.filter( + field=field, + document_id__in=target_doc_ids, + ) + } + custom_field_instances_to_create = [] + custom_field_instances_to_update = [] + for target_doc_id in target_doc_ids: + target_doc_field_instance = existing_custom_field_instances.get( + target_doc_id, + ) + if target_doc_field_instance is None: + custom_field_instances_to_create.append( + CustomFieldInstance( + document_id=target_doc_id, + field=field, + value_document_ids=[document.id], + ), + ) + elif target_doc_field_instance.value is None: + target_doc_field_instance.value_document_ids = [document.id] + custom_field_instances_to_update.append(target_doc_field_instance) + elif document.id not in target_doc_field_instance.value: + target_doc_field_instance.value_document_ids.append(document.id) + custom_field_instances_to_update.append(target_doc_field_instance) + + CustomFieldInstance.objects.bulk_create(custom_field_instances_to_create) + CustomFieldInstance.objects.bulk_update( + custom_field_instances_to_update, + ["value_document_ids"], + ) + Document.objects.filter(id__in=target_doc_ids).update(modified=timezone.now()) + + +def remove_doclink( + document: Document, + field: CustomField, + target_doc_id: int, +): + """ + Removes a 'symmetrical' link to `document` from the target document's existing custom field instance + """ + target_doc_field_instance = CustomFieldInstance.objects.filter( + document_id=target_doc_id, + field=field, + ).first() + if ( + target_doc_field_instance is not None + and document.id in target_doc_field_instance.value + ): + target_doc_field_instance.value.remove(document.id) + target_doc_field_instance.save() + Document.objects.filter(id=target_doc_id).update(modified=timezone.now()) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 0732fd242..4adadbcb2 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -16,7 +16,6 @@ from django.core.validators import DecimalValidator from django.core.validators import MaxLengthValidator from django.core.validators import RegexValidator from django.core.validators import integer_validator -from django.utils import timezone from django.utils.crypto import get_random_string from django.utils.text import slugify from django.utils.translation import gettext as _ @@ -647,7 +646,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK: # prior to update so we can look for any docs that are going to be removed - self.reflect_doclinks(document, custom_field, validated_data["value"]) + bulk_edit.reflect_doclinks(document, custom_field, validated_data["value"]) # Actually update or create the instance, providing the value # to fill in the correct attribute based on the type @@ -767,89 +766,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): return ret - def reflect_doclinks( - self, - document: Document, - field: CustomField, - target_doc_ids: list[int], - ): - """ - Add or remove 'symmetrical' links to `document` on all `target_doc_ids` - """ - - if target_doc_ids is None: - target_doc_ids = [] - - # Check if any documents are going to be removed from the current list of links and remove the symmetrical links - current_field_instance = CustomFieldInstance.objects.filter( - field=field, - document=document, - ).first() - if ( - current_field_instance is not None - and current_field_instance.value is not None - ): - for doc_id in current_field_instance.value: - if doc_id not in target_doc_ids: - self.remove_doclink(document, field, doc_id) - - # Create an instance if target doc doesn't have this field or append it to an existing one - existing_custom_field_instances = { - custom_field.document_id: custom_field - for custom_field in CustomFieldInstance.objects.filter( - field=field, - document_id__in=target_doc_ids, - ) - } - custom_field_instances_to_create = [] - custom_field_instances_to_update = [] - for target_doc_id in target_doc_ids: - target_doc_field_instance = existing_custom_field_instances.get( - target_doc_id, - ) - if target_doc_field_instance is None: - custom_field_instances_to_create.append( - CustomFieldInstance( - document_id=target_doc_id, - field=field, - value_document_ids=[document.id], - ), - ) - elif target_doc_field_instance.value is None: - target_doc_field_instance.value_document_ids = [document.id] - custom_field_instances_to_update.append(target_doc_field_instance) - elif document.id not in target_doc_field_instance.value: - target_doc_field_instance.value_document_ids.append(document.id) - custom_field_instances_to_update.append(target_doc_field_instance) - - CustomFieldInstance.objects.bulk_create(custom_field_instances_to_create) - CustomFieldInstance.objects.bulk_update( - custom_field_instances_to_update, - ["value_document_ids"], - ) - Document.objects.filter(id__in=target_doc_ids).update(modified=timezone.now()) - - @staticmethod - def remove_doclink( - document: Document, - field: CustomField, - target_doc_id: int, - ): - """ - Removes a 'symmetrical' link to `document` from the target document's existing custom field instance - """ - target_doc_field_instance = CustomFieldInstance.objects.filter( - document_id=target_doc_id, - field=field, - ).first() - if ( - target_doc_field_instance is not None - and document.id in target_doc_field_instance.value - ): - target_doc_field_instance.value.remove(document.id) - target_doc_field_instance.save() - Document.objects.filter(id=target_doc_id).update(modified=timezone.now()) - class Meta: model = CustomFieldInstance fields = [ @@ -951,7 +867,7 @@ class DocumentSerializer( ): # Doc link field is being removed entirely for doc_id in custom_field_instance.value: - CustomFieldInstanceSerializer.remove_doclink( + bulk_edit.remove_doclink( instance, custom_field_instance.field, doc_id, diff --git a/src/documents/tests/test_bulk_edit.py b/src/documents/tests/test_bulk_edit.py index 03c177343..2d8af025b 100644 --- a/src/documents/tests/test_bulk_edit.py +++ b/src/documents/tests/test_bulk_edit.py @@ -268,7 +268,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase): ) cf3 = CustomField.objects.create( name="cf3", - data_type=CustomField.FieldDataType.STRING, + data_type=CustomField.FieldDataType.DOCUMENTLINK, ) CustomFieldInstance.objects.create( document=self.doc2, @@ -282,9 +282,14 @@ class TestBulkEdit(DirectoriesMixin, TestCase): document=self.doc2, field=cf3, ) + doc3: Document = Document.objects.create( + title="doc3", + content="content", + checksum="D3", + ) bulk_edit.modify_custom_fields( [self.doc1.id, self.doc2.id], - add_custom_fields={cf2.id: None, cf3.id: "value"}, + add_custom_fields={cf2.id: None, cf3.id: [doc3.id]}, remove_custom_fields=[cf.id], ) @@ -301,7 +306,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase): ) self.assertEqual( self.doc1.custom_fields.get(field=cf3).value, - "value", + [doc3.id], ) self.assertEqual( self.doc2.custom_fields.count(), @@ -309,7 +314,13 @@ class TestBulkEdit(DirectoriesMixin, TestCase): ) self.assertEqual( self.doc2.custom_fields.get(field=cf3).value, - "value", + [doc3.id], + ) + # assert reflect document link + doc3.refresh_from_db() + self.assertEqual( + doc3.custom_fields.first().value, + [self.doc2.id, self.doc1.id], ) self.async_task.assert_called_once() From ee72e2d1fdd1b3e2149ca4097af1c3f86eeb42ea Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 31 Jan 2025 00:35:12 -0800 Subject: [PATCH 4/7] Fix: resolve error in trackBy --- .../document-card-large/document-card-large.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html index 6ebbd6055..84e415815 100644 --- a/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html +++ b/src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html @@ -32,7 +32,7 @@ {{document.title | documentTitle}} } @if (displayFields.includes(DisplayField.TAGS)) { - @for (tagID of document.tags; track t) { + @for (tagID of document.tags; track tagID) { } } From e1d8680698b91f335b1130773f10179cfda829bc Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 31 Jan 2025 07:39:22 -0800 Subject: [PATCH 5/7] Fix: also ensure symmetric doc link removal on bulk edit (#8963) --- src/documents/bulk_edit.py | 23 +++++++++++++++++---- src/documents/tests/test_bulk_edit.py | 29 ++++++++++++++++++--------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/documents/bulk_edit.py b/src/documents/bulk_edit.py index 7e2d2088e..f0522eddc 100644 --- a/src/documents/bulk_edit.py +++ b/src/documents/bulk_edit.py @@ -178,12 +178,27 @@ def modify_custom_fields( field_id=field_id, defaults=defaults, ) - if ( - custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK - and value - ): + if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK: doc = Document.objects.get(id=doc_id) reflect_doclinks(doc, custom_field, value) + + # For doc link fields that are being removed, remove symmetrical links + for doclink_being_removed_instance in CustomFieldInstance.objects.filter( + document_id__in=affected_docs, + field__id__in=remove_custom_fields, + field__data_type=CustomField.FieldDataType.DOCUMENTLINK, + value_document_ids__isnull=False, + ): + for target_doc_id in doclink_being_removed_instance.value: + remove_doclink( + document=Document.objects.get( + id=doclink_being_removed_instance.document.id, + ), + field=doclink_being_removed_instance.field, + target_doc_id=target_doc_id, + ) + + # Finally, remove the custom fields CustomFieldInstance.objects.filter( document_id__in=affected_docs, field_id__in=remove_custom_fields, diff --git a/src/documents/tests/test_bulk_edit.py b/src/documents/tests/test_bulk_edit.py index 2d8af025b..7fde5f8ee 100644 --- a/src/documents/tests/test_bulk_edit.py +++ b/src/documents/tests/test_bulk_edit.py @@ -282,14 +282,9 @@ class TestBulkEdit(DirectoriesMixin, TestCase): document=self.doc2, field=cf3, ) - doc3: Document = Document.objects.create( - title="doc3", - content="content", - checksum="D3", - ) bulk_edit.modify_custom_fields( [self.doc1.id, self.doc2.id], - add_custom_fields={cf2.id: None, cf3.id: [doc3.id]}, + add_custom_fields={cf2.id: None, cf3.id: [self.doc3.id]}, remove_custom_fields=[cf.id], ) @@ -306,7 +301,7 @@ class TestBulkEdit(DirectoriesMixin, TestCase): ) self.assertEqual( self.doc1.custom_fields.get(field=cf3).value, - [doc3.id], + [self.doc3.id], ) self.assertEqual( self.doc2.custom_fields.count(), @@ -314,12 +309,11 @@ class TestBulkEdit(DirectoriesMixin, TestCase): ) self.assertEqual( self.doc2.custom_fields.get(field=cf3).value, - [doc3.id], + [self.doc3.id], ) # assert reflect document link - doc3.refresh_from_db() self.assertEqual( - doc3.custom_fields.first().value, + self.doc3.custom_fields.first().value, [self.doc2.id, self.doc1.id], ) @@ -327,6 +321,21 @@ class TestBulkEdit(DirectoriesMixin, TestCase): args, kwargs = self.async_task.call_args self.assertCountEqual(kwargs["document_ids"], [self.doc1.id, self.doc2.id]) + # removal of document link cf, should also remove symmetric link + bulk_edit.modify_custom_fields( + [self.doc3.id], + add_custom_fields={}, + remove_custom_fields=[cf3.id], + ) + self.assertNotIn( + self.doc3.id, + self.doc1.custom_fields.filter(field=cf3).first().value, + ) + self.assertNotIn( + self.doc3.id, + self.doc2.custom_fields.filter(field=cf3).first().value, + ) + def test_delete(self): self.assertEqual(Document.objects.count(), 5) bulk_edit.delete([self.doc1.id, self.doc2.id]) From 49b658a9447e82326c4dfc9ec4983495533b1ea4 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Fri, 31 Jan 2025 07:46:43 -0800 Subject: [PATCH 6/7] Bump version to 2.14.7 --- src-ui/src/environments/environment.prod.ts | 2 +- src/paperless/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index 0fbbdae0a..9db14f6c3 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -5,7 +5,7 @@ export const environment = { apiBaseUrl: document.baseURI + 'api/', apiVersion: '7', appTitle: 'Paperless-ngx', - version: '2.14.6', + version: '2.14.7', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/src/paperless/version.py b/src/paperless/version.py index 185208da1..b09a20ef4 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, 14, 6) +__version__: Final[tuple[int, int, int]] = (2, 14, 7) # Version string like X.Y.Z __full_version_str__: Final[str] = ".".join(map(str, __version__)) # Version string like X.Y From 16d6bb7334f8144a0ea62db954ec8ecf7cdff817 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jan 2025 08:36:29 -0800 Subject: [PATCH 7/7] Documentation: Add v2.14.7 changelog (#8972) * Changelog v2.14.7 - GHA * Update changelog.md --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/changelog.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index e2d2b98ac..56316942c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,28 @@ # Changelog +## paperless-ngx 2.14.7 + +### Features + +- Enhancement: require totp code for obtain auth token by [@shamoon](https://github.com/shamoon) [#8936](https://github.com/paperless-ngx/paperless-ngx/pull/8936) + +### Bug Fixes + +- Enhancement: require totp code for obtain auth token by [@shamoon](https://github.com/shamoon) [#8936](https://github.com/paperless-ngx/paperless-ngx/pull/8936) +- Fix: reflect doc links in bulk modify custom fields by [@shamoon](https://github.com/shamoon) [#8962](https://github.com/paperless-ngx/paperless-ngx/pull/8962) +- Fix: also ensure symmetric doc link removal on bulk edit by [@shamoon](https://github.com/shamoon) [#8963](https://github.com/paperless-ngx/paperless-ngx/pull/8963) + +### All App Changes + +
+4 changes + +- Chore(deps-dev): Bump ruff from 0.9.2 to 0.9.3 in the development group by @[dependabot[bot]](https://github.com/apps/dependabot) [#8928](https://github.com/paperless-ngx/paperless-ngx/pull/8928) +- Enhancement: require totp code for obtain auth token by [@shamoon](https://github.com/shamoon) [#8936](https://github.com/paperless-ngx/paperless-ngx/pull/8936) +- Fix: reflect doc links in bulk modify custom fields by [@shamoon](https://github.com/shamoon) [#8962](https://github.com/paperless-ngx/paperless-ngx/pull/8962) +- Fix: also ensure symmetric doc link removal on bulk edit by [@shamoon](https://github.com/shamoon) [#8963](https://github.com/paperless-ngx/paperless-ngx/pull/8963) +
+ ## paperless-ngx 2.14.6 ### Bug Fixes