mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-27 21:23:20 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94651d817d | ||
|
|
1630e78aac | ||
|
|
4e79553489 | ||
|
|
f24d58a383 | ||
|
|
d486dd1205 | ||
|
|
406bc1a233 | ||
|
|
808746c28c | ||
|
|
ff6d73ce17 | ||
|
|
b4068d38bc | ||
|
|
139739c2a2 | ||
|
|
aad75b4981 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = ''
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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/',
|
||||
|
||||
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2310
-1139
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2309
-1138
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2336
-1165
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2499
-1328
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+3566
-2396
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2529
-1359
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2344
-1173
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
+2309
-1138
File diff suppressed because it is too large
Load Diff
+2310
-1139
File diff suppressed because it is too large
Load Diff
+2307
-1136
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)",
|
||||
|
||||
+1078
-782
File diff suppressed because it is too large
Load Diff
+1086
-790
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
+1063
-767
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
+1062
-766
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1086
-790
File diff suppressed because it is too large
Load Diff
+1078
-782
File diff suppressed because it is too large
Load Diff
+1078
-782
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