mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-15 07:13:18 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37c31ed36 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
|
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" placement="bottom-end" [popperOptions]="popperOptions">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||||
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
|
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+11
@@ -6,5 +6,16 @@
|
|||||||
&.show {
|
&.show {
|
||||||
margin-left: -245px !important;
|
margin-left: -245px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
max-height: min(50dvh, 20rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item:first-child {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+907
-1414
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+906
-1413
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+930
-1437
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+1094
-1601
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+2148
-2654
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+941
-1448
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+906
-1413
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
+904
-1411
File diff suppressed because it is too large
Load Diff
@@ -41,16 +41,7 @@ class SuggestionCacheData:
|
|||||||
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
|
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
|
||||||
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
|
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
|
||||||
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
||||||
# Marker distinguishing LLM suggestions from classifier-generated ones (whose
|
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1000 # Marker distinguishing LLM suggestions
|
||||||
# FORMAT_VERSION lives in a much lower range - see DocumentClassifier). Bump
|
|
||||||
# this whenever the *shape* of the cached `suggestions` dict changes, so a
|
|
||||||
# cache entry written by a previous release can never be read back by code
|
|
||||||
# that expects a different shape:
|
|
||||||
# 1000 - initial LLM suggestions cache (flat lists of resolved object ids
|
|
||||||
# per taxonomy field)
|
|
||||||
# 1001 - suggestions reshaped to {"existing_ids": [...], "new_names":
|
|
||||||
# [...]} per taxonomy field (#13676)
|
|
||||||
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
|
|
||||||
|
|
||||||
CACHE_1_MINUTE: Final[int] = 60
|
CACHE_1_MINUTE: Final[int] = 60
|
||||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||||
@@ -213,11 +204,7 @@ def get_llm_suggestion_cache(
|
|||||||
doc_key = get_suggestion_cache_key(document_id)
|
doc_key = get_suggestion_cache_key(document_id)
|
||||||
data: SuggestionCacheData = cache.get(doc_key)
|
data: SuggestionCacheData = cache.get(doc_key)
|
||||||
|
|
||||||
if (
|
if data and data.classifier_hash == backend:
|
||||||
data
|
|
||||||
and data.classifier_version == LLM_CACHE_CLASSIFIER_VERSION
|
|
||||||
and data.classifier_hash == backend
|
|
||||||
):
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import TypeVar
|
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -236,58 +235,6 @@ def permitted_object_ids(
|
|||||||
).values_list("id", flat=True)
|
).values_list("id", flat=True)
|
||||||
|
|
||||||
|
|
||||||
ModelT = TypeVar("ModelT", bound=Model)
|
|
||||||
|
|
||||||
|
|
||||||
def user_is_unrestricted(user: User | None) -> bool:
|
|
||||||
"""
|
|
||||||
True when ``user`` means "no restriction at all" (an absent user, or an
|
|
||||||
*active* superuser) without needing a database check to know it.
|
|
||||||
|
|
||||||
``permitted_object_ids(None, ...)`` itself means the much narrower "only
|
|
||||||
unowned rows", which is NOT the same thing as "no user filtering
|
|
||||||
requested", so callers must special-case this before ever calling it.
|
|
||||||
A deactivated superuser is deliberately NOT unrestricted here, matching
|
|
||||||
permitted_object_ids's own is_active-before-is_superuser ordering.
|
|
||||||
|
|
||||||
Callers that can avoid a database round trip entirely when this is true
|
|
||||||
(e.g. checking a single already-loaded object's visibility rather than
|
|
||||||
filtering a queryset) should do so via this function directly, rather
|
|
||||||
than through restrict_queryset_to_visible() below.
|
|
||||||
"""
|
|
||||||
if user is None:
|
|
||||||
return True
|
|
||||||
return (
|
|
||||||
getattr(user, "is_authenticated", False)
|
|
||||||
and getattr(user, "is_active", False)
|
|
||||||
and getattr(user, "is_superuser", False)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def restrict_queryset_to_visible(
|
|
||||||
queryset: QuerySet[ModelT],
|
|
||||||
user: User | None,
|
|
||||||
perm: str,
|
|
||||||
) -> QuerySet[ModelT]:
|
|
||||||
"""
|
|
||||||
Restrict ``queryset`` to the rows ``user`` may see with ``perm``.
|
|
||||||
|
|
||||||
Delegates the visibility check to the database as a
|
|
||||||
``WHERE id IN (subquery)`` rather than materializing the full
|
|
||||||
permitted-id set into a Python collection first: a caller that only
|
|
||||||
needs to check a small handful of rows (a resolved-id list, a few
|
|
||||||
RAG-neighbour candidate ids) never pays for scanning or holding the
|
|
||||||
installation's entire taxonomy in memory to do it.
|
|
||||||
|
|
||||||
Returns ``queryset`` unchanged for user_is_unrestricted(user); every
|
|
||||||
other case is delegated to ``permitted_object_ids`` rather than
|
|
||||||
re-deciding the ordering here.
|
|
||||||
"""
|
|
||||||
if user_is_unrestricted(user):
|
|
||||||
return queryset
|
|
||||||
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
|
||||||
|
|
||||||
|
|
||||||
def permitted_document_ids(
|
def permitted_document_ids(
|
||||||
user: User | None,
|
user: User | None,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from documents.models import StoragePath
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
|
||||||
from documents.serialisers import _get_viewable_duplicates
|
from documents.serialisers import _get_viewable_duplicates
|
||||||
from documents.tests.factories import CorrespondentFactory
|
from documents.tests.factories import CorrespondentFactory
|
||||||
from documents.tests.factories import DocumentFactory
|
from documents.tests.factories import DocumentFactory
|
||||||
@@ -737,7 +736,7 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
|||||||
NOTE: this uses ``set_permissions`` (owner reassignment) rather than
|
NOTE: this uses ``set_permissions`` (owner reassignment) rather than
|
||||||
``delete`` as the operation, because Tag.tn_parent (django-treenode)
|
``delete`` as the operation, because Tag.tn_parent (django-treenode)
|
||||||
cascades deletes to descendants at the database/ORM level regardless
|
cascades deletes to descendants at the database/ORM level regardless
|
||||||
of which tags the view resolved into ``objs`` - a delete-based test
|
of which tags the view resolved into ``objs`` -- a delete-based test
|
||||||
would pass/fail based on FK cascade behavior, not on whether the
|
would pass/fail based on FK cascade behavior, not on whether the
|
||||||
descendant-expansion logic itself respected per-object permissions.
|
descendant-expansion logic itself respected per-object permissions.
|
||||||
"""
|
"""
|
||||||
@@ -784,97 +783,3 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
|||||||
assert parent.owner == requester
|
assert parent.owner == requester
|
||||||
assert permitted_child.owner == requester
|
assert permitted_child.owner == requester
|
||||||
assert unpermitted_child.owner == owner
|
assert unpermitted_child.owner == owner
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestRestrictQuerysetToVisible:
|
|
||||||
"""restrict_queryset_to_visible() returns its queryset argument
|
|
||||||
unchanged only for "no restriction at all", so the cases that may do
|
|
||||||
that have to be kept narrow."""
|
|
||||||
|
|
||||||
def test_no_user_means_no_restriction(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- No user at all (a system-triggered call)
|
|
||||||
WHEN:
|
|
||||||
- restrict_queryset_to_visible() is called
|
|
||||||
THEN:
|
|
||||||
- The queryset is returned unfiltered, rather than
|
|
||||||
permitted_object_ids(None, ...)'s narrower "unowned rows only"
|
|
||||||
"""
|
|
||||||
owner = User.objects.create_user(username="vis_none_owner")
|
|
||||||
tag = TagFactory(owner=owner)
|
|
||||||
|
|
||||||
visible = restrict_queryset_to_visible(Tag.objects.all(), None, "view_tag")
|
|
||||||
|
|
||||||
assert tag.pk in visible.values_list("pk", flat=True)
|
|
||||||
|
|
||||||
def test_active_superuser_means_no_restriction(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An active superuser
|
|
||||||
WHEN:
|
|
||||||
- restrict_queryset_to_visible() is called
|
|
||||||
THEN:
|
|
||||||
- The queryset is returned unfiltered, skipping the permission
|
|
||||||
lookup entirely
|
|
||||||
"""
|
|
||||||
superuser = User.objects.create_superuser(username="vis_active_super")
|
|
||||||
owner = User.objects.create_user(username="vis_active_super_owner")
|
|
||||||
tag = TagFactory(owner=owner)
|
|
||||||
|
|
||||||
visible = restrict_queryset_to_visible(
|
|
||||||
Tag.objects.all(),
|
|
||||||
superuser,
|
|
||||||
"view_tag",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert tag.pk in visible.values_list("pk", flat=True)
|
|
||||||
|
|
||||||
def test_inactive_superuser_is_denied_not_unrestricted(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A deactivated superuser
|
|
||||||
WHEN:
|
|
||||||
- restrict_queryset_to_visible() is called
|
|
||||||
THEN:
|
|
||||||
- No rows are visible, never the whole unrestricted queryset -
|
|
||||||
deactivation has to win over the superuser shortcut, matching
|
|
||||||
permitted_object_ids's own ordering
|
|
||||||
"""
|
|
||||||
user = User.objects.create_user(
|
|
||||||
username="vis_inactive_super",
|
|
||||||
is_active=False,
|
|
||||||
is_superuser=True,
|
|
||||||
)
|
|
||||||
TagFactory(owner=None)
|
|
||||||
TagFactory(owner=user)
|
|
||||||
|
|
||||||
visible = restrict_queryset_to_visible(Tag.objects.all(), user, "view_tag")
|
|
||||||
|
|
||||||
assert not visible.exists()
|
|
||||||
|
|
||||||
def test_regular_user_gets_permitted_ids(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An ordinary active user and a tag owned by someone else
|
|
||||||
WHEN:
|
|
||||||
- restrict_queryset_to_visible() is called
|
|
||||||
THEN:
|
|
||||||
- Only the rows permitted_object_ids() reports are visible
|
|
||||||
"""
|
|
||||||
user = User.objects.create_user(username="vis_regular")
|
|
||||||
other = User.objects.create_user(username="vis_regular_other")
|
|
||||||
own = TagFactory(owner=user)
|
|
||||||
hidden = TagFactory(owner=other)
|
|
||||||
|
|
||||||
visible_ids = set(
|
|
||||||
restrict_queryset_to_visible(
|
|
||||||
Tag.objects.all(),
|
|
||||||
user,
|
|
||||||
"view_tag",
|
|
||||||
).values_list("pk", flat=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert own.pk in visible_ids
|
|
||||||
assert hidden.pk not in visible_ids
|
|
||||||
|
|||||||
@@ -352,95 +352,20 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
mock_refresh_cache,
|
mock_refresh_cache,
|
||||||
mock_get_cache,
|
mock_get_cache,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
mock_get_cache.return_value = MagicMock(suggestions={"tags": ["tag1", "tag2"]})
|
||||||
GIVEN:
|
|
||||||
- A cached LLM classification holding the raw existing_ids/
|
|
||||||
new_names choices (never resolved object ids)
|
|
||||||
WHEN:
|
|
||||||
- ai_suggestions is requested
|
|
||||||
THEN:
|
|
||||||
- The cached choices are resolved into ids for this request
|
|
||||||
(not returned verbatim from the cache) and the cache's TTL is
|
|
||||||
refreshed
|
|
||||||
"""
|
|
||||||
mock_get_cache.return_value = MagicMock(
|
|
||||||
suggestions={
|
|
||||||
"title": "Cached Title",
|
|
||||||
"tags": {"existing_ids": [self.tag1.pk], "new_names": []},
|
|
||||||
"correspondents": {"existing_ids": [], "new_names": []},
|
|
||||||
"document_types": {"existing_ids": [], "new_names": []},
|
|
||||||
"storage_paths": {"existing_ids": [], "new_names": []},
|
|
||||||
"dates": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_login(user=self.user)
|
self.client.force_login(user=self.user)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.json()["title"], "Cached Title")
|
self.assertEqual(response.json(), {"tags": ["tag1", "tag2"]})
|
||||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
|
||||||
mock_get_cache.assert_called_once_with(
|
mock_get_cache.assert_called_once_with(
|
||||||
self.document.pk,
|
self.document.pk,
|
||||||
backend="mock_backend",
|
backend="mock_backend",
|
||||||
)
|
)
|
||||||
mock_refresh_cache.assert_called_once_with(self.document.pk)
|
mock_refresh_cache.assert_called_once_with(self.document.pk)
|
||||||
|
|
||||||
@patch("documents.views.get_llm_suggestion_cache")
|
|
||||||
@patch("documents.views.refresh_suggestions_cache")
|
|
||||||
@override_settings(
|
|
||||||
AI_ENABLED=True,
|
|
||||||
LLM_BACKEND="mock_backend",
|
|
||||||
)
|
|
||||||
def test_ai_suggestions_cache_hit_re_filters_for_narrower_requester(
|
|
||||||
self,
|
|
||||||
mock_refresh_cache,
|
|
||||||
mock_get_cache,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A cached LLM classification whose existing_ids include a tag
|
|
||||||
only visible to a broader-visibility user (e.g. the requester
|
|
||||||
who originally generated it)
|
|
||||||
- A second, non-superuser requester who may change the document
|
|
||||||
but has no permission to view that tag
|
|
||||||
WHEN:
|
|
||||||
- ai_suggestions is requested by the second requester and the
|
|
||||||
cache is hit
|
|
||||||
THEN:
|
|
||||||
- The cache hit still runs permission filtering fresh for this
|
|
||||||
requester; the invisible tag id does not leak into either the
|
|
||||||
matched or suggested tags
|
|
||||||
"""
|
|
||||||
tag_owner = User.objects.create_user(username="cache_tag_owner")
|
|
||||||
invisible_tag = Tag.objects.create(name="cache_restricted", owner=tag_owner)
|
|
||||||
requester = User.objects.create_user(username="cache_requester")
|
|
||||||
requester.user_permissions.add(
|
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=["view_document", "change_document", "view_tag"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
mock_get_cache.return_value = MagicMock(
|
|
||||||
suggestions={
|
|
||||||
"title": "Untitled",
|
|
||||||
"tags": {"existing_ids": [invisible_tag.pk], "new_names": []},
|
|
||||||
"correspondents": {"existing_ids": [], "new_names": []},
|
|
||||||
"document_types": {"existing_ids": [], "new_names": []},
|
|
||||||
"storage_paths": {"existing_ids": [], "new_names": []},
|
|
||||||
"dates": [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.force_login(user=requester)
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.json()["tags"], [])
|
|
||||||
self.assertEqual(response.json()["suggested_tags"], [])
|
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("documents.views.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
@@ -452,16 +377,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
) -> None:
|
) -> None:
|
||||||
mock_get_ai_classification.return_value = {
|
mock_get_ai_classification.return_value = {
|
||||||
"title": "AI Title",
|
"title": "AI Title",
|
||||||
"tags": {"existing_ids": [self.tag1.pk], "new_names": ["tag2"]},
|
"tags": ["tag1", "tag2"],
|
||||||
"correspondents": {
|
"correspondents": ["correspondent1"],
|
||||||
"existing_ids": [self.correspondent1.pk],
|
"document_types": ["type1"],
|
||||||
"new_names": [],
|
"storage_paths": ["path1"],
|
||||||
},
|
|
||||||
"document_types": {
|
|
||||||
"existing_ids": [self.document_type1.pk],
|
|
||||||
"new_names": [],
|
|
||||||
},
|
|
||||||
"storage_paths": {"existing_ids": [self.path1.pk], "new_names": []},
|
|
||||||
"dates": ["2023-01-01"],
|
"dates": ["2023-01-01"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,10 +422,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
|
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
|
||||||
mock_get_ai_classification.return_value = {
|
mock_get_ai_classification.return_value = {
|
||||||
"title": "KI Title",
|
"title": "KI Title",
|
||||||
"tags": {"existing_ids": [], "new_names": []},
|
"tags": [],
|
||||||
"correspondents": {"existing_ids": [], "new_names": []},
|
"correspondents": [],
|
||||||
"document_types": {"existing_ids": [], "new_names": []},
|
"document_types": [],
|
||||||
"storage_paths": {"existing_ids": [], "new_names": []},
|
"storage_paths": [],
|
||||||
"dates": [],
|
"dates": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,10 +461,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
|
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
|
||||||
mock_get_ai_classification.return_value = {
|
mock_get_ai_classification.return_value = {
|
||||||
"title": "Titre IA",
|
"title": "Titre IA",
|
||||||
"tags": {"existing_ids": [], "new_names": []},
|
"tags": [],
|
||||||
"correspondents": {"existing_ids": [], "new_names": []},
|
"correspondents": [],
|
||||||
"document_types": {"existing_ids": [], "new_names": []},
|
"document_types": [],
|
||||||
"storage_paths": {"existing_ids": [], "new_names": []},
|
"storage_paths": [],
|
||||||
"dates": [],
|
"dates": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,10 +502,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
either yields a cache miss instead of a stale hit."""
|
either yields a cache miss instead of a stale hit."""
|
||||||
mock_get_ai_classification.return_value = {
|
mock_get_ai_classification.return_value = {
|
||||||
"title": "Answer A",
|
"title": "Answer A",
|
||||||
"tags": {"existing_ids": [], "new_names": []},
|
"tags": [],
|
||||||
"correspondents": {"existing_ids": [], "new_names": []},
|
"correspondents": [],
|
||||||
"document_types": {"existing_ids": [], "new_names": []},
|
"document_types": [],
|
||||||
"storage_paths": {"existing_ids": [], "new_names": []},
|
"storage_paths": [],
|
||||||
"dates": [],
|
"dates": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,132 +579,6 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
|
||||||
@override_settings(
|
|
||||||
AI_ENABLED=True,
|
|
||||||
LLM_BACKEND="mock_backend",
|
|
||||||
)
|
|
||||||
def test_ai_suggestions_combines_existing_ids_and_new_names(
|
|
||||||
self,
|
|
||||||
mock_get_ai_classification,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- AI classification returns a taxonomy choice with both an
|
|
||||||
existing tag id and a new tag name not present in the database
|
|
||||||
WHEN:
|
|
||||||
- ai_suggestions is requested
|
|
||||||
THEN:
|
|
||||||
- the existing id is resolved into the matched tags list
|
|
||||||
- the new name is fuzzy-matched, and since it doesn't match any
|
|
||||||
existing tag, it is surfaced as a suggested tag
|
|
||||||
"""
|
|
||||||
mock_get_ai_classification.return_value = {
|
|
||||||
"title": "Lab Report",
|
|
||||||
"tags": {"existing_ids": [self.tag1.pk], "new_names": ["Follow-up"]},
|
|
||||||
"correspondents": {"existing_ids": [], "new_names": []},
|
|
||||||
"document_types": {"existing_ids": [], "new_names": []},
|
|
||||||
"storage_paths": {"existing_ids": [], "new_names": []},
|
|
||||||
"dates": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
self.client.force_login(user=self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
|
||||||
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
|
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
|
||||||
@override_settings(
|
|
||||||
AI_ENABLED=True,
|
|
||||||
LLM_BACKEND="mock_backend",
|
|
||||||
)
|
|
||||||
def test_ai_suggestions_deduplicates_id_matched_via_both_paths(
|
|
||||||
self,
|
|
||||||
mock_get_ai_classification,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- AI classification returns the same tag both as an existing_id
|
|
||||||
and as a new_name that fuzzy-matches that same tag
|
|
||||||
WHEN:
|
|
||||||
- ai_suggestions is requested
|
|
||||||
THEN:
|
|
||||||
- The tag's id appears exactly once in the response, not twice
|
|
||||||
"""
|
|
||||||
mock_get_ai_classification.return_value = {
|
|
||||||
"title": "Lab Report",
|
|
||||||
"tags": {
|
|
||||||
"existing_ids": [self.tag1.pk],
|
|
||||||
"new_names": [self.tag1.name],
|
|
||||||
},
|
|
||||||
"correspondents": {"existing_ids": [], "new_names": []},
|
|
||||||
"document_types": {"existing_ids": [], "new_names": []},
|
|
||||||
"storage_paths": {"existing_ids": [], "new_names": []},
|
|
||||||
"dates": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
self.client.force_login(user=self.user)
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
|
||||||
self.assertEqual(response.json()["suggested_tags"], [])
|
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
|
||||||
@override_settings(
|
|
||||||
AI_ENABLED=True,
|
|
||||||
LLM_BACKEND="mock_backend",
|
|
||||||
)
|
|
||||||
def test_ai_suggestions_existing_id_not_visible_falls_through_to_suggested(
|
|
||||||
self,
|
|
||||||
mock_get_ai_classification,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A non-superuser who may change the document but has no
|
|
||||||
permission to view a tag owned by somebody else
|
|
||||||
- AI classification returns that tag's id in existing_ids (e.g.
|
|
||||||
from a cached response generated for a broader-visibility user)
|
|
||||||
WHEN:
|
|
||||||
- ai_suggestions is requested by that user
|
|
||||||
THEN:
|
|
||||||
- the invisible id is silently dropped by resolve_tag_ids, so
|
|
||||||
permission filtering survives the full request path
|
|
||||||
- it does not appear in either the matched or suggested tags
|
|
||||||
"""
|
|
||||||
tag_owner = User.objects.create_user(username="tagowner")
|
|
||||||
invisible_tag = Tag.objects.create(name="restricted", owner=tag_owner)
|
|
||||||
requester = User.objects.create_user(username="requester")
|
|
||||||
requester.user_permissions.add(
|
|
||||||
*Permission.objects.filter(
|
|
||||||
codename__in=["view_document", "change_document", "view_tag"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_get_ai_classification.return_value = {
|
|
||||||
"title": "Untitled",
|
|
||||||
"tags": {"existing_ids": [invisible_tag.pk], "new_names": []},
|
|
||||||
"correspondents": {"existing_ids": [], "new_names": []},
|
|
||||||
"document_types": {"existing_ids": [], "new_names": []},
|
|
||||||
"storage_paths": {"existing_ids": [], "new_names": []},
|
|
||||||
"dates": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
self.client.force_login(user=requester)
|
|
||||||
response = self.client.get(
|
|
||||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(response.json()["tags"], [])
|
|
||||||
self.assertEqual(response.json()["suggested_tags"], [])
|
|
||||||
|
|
||||||
def test_invalidate_suggestions_cache(self) -> None:
|
def test_invalidate_suggestions_cache(self) -> None:
|
||||||
self.client.force_login(user=self.user)
|
self.client.force_login(user=self.user)
|
||||||
suggestions = {
|
suggestions = {
|
||||||
|
|||||||
+45
-99
@@ -7,7 +7,6 @@ import tempfile
|
|||||||
import zipfile
|
import zipfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -250,10 +249,6 @@ from paperless_ai.matching import match_correspondents_by_name
|
|||||||
from paperless_ai.matching import match_document_types_by_name
|
from paperless_ai.matching import match_document_types_by_name
|
||||||
from paperless_ai.matching import match_storage_paths_by_name
|
from paperless_ai.matching import match_storage_paths_by_name
|
||||||
from paperless_ai.matching import match_tags_by_name
|
from paperless_ai.matching import match_tags_by_name
|
||||||
from paperless_ai.matching import resolve_correspondent_ids
|
|
||||||
from paperless_ai.matching import resolve_document_type_ids
|
|
||||||
from paperless_ai.matching import resolve_storage_path_ids
|
|
||||||
from paperless_ai.matching import resolve_tag_ids
|
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
||||||
@@ -263,9 +258,6 @@ from paperless_mail.serialisers import MailRuleSerializer
|
|||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from paperless_ai.base_model import TaxonomyChoiceDict
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.api")
|
logger = logging.getLogger("paperless.api")
|
||||||
|
|
||||||
@@ -1554,126 +1546,80 @@ class DocumentViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if cached_llm_suggestions:
|
if cached_llm_suggestions:
|
||||||
# Only the raw model choices are cached, never resolved object
|
|
||||||
# ids. resolve_choice() below still runs permission filtering
|
|
||||||
# freshly for this requester on every request, cache hit or not,
|
|
||||||
# so a resolved id cached for one user's visibility can never be
|
|
||||||
# handed unfiltered to a second, less-privileged requester of
|
|
||||||
# the same (backend-keyed, not user-keyed) cache entry.
|
|
||||||
refresh_suggestions_cache(doc.pk)
|
refresh_suggestions_cache(doc.pk)
|
||||||
llm_suggestions = cached_llm_suggestions.suggestions
|
return Response(cached_llm_suggestions.suggestions)
|
||||||
else:
|
|
||||||
try:
|
|
||||||
llm_suggestions = get_ai_document_classification(
|
|
||||||
doc,
|
|
||||||
request.user,
|
|
||||||
output_language,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
logger.exception(
|
|
||||||
"Invalid AI configuration while generating suggestions for "
|
|
||||||
"document %s: %s",
|
|
||||||
doc.pk,
|
|
||||||
exc,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise ValidationError(
|
|
||||||
{"ai": [_("Invalid AI configuration.")]},
|
|
||||||
) from exc
|
|
||||||
except LLMTimeoutError as exc:
|
|
||||||
logger.exception(
|
|
||||||
"AI backend timed out while generating suggestions for "
|
|
||||||
"document %s: %s",
|
|
||||||
doc.pk,
|
|
||||||
exc,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
{"ai": [_("AI backend request timed out.")]},
|
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
)
|
|
||||||
set_llm_suggestions_cache(
|
|
||||||
doc.pk,
|
|
||||||
llm_suggestions,
|
|
||||||
backend=llm_cache_backend,
|
|
||||||
)
|
|
||||||
|
|
||||||
tags_choice: TaxonomyChoiceDict = llm_suggestions["tags"]
|
try:
|
||||||
correspondents_choice: TaxonomyChoiceDict = llm_suggestions["correspondents"]
|
llm_suggestions = get_ai_document_classification(
|
||||||
document_types_choice: TaxonomyChoiceDict = llm_suggestions["document_types"]
|
doc,
|
||||||
storage_paths_choice: TaxonomyChoiceDict = llm_suggestions["storage_paths"]
|
|
||||||
|
|
||||||
def resolve_choice(
|
|
||||||
choice: "TaxonomyChoiceDict",
|
|
||||||
resolve_ids: Callable[[list[int], User], list],
|
|
||||||
match_names: Callable[[list[str], User], list],
|
|
||||||
) -> list:
|
|
||||||
"""The ids the model picked from the candidates it was shown, plus
|
|
||||||
name matches for the values it proposed as new. The schema allows
|
|
||||||
the same object to satisfy both an existing_id and a new_name in
|
|
||||||
one valid response, so results are deduplicated by pk (keeping
|
|
||||||
first-seen order) rather than trusting the two lookups to be
|
|
||||||
disjoint.
|
|
||||||
"""
|
|
||||||
matched = resolve_ids(choice["existing_ids"], request.user) + match_names(
|
|
||||||
choice["new_names"],
|
|
||||||
request.user,
|
request.user,
|
||||||
|
output_language,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Invalid AI configuration while generating suggestions for "
|
||||||
|
"document %s: %s",
|
||||||
|
doc.pk,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise ValidationError({"ai": [_("Invalid AI configuration.")]}) from exc
|
||||||
|
except LLMTimeoutError as exc:
|
||||||
|
logger.exception(
|
||||||
|
"AI backend timed out while generating suggestions for document %s: %s",
|
||||||
|
doc.pk,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{"ai": [_("AI backend request timed out.")]},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
seen_ids: set[int] = set()
|
|
||||||
deduped = []
|
|
||||||
for obj in matched:
|
|
||||||
if obj.pk in seen_ids:
|
|
||||||
continue
|
|
||||||
seen_ids.add(obj.pk)
|
|
||||||
deduped.append(obj)
|
|
||||||
return deduped
|
|
||||||
|
|
||||||
matched_tags = resolve_choice(
|
matched_tags = match_tags_by_name(
|
||||||
tags_choice,
|
llm_suggestions.get("tags", []),
|
||||||
resolve_tag_ids,
|
request.user,
|
||||||
match_tags_by_name,
|
|
||||||
)
|
)
|
||||||
matched_correspondents = resolve_choice(
|
matched_correspondents = match_correspondents_by_name(
|
||||||
correspondents_choice,
|
llm_suggestions.get("correspondents", []),
|
||||||
resolve_correspondent_ids,
|
request.user,
|
||||||
match_correspondents_by_name,
|
|
||||||
)
|
)
|
||||||
matched_types = resolve_choice(
|
matched_types = match_document_types_by_name(
|
||||||
document_types_choice,
|
llm_suggestions.get("document_types", []),
|
||||||
resolve_document_type_ids,
|
request.user,
|
||||||
match_document_types_by_name,
|
|
||||||
)
|
)
|
||||||
matched_paths = resolve_choice(
|
matched_paths = match_storage_paths_by_name(
|
||||||
storage_paths_choice,
|
llm_suggestions.get("storage_paths", []),
|
||||||
resolve_storage_path_ids,
|
request.user,
|
||||||
match_storage_paths_by_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
resp_data = {
|
resp_data = {
|
||||||
"title": llm_suggestions["title"],
|
"title": llm_suggestions.get("title"),
|
||||||
"tags": [t.id for t in matched_tags],
|
"tags": [t.id for t in matched_tags],
|
||||||
"suggested_tags": extract_unmatched_names(
|
"suggested_tags": extract_unmatched_names(
|
||||||
tags_choice["new_names"],
|
llm_suggestions.get("tags", []),
|
||||||
matched_tags,
|
matched_tags,
|
||||||
),
|
),
|
||||||
"correspondents": [c.id for c in matched_correspondents],
|
"correspondents": [c.id for c in matched_correspondents],
|
||||||
"suggested_correspondents": extract_unmatched_names(
|
"suggested_correspondents": extract_unmatched_names(
|
||||||
correspondents_choice["new_names"],
|
llm_suggestions.get("correspondents", []),
|
||||||
matched_correspondents,
|
matched_correspondents,
|
||||||
),
|
),
|
||||||
"document_types": [d.id for d in matched_types],
|
"document_types": [d.id for d in matched_types],
|
||||||
"suggested_document_types": extract_unmatched_names(
|
"suggested_document_types": extract_unmatched_names(
|
||||||
document_types_choice["new_names"],
|
llm_suggestions.get("document_types", []),
|
||||||
matched_types,
|
matched_types,
|
||||||
),
|
),
|
||||||
"storage_paths": [s.id for s in matched_paths],
|
"storage_paths": [s.id for s in matched_paths],
|
||||||
"suggested_storage_paths": extract_unmatched_names(
|
"suggested_storage_paths": extract_unmatched_names(
|
||||||
storage_paths_choice["new_names"],
|
llm_suggestions.get("storage_paths", []),
|
||||||
matched_paths,
|
matched_paths,
|
||||||
),
|
),
|
||||||
"dates": llm_suggestions["dates"],
|
"dates": llm_suggestions.get("dates", []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set_llm_suggestions_cache(doc.pk, resp_data, backend=llm_cache_backend)
|
||||||
|
|
||||||
return Response(resp_data)
|
return Response(resp_data)
|
||||||
|
|
||||||
@action(methods=["get"], detail=True, filter_backends=[])
|
@action(methods=["get"], detail=True, filter_backends=[])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-08-14 22:52+0000\n"
|
"POT-Creation-Date: 2026-08-13 19:47+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1576,7 +1576,7 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:522 documents/serialisers.py:874
|
#: documents/serialisers.py:522 documents/serialisers.py:874
|
||||||
#: documents/serialisers.py:2769 documents/views.py:307 documents/views.py:2609
|
#: documents/serialisers.py:2769 documents/views.py:299 documents/views.py:2555
|
||||||
#: paperless_mail/serialisers.py:155
|
#: paperless_mail/serialisers.py:155
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1617,7 +1617,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2855 documents/views.py:4563
|
#: documents/serialisers.py:2855 documents/views.py:4509
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1885,36 +1885,36 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:300 documents/views.py:2606
|
#: documents/views.py:292 documents/views.py:2552
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1581
|
#: documents/views.py:1566
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1592
|
#: documents/views.py:1575
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2431 documents/views.py:2752
|
#: documents/views.py:2377 documents/views.py:2698
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4576
|
#: documents/views.py:4522
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4622
|
#: documents/views.py:4568
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4683
|
#: documents/views.py:4629
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4693
|
#: documents/views.py:4639
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1034
-1254
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user