Compare commits

..
121 changed files with 161279 additions and 92258 deletions
+10
View File
@@ -8,6 +8,12 @@ common [OCR](#ocr) related settings and some frontend settings. If set, these wi
preference over the settings via environment variables. If not set, the environment setting
or applicable default will be utilized instead.
!!! warning
Changing configuration from the UI requires the `AppConfig` permission, which applies
instance-wide and should be treated as an admin-level permission. See
[global permissions](usage.md#global-permissions).
- If you run paperless on docker, `paperless.conf` is not used.
Rather, configure paperless by copying necessary options to
`docker-compose.env`.
@@ -1125,6 +1131,10 @@ they use underscores instead of dashes.
so specifying invalid options may prevent paperless from consuming
any documents. Use with caution!
These arguments are passed directly to OCRmyPDF, so this setting should only
be changed by trusted users. This applies to the `AppConfig` permission as well,
which allows setting these arguments from the UI.
Specify arguments as a JSON dictionary. Keep note of lower case
booleans and double quoted parameter names and strings. Examples:
+21 -21
View File
@@ -427,27 +427,27 @@ Global permissions define what areas of the app and API endpoints users can acce
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
still have "object-level" permissions.
| Type | Details |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
| Correspondent | Add, edit, delete or view Correspondents. |
| CustomField | Add, edit, delete or view Custom Fields. |
| Document | Add, edit, delete or view Documents. |
| DocumentType | Add, edit, delete or view Document Types. |
| Group | Add, edit, delete or view Groups. |
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
| MailAccount | Add, edit, delete or view Mail Accounts. |
| MailRule | Add, edit, delete or view Mail Rules. |
| Note | Add, edit, delete or view Notes. |
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
| SavedView | Add, edit, delete or view Saved Views. |
| ShareLink | Add, delete or view Share Links. |
| StoragePath | Add, edit, delete or view Storage Paths. |
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
| Tag | Add, edit, delete or view Tags. |
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
| Type | Details |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area.<br/>:warning: **This is a trusted, admin-level permission.** Application configuration applies instance-wide and some settings, such as OCR arguments, are passed directly to the underlying processing tools. |
| Correspondent | Add, edit, delete or view Correspondents. |
| CustomField | Add, edit, delete or view Custom Fields. |
| Document | Add, edit, delete or view Documents. |
| DocumentType | Add, edit, delete or view Document Types. |
| Group | Add, edit, delete or view Groups. |
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
| MailAccount | Add, edit, delete or view Mail Accounts. |
| MailRule | Add, edit, delete or view Mail Rules. |
| Note | Add, edit, delete or view Notes. |
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
| SavedView | Add, edit, delete or view Saved Views. |
| ShareLink | Add, delete or view Share Links. |
| StoragePath | Add, edit, delete or view Storage Paths. |
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
| Tag | Add, edit, delete or view Tags. |
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
#### Detailed Explanation of Object Permissions {#object-permissions}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "3.0.5"
version = "3.1.0"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
+9 -2
View File
@@ -4639,7 +4639,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
<context context-type="linenumber">53</context>
<context context-type="linenumber">58</context>
</context-group>
</trans-unit>
<trans-unit id="5324147361912094446" datatype="html">
@@ -6488,7 +6488,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
<context context-type="linenumber">71</context>
<context context-type="linenumber">76</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/tags/tags.component.html</context>
@@ -6614,6 +6614,13 @@
</context-group>
<note priority="1" from="description">Used for both types, correspondents, storage paths</note>
</trans-unit>
<trans-unit id="6945988051184690124" datatype="html">
<source>Remove item</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/select/select.component.html</context>
<context context-type="linenumber">41</context>
</context-group>
</trans-unit>
<trans-unit id="3686284950598311784" datatype="html">
<source>Private</source>
<context-group purpose="location">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "3.0.5",
"version": "3.1.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
@@ -6,7 +6,7 @@
infoLink="configuration">
</pngx-page-header>
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4" autocomplete="off">
<ul ngbNav #nav="ngbNav" class="nav-tabs">
@for (category of optionCategories; track category) {
@@ -44,7 +44,7 @@
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]" autocomplete="new-password"></pngx-input-password> }
}
</div>
@if (option.note) {
@@ -37,7 +37,7 @@
}
<pngx-toasts-dropdown></pngx-toasts-dropdown>
<li ngbDropdown class="nav-item dropdown">
<button class="btn navbar-action border-0" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
<button class="btn navbar-action border-0 me-1 me-md-2" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
<span class="small ms-2 d-none d-sm-inline">
{{this.settingsService.displayName}}
@@ -436,6 +436,18 @@ main {
padding-right: .5rem;
}
// true center the search with equal flex widths
@media (min-width: 768px) {
.navbar-brand,
.navbar > ul {
flex: 1 1 0;
}
.navbar > ul {
justify-content: flex-end;
}
}
:host ::ng-deep .navbar-action {
display: inline-flex;
align-items: center;
@@ -40,6 +40,10 @@ describe('PasswordComponent', () => {
// expect(component.value).toEqual('foo')
})
it('should not offer itself to browser autofill by default', () => {
expect(input.getAttribute('autocomplete')).toEqual('off')
})
it('should support toggling field visibility', () => {
expect(input.type).toEqual('password')
component.showReveal = true
@@ -25,7 +25,7 @@ export class PasswordComponent extends AbstractInputComponent<string> {
showReveal: boolean = false
@Input()
autocomplete: string
autocomplete: string = 'off'
public textVisible: boolean = false
@@ -36,11 +36,16 @@
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
<ng-template ng-label-tmp let-item="item">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
<ng-template ng-label-tmp let-item="item" let-clear="clear">
@if (multiple && !disabled) {
<span class="ng-value-icon left" role="button" tabindex="0" (click)="clear(item)" (keydown.enter)="clear(item)" aria-label="Remove item" i18n-aria-label>×</span>
}
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
<span class="ng-value-label" [title]="item[bindLabel]">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
}
{{item[bindLabel]}}
</span>
</ng-template>
<ng-template ng-option-tmp let-item="item">
@if (iconField && item[iconField]) {
@@ -24,6 +24,12 @@ describe('TextComponent', () => {
input = component.inputField.nativeElement
})
it('should not offer itself to browser autofill by default', () => {
expect(
component.inputField.nativeElement.getAttribute('autocomplete')
).toEqual('off')
})
it('should support use of input field', () => {
expect(component.value).toBeUndefined()
input.value = 'foo'
@@ -28,7 +28,7 @@ import { AbstractInputComponent } from '../abstract-input'
})
export class TextComponent extends AbstractInputComponent<string> {
@Input()
autocomplete: string
autocomplete: string = 'off'
@Input()
placeholder: string = ''
+29
View File
@@ -15,6 +15,35 @@ describe('text search utilities', () => {
expect(matchesSearchText('taxes 2026', 'tax receipt')).toBeFalsy()
})
it('does not let two terms match the same word', () => {
expect(matchesSearchText('Another Tag', 'another tag th')).toBeFalsy()
expect(matchesSearchText('Another Tag', 'another tag ag')).toBeFalsy()
expect(matchesSearchText('Another Tag', 'another tag e')).toBeFalsy()
expect(matchesSearchText('Another Tag', 'another tag')).toBeTruthy()
expect(matchesSearchText('Another Tag', 'tag another')).toBeTruthy()
})
it('matches a single term anywhere in the value', () => {
expect(matchesSearchText('Another Tag', 'anoth')).toBeTruthy()
expect(matchesSearchText('Another Tag', 'th')).toBeTruthy()
})
it('treats punctuation as a separator on both sides', () => {
expect(matchesSearchText('medical-history', 'medical history')).toBeTruthy()
expect(matchesSearchText('medical history', 'medical-history')).toBeTruthy()
expect(matchesSearchText('medical-history', 'medical dental')).toBeFalsy()
})
it('matches longer terms first so they cannot be starved', () => {
expect(matchesSearchText('tagger tag', 'tag tagger')).toBeTruthy()
})
it('handles a query with no usable terms', () => {
expect(matchesSearchText('Another Tag', '')).toBeTruthy()
// Still filters, so the dropdown can offer to create a tag named "---"
expect(matchesSearchText('Another Tag', '---')).toBeFalsy()
})
it('matches a large set of tag names without blocking input', () => {
const tagNames = Array.from(
{ length: 1280 },
+34 -9
View File
@@ -3,13 +3,18 @@ import { diacritics } from 'normalize-diacritics/diacritics'
export type SearchTextValue =
string | number | boolean | bigint | null | undefined
const NON_ASCII = /[^\x00-\x7F]/
const SEPARATORS = /[^\p{L}\p{N}]+/u
export function normalizeSearchText(value: SearchTextValue): string {
const normalized = diacritics.reduce(
(text, replacement) => {
return text.replace(replacement.diacritics, replacement.letter)
},
String(value ?? '')
)
const text = String(value ?? '')
// Nothing in the table matches ASCII, so skip normaliation
if (!NON_ASCII.test(text)) return text.toLocaleLowerCase()
const normalized = diacritics.reduce((text, replacement) => {
return text.replace(replacement.diacritics, replacement.letter)
}, text)
return normalized.toLocaleLowerCase()
}
@@ -18,8 +23,28 @@ export function matchesSearchText(
value: SearchTextValue,
searchText: SearchTextValue
): boolean {
const normalizedValue = normalizeSearchText(value)
const searchTerms = normalizeSearchText(searchText).trim().split(/\s+/)
const query = normalizeSearchText(searchText)
const terms = query.split(SEPARATORS).filter(Boolean)
return searchTerms.every((term) => normalizedValue.includes(term))
// Empty or punctuation-only query, nothing to split into terms
if (terms.length === 0) {
return normalizeSearchText(value).includes(query.trim())
}
const words = normalizeSearchText(value).split(SEPARATORS).filter(Boolean)
const claimed = new Array<boolean>(words.length).fill(false)
// Each term takes a word of its own, longest first, so that "another tag th"
// doesn't match "Another Tag" by finding the "th" inside "another"
return terms
.sort((a, b) => b.length - a.length)
.every((term) => {
for (let i = 0; i < words.length; i++) {
if (!claimed[i] && words[i].includes(term)) {
claimed[i] = true
return true
}
}
return false
})
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE,
tag: 'prod',
version: '3.0.5',
version: '3.1.0',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',
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
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
+17 -7
View File
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
from typing import Any
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.db.models import Case
from django.db.models import CharField
from django.db.models import Count
@@ -50,7 +51,6 @@ from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import permitted_object_ids
from documents.versioning import ensure_effective_content
if TYPE_CHECKING:
from collections.abc import Callable
@@ -180,9 +180,14 @@ class TitleContentFilter(Filter):
logger.warning(
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
)
return ensure_effective_content(qs).filter(
Q(title__icontains=value) | Q(effective_content__icontains=value),
)
try:
return qs.filter(
Q(title__icontains=value) | Q(effective_content__icontains=value),
)
except FieldError:
return qs.filter(
Q(title__icontains=value) | Q(content__icontains=value),
)
else:
return qs
@@ -193,9 +198,14 @@ class EffectiveContentFilter(Filter):
value = value.strip() if isinstance(value, str) else value
if not value:
return qs
return ensure_effective_content(qs).filter(
**{f"effective_content__{self.lookup_expr}": value},
)
try:
return qs.filter(
**{f"effective_content__{self.lookup_expr}": value},
)
except FieldError:
return qs.filter(
**{f"content__{self.lookup_expr}": value},
)
@extend_schema_field(serializers.BooleanField)
@@ -2,12 +2,14 @@ from __future__ import annotations
import datetime
from typing import TYPE_CHECKING
from unittest import TestCase
from unittest import mock
from auditlog.models import LogEntry # type: ignore[import-untyped]
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase as DjangoTestCase
from django.utils import timezone
@@ -20,7 +22,6 @@ from documents.filters import TitleContentFilter
from documents.models import Document
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import read_streaming_response
from documents.versioning import annotate_effective_content
from documents.views import DocumentSelectionMixin
if TYPE_CHECKING:
@@ -890,103 +891,32 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
)
class TestVersionAwareFilters(DjangoTestCase):
"""
The filters annotate effective_content themselves rather than relying on
the caller's queryset carrying it, so they stay version-aware on a plain
Document queryset (e.g. the bulk-edit "select all matching" path).
"""
class TestVersionAwareFilters(TestCase):
def test_title_content_filter_falls_back_to_content(self) -> None:
queryset = mock.Mock()
fallback_queryset = mock.Mock()
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
def setUp(self) -> None:
super().setUp()
self.root = Document.objects.create(
title="root",
checksum="root",
mime_type="application/pdf",
content="superseded-content",
)
Document.objects.create(
title="version",
checksum="version",
mime_type="application/pdf",
root_document=self.root,
version_index=1,
content="latest-content",
)
self.unversioned = Document.objects.create(
title="unversioned",
checksum="unversioned",
mime_type="application/pdf",
content="latest-content",
)
result = TitleContentFilter().filter(queryset, " latest ")
def test_title_content_filter_matches_latest_version_content(self) -> None:
result = TitleContentFilter().filter(
Document.objects.filter(root_document__isnull=True),
self.assertIs(result, fallback_queryset)
self.assertEqual(queryset.filter.call_count, 2)
def test_effective_content_filter_falls_back_to_content_lookup(self) -> None:
queryset = mock.Mock()
fallback_queryset = mock.Mock()
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
result = EffectiveContentFilter(lookup_expr="icontains").filter(
queryset,
" latest ",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_effective_content_filter_matches_latest_version_content(self) -> None:
result = EffectiveContentFilter(lookup_expr="icontains").filter(
Document.objects.filter(root_document__isnull=True),
" latest ",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_effective_content_filter_ignores_superseded_content(self) -> None:
result = EffectiveContentFilter(lookup_expr="icontains").filter(
Document.objects.filter(root_document__isnull=True),
"superseded",
)
self.assertEqual(list(result), [])
def test_filters_reuse_an_existing_annotation(self) -> None:
"""
Annotating twice under the same alias is an error, so an already
annotated queryset (the search path) has to be left alone.
"""
annotated = annotate_effective_content(
Document.objects.filter(root_document__isnull=True),
)
result = EffectiveContentFilter(lookup_expr="icontains").filter(
annotated,
"latest",
)
self.assertCountEqual(
[doc.id for doc in result],
[self.root.id, self.unversioned.id],
)
def test_bulk_selection_does_not_match_superseded_content(self) -> None:
"""
Bulk edit's "select all matching" builds its own queryset, so before
the filters annotated for themselves it matched the root document's
superseded content -- selecting documents the list view, filtered by
the same term, does not show.
"""
user = User.objects.create_superuser(username="bulk_selection")
selected = DocumentSelectionMixin()._resolve_document_ids(
user=user,
validated_data={
"all": True,
"filters": {"content__icontains": "superseded"},
},
)
self.assertEqual(selected, [])
self.assertIs(result, fallback_queryset)
first_kwargs = queryset.filter.call_args_list[0].kwargs
second_kwargs = queryset.filter.call_args_list[1].kwargs
self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"})
self.assertEqual(second_kwargs, {"content__icontains": "latest"})
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
queryset = mock.Mock()
-23
View File
@@ -1917,29 +1917,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
self.assertEqual(len(response.data["documents"]), 1)
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
def test_global_search_returns_latest_version_content(self) -> None:
root = Document.objects.create(
title="bank statement",
content="superseded content",
checksum="GSV1",
pk=23,
)
Document.objects.create(
title="bank statement v2",
content="latest content",
checksum="GSV2",
pk=24,
root_document=root,
version_index=1,
)
self.client.force_authenticate(self.user)
response = self.client.get("/api/search/?query=bank&db_only=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
self.assertEqual(returned.get(root.id), "latest content")
def test_global_search_filters_owned_mail_objects(self) -> None:
user1 = User.objects.create_user("mail-search-user")
user2 = User.objects.create_user("other-mail-search-user")
+35
View File
@@ -5573,6 +5573,41 @@ class TestApplyAISuggestionsWorkflowAction(
self.doc.refresh_from_db()
return changed
def test_fields_persist_when_tags_are_applied_in_the_same_run(self) -> None:
"""
GIVEN:
- A document that already has a filename, as any consumed document does
- Suggestions carrying both a document type and tags
WHEN:
- The suggestions are applied
THEN:
- The document type is still set after the tags are added
Adding tags fires m2m_changed, and update_filename_and_move_files
refreshes the document from the database. Assigning fields and then
adding tags before saving loses those assignments, and only for
documents with a filename, so it does not reproduce on a bare
Document.objects.create().
"""
self.doc.filename = "originals/original.pdf"
self.doc.save(update_fields=["filename"])
action = self.make_action(ai_create_missing=True)
changed = self.apply(action)
self.assertIn("document_type", changed)
self.assertIn("tags", changed)
self.assertIsNotNone(
self.doc.document_type,
"document_type was reported as applied but did not persist",
)
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
self.assertEqual(self.doc.correspondent.name, "Existing Correspondent")
self.assertCountEqual(
[t.name for t in self.doc.tags.all()],
["Existing Tag", "Suggested Tag"],
)
def test_document_added_trigger_queues_task(self) -> None:
"""
GIVEN:
-15
View File
@@ -43,21 +43,6 @@ def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Docume
)
def ensure_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
"""
Annotates effective_content unless the queryset already carries it.
Lets a filter depend on effective_content without having to assume its
caller annotated one -- annotating twice under the same alias is an error,
and silently matching on the root document's own content instead is worse,
because the same filter then selects different documents depending on which
queryset it was handed.
"""
if "effective_content" in documents.query.annotations:
return documents
return annotate_effective_content(documents)
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
"""
Same sorting as versions_newest_first()
+2 -8
View File
@@ -230,7 +230,6 @@ from documents.tasks import train_classifier
from documents.tasks import update_document_parent_tags
from documents.utils import get_boolean
from documents.versioning import VersionResolutionError
from documents.versioning import annotate_effective_content
from documents.versioning import get_latest_version_for_root
from documents.versioning import get_request_version_param
from documents.versioning import get_root_document
@@ -3614,13 +3613,8 @@ class GlobalSearchView(PassUserMixin):
OBJECT_LIMIT = 3
docs = []
if request.user.has_perm("documents.view_document"):
# Never more than OBJECT_LIMIT rows come back here, so annotating
# is cheap -- and without it these results show the root
# document's superseded content.
all_docs = annotate_effective_content(
Document.objects.filter(
id__in=permitted_document_ids(request.user),
),
all_docs = Document.objects.filter(
id__in=permitted_document_ids(request.user),
)
if db_only:
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
+7 -3
View File
@@ -226,20 +226,20 @@ def apply_ai_suggestions_to_document(
document.created = created
updated_fields.append("created")
tags_to_add: list[Tag] = []
if AISuggestionField.TAGS in selected:
choice = suggestions["tags"]
names = choice["new_names"]
tags = resolve_tags(
tags_to_add = resolve_tags(
names,
resolve_tag_ids(choice["existing_ids"], owner)
+ match_tags_by_name(names, owner),
create_missing=create_missing,
owner=owner,
)
if tags:
if tags_to_add:
# Suggested tags are always added, so overwrite_existing
# does not really apply here
document.add_nested_tags(tags)
updated_fields.append("tags")
if updated_fields:
@@ -249,6 +249,10 @@ def apply_ai_suggestions_to_document(
]
document.save(update_fields=[*direct_updated_fields, "modified"])
# Tags at the end so m2m_changed doesn't trigger db and overwrite other changes
if tags_to_add:
document.add_nested_tags(tags_to_add)
logger.info(
"Applied AI suggestions %s to document %s",
updated_fields or "(none)",
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
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