Compare commits

...
Author SHA1 Message Date
shamoon f4f1de4c0e Include animating to prevent toggle showing early 2026-09-08 12:32:37 -07:00
shamoon ca1f78336f fix slim, prefer utiltiy classes 2026-09-08 12:27:27 -07:00
shamoon aadbbddcef these need to work on the same unsaved settings, drop the sub stuff 2026-09-08 12:17:33 -07:00
shamoon 5ecf06463d Wiring up the toggles 2026-09-08 12:09:59 -07:00
shamoon d66c681aae Add a little compact mode to the switch 2026-09-08 12:07:48 -07:00
shamoon 4fb6182b41 Actually hide them 2026-09-08 11:55:24 -07:00
shamoon b1cb41438d A little more machinery here 2026-09-08 11:52:11 -07:00
shamoon d60ec3cd28 Add some basic underpinning 2026-09-08 11:16:24 -07:00
GitHub Actions b989b74140 Auto translate strings 2026-09-08 15:57:05 +00:00
shamoon 5194f47291 Performance: ensure version-aware content filters on querysets (#13792) 2026-09-08 15:55:45 +00:00
GitHub Actions 714885d7a5 Auto translate strings 2026-09-08 15:32:41 +00:00
Trenton H 73e777a48c Fix: skip nested TagSerializer construction when a tag has no children (#14039)
TagSerializer.get_children() built a full nested TagSerializer(many=True)
for every tag, even when it had zero children, likely the common case for
most tags and maybe even most installs. Constructing a DRF ModelSerializer isn't
free (field introspection, deepcopy of declared fields, i18n lookups
all re-run per instantiation), so this scaled GET /api/tags/ linearly
with tag count in pure Python overhead, unrelated to SQL query count.
2026-09-08 15:30:58 +00:00
GitHub Actions e9141366bb Auto translate strings 2026-09-08 14:40:59 +00:00
shamoon 7813375123 Enhancement (QoL): surface externally-set options in Config UI (#13989) 2026-09-08 14:39:22 +00:00
shamoon 0132c7bd6e Fix pr-bot timing 2026-09-07 22:59:41 -07:00
28 changed files with 650 additions and 116 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
'You are welcome to open a new issue that describes the problem you observed in your own words.'
: 'This issue was automatically closed because it was not opened using our bug report form. ' +
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
`If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n' +
`If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` +
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
`contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
+23 -2
View File
@@ -25,6 +25,10 @@ jobs:
pr-bot:
name: Automated PR Bot
runs-on: ubuntu-latest
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
# instead of racing it. Still runs if that job fails, so labeling is not lost.
needs: Anti-slop
if: ${{ !cancelled() }}
permissions:
contents: read
pull-requests: write
@@ -99,8 +103,25 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
const user = pr.user.login;
const user = context.payload.pull_request.user.login;
// Re-read the PR: Anti-slop may have closed and labeled it after the webhook
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
});
if (pr.state === 'closed') {
core.info('Skipping comment: PR is already closed');
return;
}
const labels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name));
if (labels.includes('ai')) {
core.info('Skipping comment: PR is labeled ai');
return;
}
const { data: members } = await github.rest.orgs.listMembers({
org: 'paperless-ngx',
+39 -11
View File
@@ -501,15 +501,43 @@
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="7057826840809102816" datatype="html">
<source>This value overrides <x id="INTERPOLATION" equiv-text="{{option.config_key}}"/>, which is set outside Paperless.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">35</context>
</context-group>
</trans-unit>
<trans-unit id="7221396516204435584" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{option.config_key}}"/> is set outside Paperless. Enter a value here to override it.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">37</context>
</context-group>
</trans-unit>
<trans-unit id="8318849619178340389" datatype="html">
<source>Use the externally configured value</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">42</context>
</context-group>
</trans-unit>
<trans-unit id="6032629623003430385" datatype="html">
<source>Reset to external</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">43</context>
</context-group>
</trans-unit>
<trans-unit id="7808756054397155068" datatype="html">
<source>Reset</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">34</context>
<context context-type="linenumber">46</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">35</context>
<context context-type="linenumber">47</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
@@ -520,7 +548,7 @@
<source>Enable</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">43</context>
<context context-type="linenumber">56</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
@@ -531,7 +559,7 @@
<source>Cancel</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">67,68</context>
<context context-type="linenumber">80,81</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
@@ -610,7 +638,7 @@
<source>Save</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">70,71</context>
<context context-type="linenumber">83,84</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
@@ -681,42 +709,42 @@
<source>Error retrieving config</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">117</context>
<context context-type="linenumber">118</context>
</context-group>
</trans-unit>
<trans-unit id="1172622527269118932" datatype="html">
<source>Invalid JSON</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">143</context>
<context context-type="linenumber">144</context>
</context-group>
</trans-unit>
<trans-unit id="5103146006962696736" datatype="html">
<source>Configuration updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">187</context>
<context context-type="linenumber">193</context>
</context-group>
</trans-unit>
<trans-unit id="1664963291286452273" datatype="html">
<source>An error occurred updating configuration</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">192</context>
<context context-type="linenumber">198</context>
</context-group>
</trans-unit>
<trans-unit id="2653081282186526824" datatype="html">
<source>File successfully updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">214</context>
<context context-type="linenumber">220</context>
</context-group>
</trans-unit>
<trans-unit id="5902783625859504265" datatype="html">
<source>An error occurred uploading file</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/config/config.component.ts</context>
<context context-type="linenumber">219</context>
<context context-type="linenumber">225</context>
</context-group>
</trans-unit>
<trans-unit id="4804785061014590286" datatype="html">
@@ -23,17 +23,30 @@
<div class="col">
<div class="card bg-light">
<div class="card-body">
<div class="card-title d-flex align-items-center">
<div class="card-title d-flex align-items-center flex-wrap">
<h6 class="mb-0">
{{option.title}}
</h6>
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
<i-bs name="info-circle"></i-bs>
</a>
@if (isExternallyConfigured(option.config_key)) {
@if (isSet(option.key)) {
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="This value overrides {{option.config_key}}, which is set outside Paperless." i18n-title>Overrides external</span>
} @else {
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="{{option.config_key}} is set outside Paperless. Enter a value here to override it." i18n-title>Set externally</span>
}
}
@if (isSet(option.key)) {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
@if (isExternallyConfigured(option.config_key)) {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
</button>
} @else {
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
</button>
}
}
</div>
<div class="mb-n3">
@@ -163,6 +163,19 @@ describe('ConfigComponent', () => {
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
})
it('should identify externally configured options', () => {
component.externallyConfiguredVariables = new Set([
'PAPERLESS_OCR_LANGUAGE',
])
expect(
component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE')
).toBeTruthy()
expect(
component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE')
).toBeFalsy()
})
it('should group options into sections within a category, or not', () => {
const sections = component.getCategorySections(ConfigCategory.OCR)
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
@@ -69,6 +69,7 @@ export class ConfigComponent
public configForm = new FormGroup({})
public errors = {}
public externallyConfiguredVariables = new Set<string>()
get optionCategories(): string[] {
return Object.values(ConfigCategory)
@@ -152,6 +153,9 @@ export class ConfigComponent
}
private initialize(config: PaperlessConfig) {
this.externallyConfiguredVariables = new Set(
config.externally_configured_variables ?? []
)
if (!this.store) {
this.store = new BehaviorSubject(config)
@@ -162,7 +166,9 @@ export class ConfigComponent
this.configForm.patchValue(state, { emitEvent: false })
})
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), {
excludeKeys: ['externally_configured_variables'],
})
}
this.configForm.patchValue(config)
@@ -227,6 +233,10 @@ export class ConfigComponent
return this.configForm.get(key).value != null
}
public isExternallyConfigured(configKey: string): boolean {
return this.externallyConfiguredVariables.has(configKey)
}
public resetOption(key: string) {
this.configForm.get(key).setValue(null)
}
@@ -112,6 +112,22 @@
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
<p class="mb-2 mt-3" i18n>Sidebar items to show:</p>
@for (option of sidebarItemOptions; track option.id) {
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
[id]="'sidebar-item-setting-' + option.id"
[checked]="isSidebarItemShown(option.id)"
(change)="toggleSidebarItem(option.id, $event.target.checked)"
/>
<label class="form-check-label" [for]="'sidebar-item-setting-' + option.id">
{{ option.label }}
</label>
</div>
}
</div>
</div>
@@ -24,7 +24,7 @@ import {
SystemStatus,
SystemStatusItemStatus,
} from 'src/app/data/system-status'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
@@ -209,6 +209,45 @@ describe('SettingsComponent', () => {
fixture.detectChanges()
}
it('supports configuring sidebar items and canceling changes', () => {
completeSetup()
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
fixture.detectChanges()
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
HideableSidebarItemID.Workflows
)
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Mail,
false
)
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
HideableSidebarItemID.Mail
)
component.reset()
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
HideableSidebarItemID.Workflows
)
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
HideableSidebarItemID.Mail
)
})
it('enables sidebar item controls on general settings until destroyed', () => {
completeSetup()
expect(settingsService.organizingSidebarItems()).toBe(true)
component.ngOnDestroy()
expect(settingsService.organizingSidebarItems()).toBe(false)
})
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
completeSetup()
const navigateSpy = jest.spyOn(router, 'navigate')
@@ -249,6 +288,7 @@ describe('SettingsComponent', () => {
it('should support save local settings updating appearance settings and calling API, show error', () => {
completeSetup()
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSpy = jest.spyOn(toastService, 'show')
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
@@ -267,7 +307,10 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(33)
expect(setSpy).toHaveBeenCalledTimes(34)
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Workflows,
])
// succeed
storeSpy.mockReturnValueOnce(of(true))
@@ -39,7 +39,12 @@ import {
SystemStatus,
SystemStatusItemStatus,
} from 'src/app/data/system-status'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import {
GlobalSearchType,
HIDEABLE_SIDEBAR_ITEM_IDS,
HideableSidebarItemID,
SETTINGS_KEYS,
} from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@@ -102,6 +107,14 @@ const documentDetailFieldOptions = [
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
]
const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
[HideableSidebarItemID.Dashboard]: $localize`Dashboard`,
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
[HideableSidebarItemID.Mail]: $localize`Mail`,
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
}
@Component({
selector: 'pngx-settings',
templateUrl: './settings.component.html',
@@ -149,6 +162,7 @@ export class SettingsComponent
bulkEditApplyOnClose: new FormControl(null),
documentListItemPerPage: new FormControl(null),
slimSidebarEnabled: new FormControl(null),
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
darkModeUseSystem: new FormControl(null),
darkModeEnabled: new FormControl(null),
darkModeInvertThumbs: new FormControl(null),
@@ -186,6 +200,7 @@ export class SettingsComponent
store: BehaviorSubject<any>
storeSub: Subscription
sidebarItemsSub: Subscription
isDirty$: Observable<boolean>
isDirty: boolean = false
unsubscribeNotifier: Subject<any> = new Subject()
@@ -203,6 +218,10 @@ export class SettingsComponent
public readonly PdfEditorEditMode = PdfEditorEditMode
public readonly documentDetailFieldOptions = documentDetailFieldOptions
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
id,
label: sidebarItemLabels[id],
}))
get systemStatusHasErrors(): boolean {
const status = this.systemStatus()
@@ -230,6 +249,10 @@ export class SettingsComponent
constructor() {
super()
this.sidebarItemsSub =
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
)
this.settings.settingsSaved.subscribe(() => {
if (!this.savePending) this.initialize()
this.savedViewsService.maybeRefreshDocumentCounts()
@@ -279,14 +302,21 @@ export class SettingsComponent
this.activatedRoute.paramMap.subscribe((paramMap) => {
const section = paramMap.get('section')
let navID = SettingsNavIDs.General
if (section) {
const navIDKey: string = Object.keys(SettingsNavIDs).find(
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID.set(SettingsNavIDs[navIDKey])
navID = SettingsNavIDs[navIDKey]
}
}
this.activeNavID.set(navID)
this.settings.sidebarHiddenItemsEditing.set(
navID === SettingsNavIDs.General
? [...this.settingsForm.controls.sidebarHiddenItems.value]
: null
)
})
}
@@ -310,6 +340,7 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
),
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS),
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
darkModeInvertThumbs: this.settings.get(
@@ -436,6 +467,12 @@ export class SettingsComponent
this.settingsForm.patchValue(currentFormValue)
}
if (this.settings.organizingSidebarItems()) {
this.settings.sidebarHiddenItemsEditing.set([
...this.settingsForm.controls.sidebarHiddenItems.value,
])
}
if (this.canViewSystemStatus) {
this.systemStatusService.get().subscribe((status) => {
this.systemStatus.set(status)
@@ -444,8 +481,18 @@ export class SettingsComponent
}
ngOnDestroy() {
this.settings.sidebarHiddenItemsEditing.set(null)
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
this.storeSub && this.storeSub.unsubscribe()
this.sidebarItemsSub.unsubscribe()
}
isSidebarItemShown(item: HideableSidebarItemID): boolean {
return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item)
}
toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void {
this.settings.updateSidebarItemVisibility(item, checked)
}
public saveSettings() {
@@ -473,6 +520,10 @@ export class SettingsComponent
SETTINGS_KEYS.SLIM_SIDEBAR,
this.settingsForm.value.slimSidebarEnabled
)
this.settings.set(
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
this.settingsForm.value.sidebarHiddenItems
)
this.settings.set(
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
this.settingsForm.value.darkModeUseSystem
@@ -632,6 +683,11 @@ export class SettingsComponent
reset() {
this.settingsForm.patchValue(this.store.getValue())
if (this.settings.organizingSidebarItems()) {
this.settings.sidebarHiddenItemsEditing.set([
...this.settingsForm.controls.sidebarHiddenItems.value,
])
}
}
clearThemeColor() {
@@ -86,12 +86,15 @@
}
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
<ul class="nav flex-column">
<li class="nav-item app-link">
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active"
@@ -237,29 +240,38 @@
</div>
</li>
}
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link"
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
@@ -322,13 +334,16 @@
</a>
</li>
}
<li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
@@ -15,7 +15,7 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { SavedView } from 'src/app/data/saved-view'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import {
@@ -287,6 +287,82 @@ describe('AppFrameComponent', () => {
jest.useRealTimers()
})
it('should hide configured sidebar items', () => {
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Dashboard,
HideableSidebarItemID.Workflows,
])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
.parentElement.classList
).toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="workflows"]')
.parentElement.classList
).toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
.classList
).not.toContain('d-none')
})
it('should show hidden items and visibility switches while customizing', () => {
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Dashboard,
])
settingsService.sidebarHiddenItemsEditing.set([
HideableSidebarItemID.Dashboard,
])
fixture.detectChanges()
expect(
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
).toBe(5)
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
.parentElement.classList
).not.toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
).toContain('opacity-50')
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, true)
fixture.detectChanges()
expect(
Array.from(
fixture.nativeElement.querySelectorAll('pngx-input-switch')
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
).toBe(true)
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
).not.toContain('pe-5')
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
component.slimSidebarAnimating.set(true)
fixture.detectChanges()
expect(
Array.from(
fixture.nativeElement.querySelectorAll('pngx-input-switch')
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
).toBe(true)
component.slimSidebarAnimating.set(false)
fixture.detectChanges()
expect(
Array.from(
fixture.nativeElement.querySelectorAll('pngx-input-switch')
).every((toggle: HTMLElement) => !toggle.classList.contains('d-none'))
).toBe(true)
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
).toContain('pe-5')
})
it('should show error on toggle slim sidebar if store settings fails', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showError')
@@ -7,6 +7,7 @@ import {
} from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import {
NgbCollapseModule,
@@ -21,7 +22,11 @@ import { Observable } from 'rxjs'
import { first } from 'rxjs/operators'
import { Document } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view'
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import {
CollapsibleSection,
HideableSidebarItemID,
SETTINGS_KEYS,
} from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -48,6 +53,7 @@ import { ChatComponent } from '../chat/chat/chat.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -76,6 +82,8 @@ const SCROLL_THRESHOLD = 16
NgxBootstrapIconsModule,
DragDropModule,
TourNgBootstrap,
FormsModule,
SwitchComponent,
],
})
export class AppFrameComponent
@@ -98,6 +106,7 @@ export class AppFrameComponent
readonly isMenuCollapsed = signal(true)
readonly slimSidebarAnimating = signal(false)
readonly mobileSearchHidden = signal(false)
readonly HideableSidebarItemID = HideableSidebarItemID
private readonly versionSetting = this.settingsService.getSignal<string>(
SETTINGS_KEYS.VERSION
)
@@ -195,6 +204,10 @@ export class AppFrameComponent
}, 200) // slightly longer than css animation for slim sidebar
}
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
this.settingsService.updateSidebarItemVisibility(item, visible)
}
toggleAttributesSections(event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
@@ -1,6 +1,6 @@
<div class="mb-3">
<div class="row">
@if (!horizontal) {
<div [class.mb-3]="!compact">
<div [class.row]="!compact">
@if (!horizontal && !compact) {
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
{{title}}
@@ -17,8 +17,8 @@
}
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
<div class="form-check form-switch">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
@if (horizontal) {
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled" [attr.aria-label]="compact ? title : null">
@if (horizontal && !compact) {
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
{{title}}
@if (showUnsetNote && isUnset) {
@@ -48,4 +48,14 @@ describe('SwitchComponent', () => {
component.value = undefined
expect(component.isUnset).toBeTruthy()
})
it('should support a compact layout', () => {
component.compact = true
component.title = 'Test switch'
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.mb-3')).toBeNull()
expect(fixture.nativeElement.querySelector('.row')).toBeNull()
expect(input.getAttribute('aria-label')).toEqual('Test switch')
})
})
@@ -25,6 +25,9 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
@Input()
showUnsetNote: boolean = false
@Input()
compact: boolean = false
constructor() {
super()
}
+1
View File
@@ -422,6 +422,7 @@ export const PaperlessConfigOptions: ConfigOption[] = [
]
export interface PaperlessConfig extends ObjectWithId {
externally_configured_variables: string[]
output_type: OutputTypeConfig
pages: number
language: string
+16
View File
@@ -24,6 +24,16 @@ export enum CollapsibleSection {
ATTRIBUTES = 'attributes',
}
export enum HideableSidebarItemID {
Dashboard = 'dashboard',
SavedViews = 'saved_views',
Workflows = 'workflows',
Mail = 'mail',
Documentation = 'documentation',
}
export const HIDEABLE_SIDEBAR_ITEM_IDS = Object.values(HideableSidebarItemID)
export const PAPERLESS_GREEN_HEX = '#17541f'
export const SETTINGS_KEYS = {
@@ -56,6 +66,7 @@ export const SETTINGS_KEYS = {
NOTES_ENABLED: 'general-settings:notes-enabled',
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
ATTRIBUTES_SECTIONS_COLLAPSED:
'general-settings:attributes-sections-collapsed',
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
@@ -127,6 +138,11 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
type: 'array',
default: [],
},
{
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
type: 'array',
@@ -14,7 +14,11 @@ import { CustomFieldDataType } from '../data/custom-field'
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { SavedView } from '../data/saved-view'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
import {
HideableSidebarItemID,
SETTINGS_KEYS,
UiSettings,
} from '../data/ui-settings'
import { PermissionsService } from './permissions.service'
import { CustomFieldsService } from './rest/custom-fields.service'
import { SettingsService } from './settings.service'
@@ -230,6 +234,35 @@ describe('SettingsService', () => {
expect(notesEnabled()).toBeFalsy()
})
it('updates sidebar item visibility', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(false)
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Workflows,
false
)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(true)
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Workflows,
true
)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(false)
})
it('updates setting signals when settings are reinitialized', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}ui_settings/`
@@ -24,6 +24,7 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SavedView } from '../data/saved-view'
import {
HideableSidebarItemID,
PAPERLESS_GREEN_HEX,
SETTINGS,
SETTINGS_KEYS,
@@ -313,6 +314,18 @@ export class SettingsService {
readonly globalDropzoneEnabled = signal(true)
readonly globalDropzoneActive = signal(false)
readonly organizingSidebarSavedViews = signal(false)
readonly sidebarHiddenItemsEditing = signal<HideableSidebarItemID[] | null>(
null
)
readonly organizingSidebarItems = computed(
() => this.sidebarHiddenItemsEditing() !== null
)
readonly sidebarHiddenItemsEditingChanged = new EventEmitter<
HideableSidebarItemID[]
>()
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
)
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
DEFAULT_DISPLAY_FIELDS
@@ -749,6 +762,29 @@ export class SettingsService {
return this.storeSettings()
}
sidebarItemIsHidden(item: HideableSidebarItemID): boolean {
return (
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
).includes(item)
}
updateSidebarItemVisibility(
item: HideableSidebarItemID,
visible: boolean
): void {
const hiddenItems = new Set(
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
)
if (visible) {
hiddenItems.delete(item)
} else {
hiddenItems.add(item)
}
const updatedHiddenItems = [...hiddenItems]
this.sidebarHiddenItemsEditing.set(updatedHiddenItems)
this.sidebarHiddenItemsEditingChanged.emit(updatedHiddenItems)
}
updateSavedViewsVisibility(
dashboardVisibleViewIds: number[],
sidebarVisibleViewIds: number[]
+7 -17
View File
@@ -12,7 +12,6 @@ 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
@@ -53,6 +52,7 @@ from documents.models import StoragePath
from documents.models import Tag
from documents.permissions import permitted_document_ids
from documents.permissions import permitted_object_ids
from documents.versioning import annotate_effective_content
if TYPE_CHECKING:
from collections.abc import Callable
@@ -182,14 +182,9 @@ class TitleContentFilter(Filter):
logger.warning(
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
)
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),
)
return annotate_effective_content(qs).filter(
Q(title__icontains=value) | Q(effective_content__icontains=value),
)
else:
return qs
@@ -200,14 +195,9 @@ class EffectiveContentFilter(Filter):
value = value.strip() if isinstance(value, str) else value
if not value:
return qs
try:
return qs.filter(
**{f"effective_content__{self.lookup_expr}": value},
)
except FieldError:
return qs.filter(
**{f"content__{self.lookup_expr}": value},
)
return annotate_effective_content(qs).filter(
**{f"effective_content__{self.lookup_expr}": value},
)
@extend_schema_field(serializers.BooleanField)
+3
View File
@@ -674,6 +674,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
ordering = ordering or (Lower("name"),)
children = children.order_by(*ordering)
if not children:
return []
serializer = TagSerializer(
children,
many=True,
+28 -1
View File
@@ -35,7 +35,8 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
THEN:
- Existing config
"""
response = self.client.get(self.ENDPOINT, format="json")
with patch.dict("os.environ", {}, clear=True):
response = self.client.get(self.ENDPOINT, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -45,6 +46,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
response.data[0],
{
"id": 1,
"externally_configured_variables": [],
"output_type": None,
"pages": None,
"language": None,
@@ -91,6 +93,31 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
},
)
def test_api_get_config_reports_external_configuration_without_values(self) -> None:
with patch.dict(
"os.environ",
{
"PAPERLESS_OCR_LANGUAGE": "eng",
"PAPERLESS_REMOTE_OCR_API_KEY": "secret-value",
"PAPERLESS_FUTURE_SETTING": "future-value",
"UNRELATED_SETTING": "unrelated-value",
},
clear=True,
):
response = self.client.get(self.ENDPOINT, format="json")
self.assertCountEqual(
response.data[0]["externally_configured_variables"],
[
"PAPERLESS_FUTURE_SETTING",
"PAPERLESS_OCR_LANGUAGE",
"PAPERLESS_REMOTE_OCR_API_KEY",
],
)
self.assertNotContains(response, "secret-value")
self.assertNotContains(response, "future-value")
self.assertNotContains(response, "UNRELATED_SETTING")
def test_api_get_ui_settings_with_config(self) -> None:
"""
GIVEN:
@@ -2,14 +2,12 @@ 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
@@ -22,6 +20,7 @@ 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:
@@ -892,32 +891,104 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
)
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]
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).
"""
result = TitleContentFilter().filter(queryset, " latest ")
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",
)
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,
def test_title_content_filter_matches_latest_version_content(self) -> None:
result = TitleContentFilter().filter(
Document.objects.filter(root_document__isnull=True),
" latest ",
)
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"})
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),
)
self.assertIs(annotate_effective_content(annotated), annotated)
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, [])
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
queryset = mock.Mock()
+23
View File
@@ -1947,6 +1947,29 @@ 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")
+6 -3
View File
@@ -27,10 +27,13 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
"""
Annotates documents with the content of their newest version, falling back
to their own, so get_effective_content() can answer from the row rather
than querying for the versions of each document
Annotates documents with the content of their newest version unless the
queryset already carries the annotation, falling back to their own, so
get_effective_content() can answer from the row rather than querying for
the versions of each document.
"""
if "effective_content" in documents.query.annotations:
return documents
return documents.annotate(
effective_content=Coalesce(
Subquery(
+8 -2
View File
@@ -232,6 +232,7 @@ 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
@@ -3632,8 +3633,13 @@ class GlobalSearchView(PassUserMixin):
OBJECT_LIMIT = 3
docs = []
if request.user.has_perm("documents.view_document"):
all_docs = Document.objects.filter(
id__in=permitted_document_ids(request.user),
# 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),
),
)
if db_only:
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
+29 -29
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-07 20:47+0000\n"
"POT-Creation-Date: 2026-09-08 15:56+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -21,39 +21,39 @@ msgstr ""
msgid "Documents"
msgstr ""
#: documents/filters.py:473
#: documents/filters.py:463
msgid "Value must be valid JSON."
msgstr ""
#: documents/filters.py:492
#: documents/filters.py:482
msgid "Invalid custom field query expression"
msgstr ""
#: documents/filters.py:502
#: documents/filters.py:492
msgid "Invalid expression list. Must be nonempty."
msgstr ""
#: documents/filters.py:523
#: documents/filters.py:513
msgid "Invalid logical operator {op!r}"
msgstr ""
#: documents/filters.py:537
#: documents/filters.py:527
msgid "Maximum number of query conditions exceeded."
msgstr ""
#: documents/filters.py:601
#: documents/filters.py:591
msgid "{name!r} is not a valid custom field."
msgstr ""
#: documents/filters.py:638
#: documents/filters.py:628
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
#: documents/filters.py:757 documents/models.py:136
#: documents/filters.py:747 documents/models.py:136
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:1119
#: documents/filters.py:1109
msgid "Custom field not found"
msgstr ""
@@ -1631,49 +1631,49 @@ msgstr ""
msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:524 documents/serialisers.py:878
#: documents/serialisers.py:2838 documents/views.py:314 documents/views.py:2624
#: documents/serialisers.py:524 documents/serialisers.py:881
#: documents/serialisers.py:2841 documents/views.py:315 documents/views.py:2625
#: paperless_mail/serialisers.py:156
msgid "Insufficient permissions."
msgstr ""
#: documents/serialisers.py:714
#: documents/serialisers.py:717
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:2315
#: documents/serialisers.py:2318
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:2359
#: documents/serialisers.py:2362
#, python-format
msgid "Custom field id must be an integer: %(id)s"
msgstr ""
#: documents/serialisers.py:2366
#: documents/serialisers.py:2369
#, python-format
msgid "Custom field with id %(id)s does not exist"
msgstr ""
#: documents/serialisers.py:2383 documents/serialisers.py:2393
#: documents/serialisers.py:2386 documents/serialisers.py:2396
msgid ""
"Custom fields must be a list of integers or an object mapping ids to values."
msgstr ""
#: documents/serialisers.py:2388
#: documents/serialisers.py:2391
msgid "Some custom fields don't exist or were specified twice."
msgstr ""
#: documents/serialisers.py:2535
#: documents/serialisers.py:2538
msgid "Invalid variable detected."
msgstr ""
#: documents/serialisers.py:2894
#: documents/serialisers.py:2897
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2924 documents/views.py:4626
#: documents/serialisers.py:2927 documents/views.py:4632
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1941,36 +1941,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:307 documents/views.py:2621
#: documents/views.py:308 documents/views.py:2622
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1591
#: documents/views.py:1592
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1602
#: documents/views.py:1603
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2446 documents/views.py:2767
#: documents/views.py:2447 documents/views.py:2768
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4639
#: documents/views.py:4645
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4685
#: documents/views.py:4691
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4749
#: documents/views.py:4755
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4763
#: documents/views.py:4769
msgid "The share link bundle is unavailable."
msgstr ""
+8
View File
@@ -1,4 +1,5 @@
import logging
import os
from io import BytesIO
import magic
@@ -212,6 +213,7 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer[Use
class ApplicationConfigurationSerializer(
serializers.ModelSerializer[ApplicationConfiguration],
):
externally_configured_variables = serializers.SerializerMethodField()
user_args = serializers.JSONField(binary=True, allow_null=True)
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
llm_api_key = ObfuscatedPasswordField(
@@ -227,6 +229,12 @@ class ApplicationConfigurationSerializer(
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
def get_externally_configured_variables(
self,
instance: ApplicationConfiguration,
) -> list[str]:
return sorted(name for name in os.environ if name.startswith("PAPERLESS_"))
def run_validation(self, data):
# Empty strings treated as None to avoid unexpected behavior
if "user_args" in data and data["user_args"] == "":