mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-16 15:53:19 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27cc2497b8 | ||
|
|
d88b281eb2 | ||
|
|
8446036777 | ||
|
|
8ad5e8cca3 | ||
|
|
1606a46b53 | ||
|
|
f647f304da | ||
|
|
31746371f4 | ||
|
|
0e5fbc973a | ||
|
|
3322c92837 | ||
|
|
db15c82804 | ||
|
|
fe5d09a123 | ||
|
|
a0feb827c9 | ||
|
|
b599b13f72 |
+78
-29
@@ -343,7 +343,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
||||||
<context context-type="linenumber">58</context>
|
<context context-type="linenumber">59</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
|
||||||
@@ -2655,11 +2655,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
||||||
<context context-type="linenumber">31,32</context>
|
<context context-type="linenumber">32,33</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
||||||
<context context-type="linenumber">49</context>
|
<context context-type="linenumber">50</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
|
||||||
@@ -2962,11 +2962,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
||||||
<context context-type="linenumber">30,31</context>
|
<context context-type="linenumber">31,32</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
||||||
<context context-type="linenumber">46</context>
|
<context context-type="linenumber">47</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
|
||||||
@@ -5973,7 +5973,7 @@
|
|||||||
<source>Open <x id="PH" equiv-text="this.title"/> filter</source>
|
<source>Open <x id="PH" equiv-text="this.title"/> filter</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">828</context>
|
<context context-type="linenumber">831</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7005745151564974365" datatype="html">
|
<trans-unit id="7005745151564974365" datatype="html">
|
||||||
@@ -6382,27 +6382,6 @@
|
|||||||
<context context-type="linenumber">94</context>
|
<context context-type="linenumber">94</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5947558132119506443" datatype="html">
|
|
||||||
<source>My documents</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">25,26</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="231920238966427751" datatype="html">
|
|
||||||
<source>Shared with me</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">35,36</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="175385209536581523" datatype="html">
|
|
||||||
<source>Shared by me</source>
|
|
||||||
<context-group purpose="location">
|
|
||||||
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.html</context>
|
|
||||||
<context context-type="linenumber">45,46</context>
|
|
||||||
</context-group>
|
|
||||||
</trans-unit>
|
|
||||||
<trans-unit id="5151074932731293042" datatype="html">
|
<trans-unit id="5151074932731293042" datatype="html">
|
||||||
<source>Unowned</source>
|
<source>Unowned</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@@ -6417,6 +6396,76 @@
|
|||||||
<context context-type="linenumber">85</context>
|
<context context-type="linenumber">85</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="5947558132119506443" datatype="html">
|
||||||
|
<source>My documents</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">101</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="1930869169119109336" datatype="html">
|
||||||
|
<source>Owned by <x id="PH" equiv-text="username"/></source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">106</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="5339682692608120628" datatype="html">
|
||||||
|
<source>Owned by another user</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">107</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="231920238966427751" datatype="html">
|
||||||
|
<source>Shared with me</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">117</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="1894556100995563325" datatype="html">
|
||||||
|
<source>Not owned by <x id="PH" equiv-text="usernames.join(', ')"/></source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">124</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="4647949080250052038" datatype="html">
|
||||||
|
<source>Not owned by another user</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">127</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="8858352775080403297" datatype="html">
|
||||||
|
<source>Not owned by selected users</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">128</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="175385209536581523" datatype="html">
|
||||||
|
<source>Shared by me</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">136</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="5140574576358170412" datatype="html">
|
||||||
|
<source>Shared by <x id="PH" equiv-text="username"/></source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">141</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="391557549689505150" datatype="html">
|
||||||
|
<source>Shared by another user</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/common/permissions-filter-dropdown/permissions-filter-dropdown.component.ts</context>
|
||||||
|
<context context-type="linenumber">142</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="941924371433275463" datatype="html">
|
<trans-unit id="941924371433275463" datatype="html">
|
||||||
<source>Global permissions define what areas of the app and API endpoints users can access.</source>
|
<source>Global permissions define what areas of the app and API endpoints users can access.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
@@ -9628,7 +9677,7 @@
|
|||||||
<source>Filter Documents (<x id="INTERPOLATION" equiv-text="{{ field.document_count }}"/>)</source>
|
<source>Filter Documents (<x id="INTERPOLATION" equiv-text="{{ field.document_count }}"/>)</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
||||||
<context context-type="linenumber">38,39</context>
|
<context context-type="linenumber">39,40</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/management-list/management-list.component.html</context>
|
||||||
@@ -9651,7 +9700,7 @@
|
|||||||
<source>No fields defined.</source>
|
<source>No fields defined.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
||||||
<context context-type="linenumber">68,70</context>
|
<context context-type="linenumber">70,72</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3032792139967609806" datatype="html">
|
<trans-unit id="3032792139967609806" datatype="html">
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
height: 0.8em;
|
height: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-heading,
|
||||||
|
.text-uppercase {
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
.view-name {
|
.view-name {
|
||||||
max-width: calc(100% - 50px)
|
max-width: calc(100% - 50px)
|
||||||
}
|
}
|
||||||
@@ -81,7 +86,7 @@ main {
|
|||||||
|
|
||||||
.sidebar li.nav-item span,
|
.sidebar li.nav-item span,
|
||||||
.sidebar .sidebar-heading span {
|
.sidebar .sidebar-heading span {
|
||||||
transition: all .1s ease;
|
transition: opacity .1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@media(min-width: 768px) {
|
||||||
@@ -149,7 +154,7 @@ main {
|
|||||||
z-index: 996;
|
z-index: 996;
|
||||||
--bs-btn-padding-x: 0.35rem;
|
--bs-btn-padding-x: 0.35rem;
|
||||||
--bs-btn-padding-y: 0.125rem;
|
--bs-btn-padding-y: 0.125rem;
|
||||||
transition: all .2s ease;
|
transition: left .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim .sidebar-slim-toggler {
|
.sidebar.slim .sidebar-slim-toggler {
|
||||||
@@ -181,6 +186,8 @@ main {
|
|||||||
.sidebar .nav-link {
|
.sidebar .nav-link {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: color .15s ease-in-out;
|
||||||
|
|
||||||
&:hover, &.active, &:focus {
|
&:hover, &.active, &:focus {
|
||||||
color: var(--bs-primary);
|
color: var(--bs-primary);
|
||||||
@@ -193,6 +200,7 @@ main {
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
border-left-color: var(--bs-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
i-bs {
|
i-bs {
|
||||||
@@ -201,6 +209,17 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sub-page gets marker only
|
||||||
|
.nav-item:has(.attributes-submenu.show .nav-link.active) > .attributes-row > .nav-link.active {
|
||||||
|
border-left-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bring sub-menu markers back out to L edge
|
||||||
|
.attributes-submenu .nav-link {
|
||||||
|
margin-left: -0.5rem;
|
||||||
|
padding-left: calc(var(--bs-nav-link-padding-x) + 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
.attributes-row .attributes-expand-btn {
|
.attributes-row .attributes-expand-btn {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
transition: opacity 0.15s ease-in-out;
|
transition: opacity 0.15s ease-in-out;
|
||||||
|
|||||||
+2
-2
@@ -49,7 +49,7 @@
|
|||||||
</cdk-virtual-scroll-viewport>
|
</cdk-virtual-scroll-viewport>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
@if (filteredItems.length === 0 && createRef !== undefined) {
|
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
|
||||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (extraButtonTitle) {
|
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
|
||||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
|
||||||
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
|
|||||||
+33
@@ -911,6 +911,25 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(createSpy).toHaveBeenCalled()
|
expect(createSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should only show create when a non-empty filter has no matches', () => {
|
||||||
|
component.selectionModel.items = []
|
||||||
|
component.icon = 'tag-fill'
|
||||||
|
component.editing = true
|
||||||
|
component.createRef = jest.fn()
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Create')
|
||||||
|
component.listFilterEnter()
|
||||||
|
expect(component.createRef).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const filterInput: HTMLInputElement =
|
||||||
|
fixture.nativeElement.querySelector('input[type="text"]')
|
||||||
|
filterInput.value = 'FooBar'
|
||||||
|
filterInput.dispatchEvent(new Event('input'))
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Create "FooBar"')
|
||||||
|
})
|
||||||
|
|
||||||
it('should exclude item and trigger change event', () => {
|
it('should exclude item and trigger change event', () => {
|
||||||
const id = 1
|
const id = 1
|
||||||
const state = ToggleableItemState.Selected
|
const state = ToggleableItemState.Selected
|
||||||
@@ -970,4 +989,18 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(extraButtonClicked).toBeTruthy()
|
expect(extraButtonClicked).toBeTruthy()
|
||||||
expect(applied).toBeFalsy()
|
expect(applied).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should only show the extra button for an empty result when enabled', () => {
|
||||||
|
component.selectionModel.items = items
|
||||||
|
component.icon = 'tag-fill'
|
||||||
|
component.extraButtonTitle = 'Extra'
|
||||||
|
component.filterText = 'FooBar'
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Extra')
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('showExtraButtonIfEmpty', true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Extra')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+8
-1
@@ -774,6 +774,9 @@ export class FilterableDropdownComponent
|
|||||||
@Input()
|
@Input()
|
||||||
extraButtonTitle: string
|
extraButtonTitle: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
showExtraButtonIfEmpty: boolean = false
|
||||||
|
|
||||||
creating: boolean = false
|
creating: boolean = false
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
@@ -892,7 +895,11 @@ export class FilterableDropdownComponent
|
|||||||
this.dropdown.close()
|
this.dropdown.close()
|
||||||
}
|
}
|
||||||
}, 200)
|
}, 200)
|
||||||
} else if (filtered.length == 0 && this.createRef) {
|
} else if (
|
||||||
|
filtered.length == 0 &&
|
||||||
|
this.createRef &&
|
||||||
|
this.filterText?.length > 0
|
||||||
|
) {
|
||||||
this.createClicked()
|
this.createClicked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small i18n>My documents</small>
|
<small>{{ownerFilterLabel}}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small i18n>Shared with me</small>
|
<small>{{ownerExclusionFilterLabel}}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small i18n>Shared by me</small>
|
<small>{{sharedByFilterLabel}}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||||
|
|||||||
+52
@@ -94,6 +94,58 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should describe concrete user filters honestly', () => {
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||||
|
component.selectionModel.userID = 1
|
||||||
|
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||||
|
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||||
|
component.selectionModel.excludeUsers = [1]
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||||
|
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||||
|
component.selectionModel.userID = 1
|
||||||
|
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should describe concrete filters when usernames are unavailable', () => {
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||||
|
component.selectionModel.userID = 99
|
||||||
|
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||||
|
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||||
|
component.selectionModel.excludeUsers = [99]
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
|
'Not owned by another user'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.selectionModel.excludeUsers = [98, 99]
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
|
'Not owned by selected users'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||||
|
component.selectionModel.userID = 99
|
||||||
|
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retain relative labels for filters bound to the current user', () => {
|
||||||
|
component.selectionModel.userID = currentUserID
|
||||||
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
|
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||||
|
|
||||||
|
component.selectionModel.excludeUsers = [currentUserID]
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retain relative labels for inactive filter choices', () => {
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||||
|
|
||||||
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
|
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||||
|
})
|
||||||
|
|
||||||
it('should support reset', () => {
|
it('should support reset', () => {
|
||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
expect(component.selectionModel.ownerFilter).not.toEqual(
|
||||||
|
|||||||
+53
@@ -93,6 +93,55 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get ownerFilterLabel(): string {
|
||||||
|
if (
|
||||||
|
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
||||||
|
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||||
|
) {
|
||||||
|
return $localize`My documents`
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = this.getUsername(this.selectionModel?.userID)
|
||||||
|
return username
|
||||||
|
? $localize`Owned by ${username}`
|
||||||
|
: $localize`Owned by another user`
|
||||||
|
}
|
||||||
|
|
||||||
|
get ownerExclusionFilterLabel(): string {
|
||||||
|
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
||||||
|
if (
|
||||||
|
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
||||||
|
(excludedUsers.length === 1 &&
|
||||||
|
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||||
|
) {
|
||||||
|
return $localize`Shared with me`
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernames = excludedUsers
|
||||||
|
.map((id) => this.getUsername(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
if (usernames.length === excludedUsers.length && usernames.length > 0) {
|
||||||
|
return $localize`Not owned by ${usernames.join(', ')}`
|
||||||
|
}
|
||||||
|
return excludedUsers.length === 1
|
||||||
|
? $localize`Not owned by another user`
|
||||||
|
: $localize`Not owned by selected users`
|
||||||
|
}
|
||||||
|
|
||||||
|
get sharedByFilterLabel(): string {
|
||||||
|
if (
|
||||||
|
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
||||||
|
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||||
|
) {
|
||||||
|
return $localize`Shared by me`
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = this.getUsername(this.selectionModel?.userID)
|
||||||
|
return username
|
||||||
|
? $localize`Shared by ${username}`
|
||||||
|
: $localize`Shared by another user`
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const userService = inject(UserService)
|
const userService = inject(UserService)
|
||||||
|
|
||||||
@@ -164,4 +213,8 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getUsername(userID: number): string {
|
||||||
|
return this.users().find((user) => user.id === userID)?.username
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ $paperless-card-breakpoints: (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Popper may place a dropdown above its toggle when the virtual keyboard
|
||||||
|
// reduces the available viewport, increase the z-index so navbar doesn't
|
||||||
|
// obscure it. See github.com/paperless-ngx/paperless-ngx/pull/13694
|
||||||
|
:host ::ng-deep .sticky-top:has(.dropdown-menu.show) {
|
||||||
|
z-index: 1040;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 579.98px) {
|
@media (max-width: 579.98px) {
|
||||||
:host-context(main.mobile-search-hidden) .sticky-top {
|
:host-context(main.mobile-search-hidden) .sticky-top {
|
||||||
top: calc(3.5rem - 2px); // height of navbar only when search is hidden
|
top: calc(3.5rem - 2px); // height of navbar only when search is hidden
|
||||||
|
|||||||
+38
-36
@@ -21,45 +21,47 @@
|
|||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
|
||||||
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
|
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="btn-group d-block d-sm-none">
|
<div class="btn-toolbar gap-2">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
<i-bs name="three-dots-vertical"></i-bs>
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
</button>
|
<i-bs name="three-dots-vertical"></i-bs>
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
</button>
|
||||||
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||||
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
|
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
|
||||||
@if (field.document_count > 0) {
|
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
|
||||||
<a
|
@if (field.document_count > 0) {
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
<a
|
||||||
ngbDropdownItem
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||||
[routerLink]="getDocumentFilterUrl(field)"
|
ngbDropdownItem
|
||||||
i18n
|
[routerLink]="getDocumentFilterUrl(field)"
|
||||||
>Filter Documents ({{ field.document_count }})</a
|
i18n
|
||||||
>
|
>Filter Documents ({{ field.document_count }})</a
|
||||||
}
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="btn-group d-none d-sm-inline-block">
|
||||||
<div class="btn-group d-none d-sm-inline-block">
|
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
|
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
||||||
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
</button>
|
||||||
</button>
|
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
|
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||||
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@if (field.document_count > 0) {
|
|
||||||
<div class="btn-group d-none d-sm-inline-block ms-2">
|
|
||||||
<a
|
|
||||||
class="btn btn-sm btn-outline-secondary"
|
|
||||||
[routerLink]="getDocumentFilterUrl(field)"
|
|
||||||
>
|
|
||||||
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
|
|
||||||
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
@if (field.document_count > 0) {
|
||||||
|
<div class="btn-group d-none d-sm-inline-block">
|
||||||
|
<a
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
[routerLink]="getDocumentFilterUrl(field)"
|
||||||
|
>
|
||||||
|
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
|
||||||
|
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="my-3 shadow-sm">
|
<div class="my-3">
|
||||||
<ng-container
|
<ng-container
|
||||||
[ngComponentOutlet]="activeSection?.component"
|
[ngComponentOutlet]="activeSection?.component"
|
||||||
#activeOutlet="ngComponentOutlet"
|
#activeOutlet="ngComponentOutlet"
|
||||||
|
|||||||
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1417
-910
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1416
-909
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1440
-933
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1604
-1097
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+2661
-2155
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1451
-944
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1416
-909
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+1414
-907
File diff suppressed because it is too large
Load Diff
+66
-26
@@ -127,6 +127,28 @@ table .btn-link {
|
|||||||
background-color: var(--bs-body-bg);
|
background-color: var(--bs-body-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6,
|
||||||
|
.h1, .h2, .h3, .h4, .h5, .h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.021em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pngx-page-header h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
.badge,
|
||||||
|
.card-info,
|
||||||
|
.pagination {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.bg-primary {
|
.bg-primary {
|
||||||
background-color: var(--bs-primary) !important;
|
background-color: var(--bs-primary) !important;
|
||||||
color: var(--pngx-primary-text-contrast);
|
color: var(--pngx-primary-text-contrast);
|
||||||
@@ -213,7 +235,8 @@ table .btn-link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-switch .form-check-input:focus {
|
.form-switch .form-check-input:focus {
|
||||||
background-image: escape-svg(url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#bbb'/></svg>"));
|
// neutral knob in place of bootstrap's blue, which clashes with the theme colour
|
||||||
|
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23bbb'/%3e%3c/svg%3e");
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item a:focus-visible {
|
.nav-item a:focus-visible {
|
||||||
@@ -269,33 +292,44 @@ a.btn-link:focus-visible,
|
|||||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.asc {
|
// Sortable table headers
|
||||||
background-color: #f8f9fa!important;
|
th[pngxSortable] {
|
||||||
}
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
.asc:after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
transform: rotate(180deg);
|
display: inline-block;
|
||||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
|
vertical-align: -0.15em;
|
||||||
height: 1rem;
|
width: 0.8rem;
|
||||||
width: 1rem;
|
height: 0.8rem;
|
||||||
display: block;
|
margin-left: 0.25rem;
|
||||||
background-size: 1rem;
|
// chevron, matched to the bootstrap-icons set used elsewhere in the app
|
||||||
float: right;
|
mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e") no-repeat center / 0.8rem;
|
||||||
|
background-color: currentColor;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hint that an unsorted column can be sorted
|
||||||
|
&:hover::after {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.asc,
|
||||||
.des {
|
.des {
|
||||||
background-color: #f8f9fa!important;
|
color: var(--bs-primary);
|
||||||
|
--bs-table-color-state: var(--bs-primary); // bootstrap sets cell color at higher specificity
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.des:after {
|
.asc::after {
|
||||||
content: '';
|
transform: rotate(180deg);
|
||||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
|
|
||||||
height: 1rem;
|
|
||||||
width: 1rem;
|
|
||||||
display: block;
|
|
||||||
background-size: 1rem;
|
|
||||||
float: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperless-input-select {
|
.paperless-input-select {
|
||||||
@@ -564,10 +598,6 @@ ul.pagination {
|
|||||||
table.table {
|
table.table {
|
||||||
--bs-table-color: var(--bs-body-color);
|
--bs-table-color: var(--bs-body-color);
|
||||||
--bs-table-bg: var(--bs-light-rgb);
|
--bs-table-bg: var(--bs-light-rgb);
|
||||||
|
|
||||||
.des,.asc {
|
|
||||||
background-color: var(--bs-body-bg) !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
@@ -776,6 +806,16 @@ canvas.hiddenCanvasElement {
|
|||||||
|
|
||||||
.document-card {
|
.document-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:hover:not(.card-selected) {
|
||||||
|
border-color: var(--pngx-card-hover-border);
|
||||||
|
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card-check {
|
||||||
|
border-color: var(--pngx-card-hover-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.card-footer i-bs svg {
|
.card-footer i-bs svg {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|||||||
+20
-12
@@ -23,6 +23,7 @@
|
|||||||
--pngx-bg-darker: var(--bs-gray-100);
|
--pngx-bg-darker: var(--bs-gray-100);
|
||||||
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
|
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
|
||||||
--pngx-bg-disabled: #f7f7f7;
|
--pngx-bg-disabled: #f7f7f7;
|
||||||
|
--pngx-card-hover-border: var(--bs-tertiary-color);
|
||||||
--pngx-focus-alpha: 0.3;
|
--pngx-focus-alpha: 0.3;
|
||||||
--pngx-toast-max-width: 340px;
|
--pngx-toast-max-width: 340px;
|
||||||
--bs-info: var(--pngx-bg-alt2);
|
--bs-info: var(--pngx-bg-alt2);
|
||||||
@@ -36,20 +37,22 @@
|
|||||||
$text-color-light-bg: #212529;
|
$text-color-light-bg: #212529;
|
||||||
$text-color-dark-bg: #abb2bf;
|
$text-color-dark-bg: #abb2bf;
|
||||||
$text-color-dark-bg-accent: color.adjust($text-color-dark-bg, $lightness: 10%);
|
$text-color-dark-bg-accent: color.adjust($text-color-dark-bg, $lightness: 10%);
|
||||||
// Taken from bootstrap
|
// url-encoded $text-color-light-bg
|
||||||
$form-check-input-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='none' stroke='#{$text-color-light-bg}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/></svg>");
|
$text-color-light-bg-esc: "%23212529";
|
||||||
$form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='2' fill='#{$text-color-light-bg}'/></svg>");
|
// Taken from bootstrap, pre-encoded
|
||||||
|
$form-check-input-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='#{$text-color-light-bg-esc}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e");
|
||||||
|
$form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='#{$text-color-light-bg-esc}'/%3e%3c/svg%3e");
|
||||||
|
|
||||||
.primary-light {
|
.primary-light {
|
||||||
--pngx-primary-text-contrast: #{$text-color-light-bg} !important;
|
--pngx-primary-text-contrast: #{$text-color-light-bg} !important;
|
||||||
|
|
||||||
.form-check:not(.form-switch) {
|
.form-check:not(.form-switch) {
|
||||||
.form-check-input:checked[type=checkbox] {
|
.form-check-input:checked[type=checkbox] {
|
||||||
background-image: escape-svg($form-check-input-checked-bg-image-dark);
|
--bs-form-check-bg-image: #{$form-check-input-checked-bg-image-dark};
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-check-input:checked[type=radio] {
|
.form-check-input:checked[type=radio] {
|
||||||
background-image: escape-svg($form-check-radio-checked-bg-image-dark);
|
--bs-form-check-bg-image: #{$form-check-radio-checked-bg-image-dark};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +69,17 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
|||||||
color: var(--pngx-primary-text-contrast);
|
color: var(--pngx-primary-text-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu > .list-group-flush:only-child {
|
||||||
|
> .list-group-item:first-child {
|
||||||
|
border-top-left-radius: var(--bs-dropdown-border-radius);
|
||||||
|
border-top-right-radius: var(--bs-dropdown-border-radius);
|
||||||
|
}
|
||||||
|
> .list-group-item:last-child {
|
||||||
|
border-bottom-left-radius: var(--bs-dropdown-border-radius);
|
||||||
|
border-bottom-right-radius: var(--bs-dropdown-border-radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dark mode
|
// Dark mode
|
||||||
@mixin paperless-green-dark-mode {
|
@mixin paperless-green-dark-mode {
|
||||||
--pngx-primary-lightness: 31%;
|
--pngx-primary-lightness: 31%;
|
||||||
@@ -79,6 +93,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
|||||||
--pngx-bg-alt2: #232323;
|
--pngx-bg-alt2: #232323;
|
||||||
--pngx-bg-darker: #101216;
|
--pngx-bg-darker: #101216;
|
||||||
--pngx-bg-disabled: var(--pngx-bg-alt);
|
--pngx-bg-disabled: var(--pngx-bg-alt);
|
||||||
|
--pngx-card-hover-border: var(--bs-border-color);
|
||||||
--pngx-focus-alpha: 0.6;
|
--pngx-focus-alpha: 0.6;
|
||||||
--pngx-primary-faded: var(--pngx-primary-darken-15);
|
--pngx-primary-faded: var(--pngx-primary-darken-15);
|
||||||
--pngx-primary-text-contrast: var(--bs-body-color);
|
--pngx-primary-text-contrast: var(--bs-body-color);
|
||||||
@@ -238,13 +253,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
|||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
.des,
|
|
||||||
.asc {
|
|
||||||
&::after {
|
|
||||||
filter: invert(0.8); /* arrow is a black inline png bkgd image (!) so use filter */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.table-hover > tbody > tr:hover > * {
|
&.table-hover > tbody > tr:hover > * {
|
||||||
background-color: var(--bs-light);
|
background-color: var(--bs-light);
|
||||||
color: var(--pngx-body-color-accent);
|
color: var(--pngx-body-color-accent);
|
||||||
|
|||||||
@@ -41,7 +41,16 @@ class SuggestionCacheData:
|
|||||||
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
|
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
|
||||||
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
|
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
|
||||||
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
||||||
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1000 # Marker distinguishing LLM suggestions
|
# Marker distinguishing LLM suggestions from classifier-generated ones (whose
|
||||||
|
# FORMAT_VERSION lives in a much lower range - see DocumentClassifier). Bump
|
||||||
|
# this whenever the *shape* of the cached `suggestions` dict changes, so a
|
||||||
|
# cache entry written by a previous release can never be read back by code
|
||||||
|
# that expects a different shape:
|
||||||
|
# 1000 - initial LLM suggestions cache (flat lists of resolved object ids
|
||||||
|
# per taxonomy field)
|
||||||
|
# 1001 - suggestions reshaped to {"existing_ids": [...], "new_names":
|
||||||
|
# [...]} per taxonomy field (#13676)
|
||||||
|
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
|
||||||
|
|
||||||
CACHE_1_MINUTE: Final[int] = 60
|
CACHE_1_MINUTE: Final[int] = 60
|
||||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||||
@@ -204,7 +213,11 @@ def get_llm_suggestion_cache(
|
|||||||
doc_key = get_suggestion_cache_key(document_id)
|
doc_key = get_suggestion_cache_key(document_id)
|
||||||
data: SuggestionCacheData = cache.get(doc_key)
|
data: SuggestionCacheData = cache.get(doc_key)
|
||||||
|
|
||||||
if data and data.classifier_hash == backend:
|
if (
|
||||||
|
data
|
||||||
|
and data.classifier_version == LLM_CACHE_CLASSIFIER_VERSION
|
||||||
|
and data.classifier_hash == backend
|
||||||
|
):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@@ -235,6 +236,58 @@ def permitted_object_ids(
|
|||||||
).values_list("id", flat=True)
|
).values_list("id", flat=True)
|
||||||
|
|
||||||
|
|
||||||
|
ModelT = TypeVar("ModelT", bound=Model)
|
||||||
|
|
||||||
|
|
||||||
|
def user_is_unrestricted(user: User | None) -> bool:
|
||||||
|
"""
|
||||||
|
True when ``user`` means "no restriction at all" (an absent user, or an
|
||||||
|
*active* superuser) without needing a database check to know it.
|
||||||
|
|
||||||
|
``permitted_object_ids(None, ...)`` itself means the much narrower "only
|
||||||
|
unowned rows", which is NOT the same thing as "no user filtering
|
||||||
|
requested", so callers must special-case this before ever calling it.
|
||||||
|
A deactivated superuser is deliberately NOT unrestricted here, matching
|
||||||
|
permitted_object_ids's own is_active-before-is_superuser ordering.
|
||||||
|
|
||||||
|
Callers that can avoid a database round trip entirely when this is true
|
||||||
|
(e.g. checking a single already-loaded object's visibility rather than
|
||||||
|
filtering a queryset) should do so via this function directly, rather
|
||||||
|
than through restrict_queryset_to_visible() below.
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
getattr(user, "is_authenticated", False)
|
||||||
|
and getattr(user, "is_active", False)
|
||||||
|
and getattr(user, "is_superuser", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def restrict_queryset_to_visible(
|
||||||
|
queryset: QuerySet[ModelT],
|
||||||
|
user: User | None,
|
||||||
|
perm: str,
|
||||||
|
) -> QuerySet[ModelT]:
|
||||||
|
"""
|
||||||
|
Restrict ``queryset`` to the rows ``user`` may see with ``perm``.
|
||||||
|
|
||||||
|
Delegates the visibility check to the database as a
|
||||||
|
``WHERE id IN (subquery)`` rather than materializing the full
|
||||||
|
permitted-id set into a Python collection first: a caller that only
|
||||||
|
needs to check a small handful of rows (a resolved-id list, a few
|
||||||
|
RAG-neighbour candidate ids) never pays for scanning or holding the
|
||||||
|
installation's entire taxonomy in memory to do it.
|
||||||
|
|
||||||
|
Returns ``queryset`` unchanged for user_is_unrestricted(user); every
|
||||||
|
other case is delegated to ``permitted_object_ids`` rather than
|
||||||
|
re-deciding the ordering here.
|
||||||
|
"""
|
||||||
|
if user_is_unrestricted(user):
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
||||||
|
|
||||||
|
|
||||||
def permitted_document_ids(
|
def permitted_document_ids(
|
||||||
user: User | None,
|
user: User | None,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -223,7 +223,27 @@ class WriteBatch:
|
|||||||
)
|
)
|
||||||
time.sleep(sleep_s)
|
time.sleep(sleep_s)
|
||||||
|
|
||||||
self._raw_writer = self._backend._index.writer()
|
# Open a fresh Index (and thus a fresh Tantivy ManagedDirectory)
|
||||||
|
# for the write, rather than reusing the process-local cached
|
||||||
|
# index. ManagedDirectory loads its GC bookkeeping (.managed.json)
|
||||||
|
# once, at construction, and never re-reads it; paperless runs
|
||||||
|
# several long-lived processes (Granian workers, Celery workers)
|
||||||
|
# that take turns writing under the file lock above. A cached,
|
||||||
|
# long-lived writer index would carry a stale managed-files view
|
||||||
|
# and, on commit, overwrite .managed.json with that stale view -
|
||||||
|
# permanently losing track of segment files other processes
|
||||||
|
# registered in the meantime, so they can never be garbage
|
||||||
|
# collected. Reopening fresh here always picks up the current
|
||||||
|
# on-disk state. The long-lived self._backend._index is used for
|
||||||
|
# reads only and is reloaded (not reopened) after commit below.
|
||||||
|
write_index = tantivy.Index(
|
||||||
|
build_schema(),
|
||||||
|
path=str(self._backend._path),
|
||||||
|
)
|
||||||
|
register_tokenizers(write_index, settings.SEARCH_LANGUAGE)
|
||||||
|
self._raw_writer = write_index.writer()
|
||||||
|
else:
|
||||||
|
self._raw_writer = self._backend._index.writer()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@@ -21,6 +24,17 @@ from documents.tests.factories import UserFactory
|
|||||||
|
|
||||||
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
pytestmark = [pytest.mark.search, pytest.mark.django_db]
|
||||||
|
|
||||||
|
# Extensions of actual Tantivy segment data files, as opposed to its own
|
||||||
|
# bookkeeping files (meta.json, .managed.json, lock files).
|
||||||
|
_SEGMENT_FILE_EXTENSIONS = (
|
||||||
|
".fast",
|
||||||
|
".fieldnorm",
|
||||||
|
".idx",
|
||||||
|
".pos",
|
||||||
|
".store",
|
||||||
|
".term",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestWriteBatch:
|
class TestWriteBatch:
|
||||||
"""Test WriteBatch context manager functionality."""
|
"""Test WriteBatch context manager functionality."""
|
||||||
@@ -1014,3 +1028,63 @@ class TestHighlightHits:
|
|||||||
hits = backend.highlight_hits("quick", [doc.pk])
|
hits = backend.highlight_hits("quick", [doc.pk])
|
||||||
|
|
||||||
assert len(hits) == 0
|
assert len(hits) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestIndexDirectoryGarbageCollection:
|
||||||
|
"""Regression tests for Tantivy segment files leaking on disk when
|
||||||
|
multiple long-lived worker processes (Granian/Celery) take turns writing
|
||||||
|
to the same on-disk index (issue #13679)."""
|
||||||
|
|
||||||
|
def test_no_permanently_orphaned_segment_files_across_worker_processes(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Simulate two long-lived worker processes, each with its own
|
||||||
|
process-local ``TantivyBackend``/``Index`` opened once at process
|
||||||
|
start, alternating turns as the writer -- exactly how paperless runs
|
||||||
|
in production (several Granian + Celery worker processes).
|
||||||
|
|
||||||
|
Every segment file physically present on disk must still be tracked
|
||||||
|
in Tantivy's ``.managed.json`` bookkeeping; otherwise it can never be
|
||||||
|
garbage collected by anyone again and the index directory grows
|
||||||
|
without bound.
|
||||||
|
"""
|
||||||
|
index_dir = tmp_path / "index"
|
||||||
|
index_dir.mkdir()
|
||||||
|
|
||||||
|
worker_a = TantivyBackend(path=index_dir)
|
||||||
|
worker_a.open()
|
||||||
|
worker_b = TantivyBackend(path=index_dir)
|
||||||
|
worker_b.open()
|
||||||
|
workers = [worker_a, worker_b]
|
||||||
|
|
||||||
|
docs = [
|
||||||
|
DocumentFactory.create(checksum=f"GC{i}", title=f"gc doc {i}")
|
||||||
|
for i in range(5)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Alternate writers across many commits, repeatedly upserting the
|
||||||
|
# same documents so segments accumulate and get superseded,
|
||||||
|
# forcing the delete+add upsert pattern and eventual merges.
|
||||||
|
for i in range(30):
|
||||||
|
worker = workers[i % len(workers)]
|
||||||
|
doc = docs[i % len(docs)]
|
||||||
|
worker.add_or_update(doc)
|
||||||
|
finally:
|
||||||
|
worker_a.close()
|
||||||
|
worker_b.close()
|
||||||
|
|
||||||
|
managed_path = index_dir / ".managed.json"
|
||||||
|
managed = set(json.loads(managed_path.read_text()))
|
||||||
|
on_disk = {
|
||||||
|
p.name
|
||||||
|
for p in index_dir.iterdir()
|
||||||
|
if p.is_file() and p.suffix in _SEGMENT_FILE_EXTENSIONS
|
||||||
|
}
|
||||||
|
orphans = on_disk - managed
|
||||||
|
|
||||||
|
assert not orphans, (
|
||||||
|
"Segment files present on disk but absent from Tantivy's "
|
||||||
|
f".managed.json bookkeeping (permanently un-collectible): {orphans}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from documents.models import StoragePath
|
|||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
from documents.serialisers import _get_viewable_duplicates
|
from documents.serialisers import _get_viewable_duplicates
|
||||||
from documents.tests.factories import CorrespondentFactory
|
from documents.tests.factories import CorrespondentFactory
|
||||||
from documents.tests.factories import DocumentFactory
|
from documents.tests.factories import DocumentFactory
|
||||||
@@ -736,7 +737,7 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
|||||||
NOTE: this uses ``set_permissions`` (owner reassignment) rather than
|
NOTE: this uses ``set_permissions`` (owner reassignment) rather than
|
||||||
``delete`` as the operation, because Tag.tn_parent (django-treenode)
|
``delete`` as the operation, because Tag.tn_parent (django-treenode)
|
||||||
cascades deletes to descendants at the database/ORM level regardless
|
cascades deletes to descendants at the database/ORM level regardless
|
||||||
of which tags the view resolved into ``objs`` -- a delete-based test
|
of which tags the view resolved into ``objs`` - a delete-based test
|
||||||
would pass/fail based on FK cascade behavior, not on whether the
|
would pass/fail based on FK cascade behavior, not on whether the
|
||||||
descendant-expansion logic itself respected per-object permissions.
|
descendant-expansion logic itself respected per-object permissions.
|
||||||
"""
|
"""
|
||||||
@@ -783,3 +784,97 @@ class TestBulkEditObjectsTagDescendantPartialPermission:
|
|||||||
assert parent.owner == requester
|
assert parent.owner == requester
|
||||||
assert permitted_child.owner == requester
|
assert permitted_child.owner == requester
|
||||||
assert unpermitted_child.owner == owner
|
assert unpermitted_child.owner == owner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestRestrictQuerysetToVisible:
|
||||||
|
"""restrict_queryset_to_visible() returns its queryset argument
|
||||||
|
unchanged only for "no restriction at all", so the cases that may do
|
||||||
|
that have to be kept narrow."""
|
||||||
|
|
||||||
|
def test_no_user_means_no_restriction(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- No user at all (a system-triggered call)
|
||||||
|
WHEN:
|
||||||
|
- restrict_queryset_to_visible() is called
|
||||||
|
THEN:
|
||||||
|
- The queryset is returned unfiltered, rather than
|
||||||
|
permitted_object_ids(None, ...)'s narrower "unowned rows only"
|
||||||
|
"""
|
||||||
|
owner = User.objects.create_user(username="vis_none_owner")
|
||||||
|
tag = TagFactory(owner=owner)
|
||||||
|
|
||||||
|
visible = restrict_queryset_to_visible(Tag.objects.all(), None, "view_tag")
|
||||||
|
|
||||||
|
assert tag.pk in visible.values_list("pk", flat=True)
|
||||||
|
|
||||||
|
def test_active_superuser_means_no_restriction(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An active superuser
|
||||||
|
WHEN:
|
||||||
|
- restrict_queryset_to_visible() is called
|
||||||
|
THEN:
|
||||||
|
- The queryset is returned unfiltered, skipping the permission
|
||||||
|
lookup entirely
|
||||||
|
"""
|
||||||
|
superuser = User.objects.create_superuser(username="vis_active_super")
|
||||||
|
owner = User.objects.create_user(username="vis_active_super_owner")
|
||||||
|
tag = TagFactory(owner=owner)
|
||||||
|
|
||||||
|
visible = restrict_queryset_to_visible(
|
||||||
|
Tag.objects.all(),
|
||||||
|
superuser,
|
||||||
|
"view_tag",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tag.pk in visible.values_list("pk", flat=True)
|
||||||
|
|
||||||
|
def test_inactive_superuser_is_denied_not_unrestricted(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A deactivated superuser
|
||||||
|
WHEN:
|
||||||
|
- restrict_queryset_to_visible() is called
|
||||||
|
THEN:
|
||||||
|
- No rows are visible, never the whole unrestricted queryset -
|
||||||
|
deactivation has to win over the superuser shortcut, matching
|
||||||
|
permitted_object_ids's own ordering
|
||||||
|
"""
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="vis_inactive_super",
|
||||||
|
is_active=False,
|
||||||
|
is_superuser=True,
|
||||||
|
)
|
||||||
|
TagFactory(owner=None)
|
||||||
|
TagFactory(owner=user)
|
||||||
|
|
||||||
|
visible = restrict_queryset_to_visible(Tag.objects.all(), user, "view_tag")
|
||||||
|
|
||||||
|
assert not visible.exists()
|
||||||
|
|
||||||
|
def test_regular_user_gets_permitted_ids(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An ordinary active user and a tag owned by someone else
|
||||||
|
WHEN:
|
||||||
|
- restrict_queryset_to_visible() is called
|
||||||
|
THEN:
|
||||||
|
- Only the rows permitted_object_ids() reports are visible
|
||||||
|
"""
|
||||||
|
user = User.objects.create_user(username="vis_regular")
|
||||||
|
other = User.objects.create_user(username="vis_regular_other")
|
||||||
|
own = TagFactory(owner=user)
|
||||||
|
hidden = TagFactory(owner=other)
|
||||||
|
|
||||||
|
visible_ids = set(
|
||||||
|
restrict_queryset_to_visible(
|
||||||
|
Tag.objects.all(),
|
||||||
|
user,
|
||||||
|
"view_tag",
|
||||||
|
).values_list("pk", flat=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert own.pk in visible_ids
|
||||||
|
assert hidden.pk not in visible_ids
|
||||||
|
|||||||
@@ -352,20 +352,95 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
mock_refresh_cache,
|
mock_refresh_cache,
|
||||||
mock_get_cache,
|
mock_get_cache,
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_get_cache.return_value = MagicMock(suggestions={"tags": ["tag1", "tag2"]})
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A cached LLM classification holding the raw existing_ids/
|
||||||
|
new_names choices (never resolved object ids)
|
||||||
|
WHEN:
|
||||||
|
- ai_suggestions is requested
|
||||||
|
THEN:
|
||||||
|
- The cached choices are resolved into ids for this request
|
||||||
|
(not returned verbatim from the cache) and the cache's TTL is
|
||||||
|
refreshed
|
||||||
|
"""
|
||||||
|
mock_get_cache.return_value = MagicMock(
|
||||||
|
suggestions={
|
||||||
|
"title": "Cached Title",
|
||||||
|
"tags": {"existing_ids": [self.tag1.pk], "new_names": []},
|
||||||
|
"correspondents": {"existing_ids": [], "new_names": []},
|
||||||
|
"document_types": {"existing_ids": [], "new_names": []},
|
||||||
|
"storage_paths": {"existing_ids": [], "new_names": []},
|
||||||
|
"dates": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
self.client.force_login(user=self.user)
|
self.client.force_login(user=self.user)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.json(), {"tags": ["tag1", "tag2"]})
|
self.assertEqual(response.json()["title"], "Cached Title")
|
||||||
|
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||||
mock_get_cache.assert_called_once_with(
|
mock_get_cache.assert_called_once_with(
|
||||||
self.document.pk,
|
self.document.pk,
|
||||||
backend="mock_backend",
|
backend="mock_backend",
|
||||||
)
|
)
|
||||||
mock_refresh_cache.assert_called_once_with(self.document.pk)
|
mock_refresh_cache.assert_called_once_with(self.document.pk)
|
||||||
|
|
||||||
|
@patch("documents.views.get_llm_suggestion_cache")
|
||||||
|
@patch("documents.views.refresh_suggestions_cache")
|
||||||
|
@override_settings(
|
||||||
|
AI_ENABLED=True,
|
||||||
|
LLM_BACKEND="mock_backend",
|
||||||
|
)
|
||||||
|
def test_ai_suggestions_cache_hit_re_filters_for_narrower_requester(
|
||||||
|
self,
|
||||||
|
mock_refresh_cache,
|
||||||
|
mock_get_cache,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A cached LLM classification whose existing_ids include a tag
|
||||||
|
only visible to a broader-visibility user (e.g. the requester
|
||||||
|
who originally generated it)
|
||||||
|
- A second, non-superuser requester who may change the document
|
||||||
|
but has no permission to view that tag
|
||||||
|
WHEN:
|
||||||
|
- ai_suggestions is requested by the second requester and the
|
||||||
|
cache is hit
|
||||||
|
THEN:
|
||||||
|
- The cache hit still runs permission filtering fresh for this
|
||||||
|
requester; the invisible tag id does not leak into either the
|
||||||
|
matched or suggested tags
|
||||||
|
"""
|
||||||
|
tag_owner = User.objects.create_user(username="cache_tag_owner")
|
||||||
|
invisible_tag = Tag.objects.create(name="cache_restricted", owner=tag_owner)
|
||||||
|
requester = User.objects.create_user(username="cache_requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
*Permission.objects.filter(
|
||||||
|
codename__in=["view_document", "change_document", "view_tag"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mock_get_cache.return_value = MagicMock(
|
||||||
|
suggestions={
|
||||||
|
"title": "Untitled",
|
||||||
|
"tags": {"existing_ids": [invisible_tag.pk], "new_names": []},
|
||||||
|
"correspondents": {"existing_ids": [], "new_names": []},
|
||||||
|
"document_types": {"existing_ids": [], "new_names": []},
|
||||||
|
"storage_paths": {"existing_ids": [], "new_names": []},
|
||||||
|
"dates": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_login(user=requester)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.json()["tags"], [])
|
||||||
|
self.assertEqual(response.json()["suggested_tags"], [])
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("documents.views.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
@@ -377,10 +452,16 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
) -> None:
|
) -> None:
|
||||||
mock_get_ai_classification.return_value = {
|
mock_get_ai_classification.return_value = {
|
||||||
"title": "AI Title",
|
"title": "AI Title",
|
||||||
"tags": ["tag1", "tag2"],
|
"tags": {"existing_ids": [self.tag1.pk], "new_names": ["tag2"]},
|
||||||
"correspondents": ["correspondent1"],
|
"correspondents": {
|
||||||
"document_types": ["type1"],
|
"existing_ids": [self.correspondent1.pk],
|
||||||
"storage_paths": ["path1"],
|
"new_names": [],
|
||||||
|
},
|
||||||
|
"document_types": {
|
||||||
|
"existing_ids": [self.document_type1.pk],
|
||||||
|
"new_names": [],
|
||||||
|
},
|
||||||
|
"storage_paths": {"existing_ids": [self.path1.pk], "new_names": []},
|
||||||
"dates": ["2023-01-01"],
|
"dates": ["2023-01-01"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,10 +503,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
|
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
|
||||||
mock_get_ai_classification.return_value = {
|
mock_get_ai_classification.return_value = {
|
||||||
"title": "KI Title",
|
"title": "KI Title",
|
||||||
"tags": [],
|
"tags": {"existing_ids": [], "new_names": []},
|
||||||
"correspondents": [],
|
"correspondents": {"existing_ids": [], "new_names": []},
|
||||||
"document_types": [],
|
"document_types": {"existing_ids": [], "new_names": []},
|
||||||
"storage_paths": [],
|
"storage_paths": {"existing_ids": [], "new_names": []},
|
||||||
"dates": [],
|
"dates": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,10 +542,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
|
UiSettings.objects.create(user=self.user, settings={"language": "de-de"})
|
||||||
mock_get_ai_classification.return_value = {
|
mock_get_ai_classification.return_value = {
|
||||||
"title": "Titre IA",
|
"title": "Titre IA",
|
||||||
"tags": [],
|
"tags": {"existing_ids": [], "new_names": []},
|
||||||
"correspondents": [],
|
"correspondents": {"existing_ids": [], "new_names": []},
|
||||||
"document_types": [],
|
"document_types": {"existing_ids": [], "new_names": []},
|
||||||
"storage_paths": [],
|
"storage_paths": {"existing_ids": [], "new_names": []},
|
||||||
"dates": [],
|
"dates": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,10 +583,10 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
either yields a cache miss instead of a stale hit."""
|
either yields a cache miss instead of a stale hit."""
|
||||||
mock_get_ai_classification.return_value = {
|
mock_get_ai_classification.return_value = {
|
||||||
"title": "Answer A",
|
"title": "Answer A",
|
||||||
"tags": [],
|
"tags": {"existing_ids": [], "new_names": []},
|
||||||
"correspondents": [],
|
"correspondents": {"existing_ids": [], "new_names": []},
|
||||||
"document_types": [],
|
"document_types": {"existing_ids": [], "new_names": []},
|
||||||
"storage_paths": [],
|
"storage_paths": {"existing_ids": [], "new_names": []},
|
||||||
"dates": [],
|
"dates": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,6 +660,132 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("documents.views.get_ai_document_classification")
|
||||||
|
@override_settings(
|
||||||
|
AI_ENABLED=True,
|
||||||
|
LLM_BACKEND="mock_backend",
|
||||||
|
)
|
||||||
|
def test_ai_suggestions_combines_existing_ids_and_new_names(
|
||||||
|
self,
|
||||||
|
mock_get_ai_classification,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- AI classification returns a taxonomy choice with both an
|
||||||
|
existing tag id and a new tag name not present in the database
|
||||||
|
WHEN:
|
||||||
|
- ai_suggestions is requested
|
||||||
|
THEN:
|
||||||
|
- the existing id is resolved into the matched tags list
|
||||||
|
- the new name is fuzzy-matched, and since it doesn't match any
|
||||||
|
existing tag, it is surfaced as a suggested tag
|
||||||
|
"""
|
||||||
|
mock_get_ai_classification.return_value = {
|
||||||
|
"title": "Lab Report",
|
||||||
|
"tags": {"existing_ids": [self.tag1.pk], "new_names": ["Follow-up"]},
|
||||||
|
"correspondents": {"existing_ids": [], "new_names": []},
|
||||||
|
"document_types": {"existing_ids": [], "new_names": []},
|
||||||
|
"storage_paths": {"existing_ids": [], "new_names": []},
|
||||||
|
"dates": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
self.client.force_login(user=self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||||
|
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
|
||||||
|
|
||||||
|
@patch("documents.views.get_ai_document_classification")
|
||||||
|
@override_settings(
|
||||||
|
AI_ENABLED=True,
|
||||||
|
LLM_BACKEND="mock_backend",
|
||||||
|
)
|
||||||
|
def test_ai_suggestions_deduplicates_id_matched_via_both_paths(
|
||||||
|
self,
|
||||||
|
mock_get_ai_classification,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- AI classification returns the same tag both as an existing_id
|
||||||
|
and as a new_name that fuzzy-matches that same tag
|
||||||
|
WHEN:
|
||||||
|
- ai_suggestions is requested
|
||||||
|
THEN:
|
||||||
|
- The tag's id appears exactly once in the response, not twice
|
||||||
|
"""
|
||||||
|
mock_get_ai_classification.return_value = {
|
||||||
|
"title": "Lab Report",
|
||||||
|
"tags": {
|
||||||
|
"existing_ids": [self.tag1.pk],
|
||||||
|
"new_names": [self.tag1.name],
|
||||||
|
},
|
||||||
|
"correspondents": {"existing_ids": [], "new_names": []},
|
||||||
|
"document_types": {"existing_ids": [], "new_names": []},
|
||||||
|
"storage_paths": {"existing_ids": [], "new_names": []},
|
||||||
|
"dates": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
self.client.force_login(user=self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||||
|
self.assertEqual(response.json()["suggested_tags"], [])
|
||||||
|
|
||||||
|
@patch("documents.views.get_ai_document_classification")
|
||||||
|
@override_settings(
|
||||||
|
AI_ENABLED=True,
|
||||||
|
LLM_BACKEND="mock_backend",
|
||||||
|
)
|
||||||
|
def test_ai_suggestions_existing_id_not_visible_falls_through_to_suggested(
|
||||||
|
self,
|
||||||
|
mock_get_ai_classification,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A non-superuser who may change the document but has no
|
||||||
|
permission to view a tag owned by somebody else
|
||||||
|
- AI classification returns that tag's id in existing_ids (e.g.
|
||||||
|
from a cached response generated for a broader-visibility user)
|
||||||
|
WHEN:
|
||||||
|
- ai_suggestions is requested by that user
|
||||||
|
THEN:
|
||||||
|
- the invisible id is silently dropped by resolve_tag_ids, so
|
||||||
|
permission filtering survives the full request path
|
||||||
|
- it does not appear in either the matched or suggested tags
|
||||||
|
"""
|
||||||
|
tag_owner = User.objects.create_user(username="tagowner")
|
||||||
|
invisible_tag = Tag.objects.create(name="restricted", owner=tag_owner)
|
||||||
|
requester = User.objects.create_user(username="requester")
|
||||||
|
requester.user_permissions.add(
|
||||||
|
*Permission.objects.filter(
|
||||||
|
codename__in=["view_document", "change_document", "view_tag"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_get_ai_classification.return_value = {
|
||||||
|
"title": "Untitled",
|
||||||
|
"tags": {"existing_ids": [invisible_tag.pk], "new_names": []},
|
||||||
|
"correspondents": {"existing_ids": [], "new_names": []},
|
||||||
|
"document_types": {"existing_ids": [], "new_names": []},
|
||||||
|
"storage_paths": {"existing_ids": [], "new_names": []},
|
||||||
|
"dates": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
self.client.force_login(user=requester)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.json()["tags"], [])
|
||||||
|
self.assertEqual(response.json()["suggested_tags"], [])
|
||||||
|
|
||||||
def test_invalidate_suggestions_cache(self) -> None:
|
def test_invalidate_suggestions_cache(self) -> None:
|
||||||
self.client.force_login(user=self.user)
|
self.client.force_login(user=self.user)
|
||||||
suggestions = {
|
suggestions = {
|
||||||
|
|||||||
+99
-45
@@ -7,6 +7,7 @@ import tempfile
|
|||||||
import zipfile
|
import zipfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -249,6 +250,10 @@ from paperless_ai.matching import match_correspondents_by_name
|
|||||||
from paperless_ai.matching import match_document_types_by_name
|
from paperless_ai.matching import match_document_types_by_name
|
||||||
from paperless_ai.matching import match_storage_paths_by_name
|
from paperless_ai.matching import match_storage_paths_by_name
|
||||||
from paperless_ai.matching import match_tags_by_name
|
from paperless_ai.matching import match_tags_by_name
|
||||||
|
from paperless_ai.matching import resolve_correspondent_ids
|
||||||
|
from paperless_ai.matching import resolve_document_type_ids
|
||||||
|
from paperless_ai.matching import resolve_storage_path_ids
|
||||||
|
from paperless_ai.matching import resolve_tag_ids
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
||||||
@@ -258,6 +263,9 @@ from paperless_mail.serialisers import MailRuleSerializer
|
|||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from paperless_ai.base_model import TaxonomyChoiceDict
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.api")
|
logger = logging.getLogger("paperless.api")
|
||||||
|
|
||||||
@@ -1546,80 +1554,126 @@ class DocumentViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if cached_llm_suggestions:
|
if cached_llm_suggestions:
|
||||||
|
# Only the raw model choices are cached, never resolved object
|
||||||
|
# ids. resolve_choice() below still runs permission filtering
|
||||||
|
# freshly for this requester on every request, cache hit or not,
|
||||||
|
# so a resolved id cached for one user's visibility can never be
|
||||||
|
# handed unfiltered to a second, less-privileged requester of
|
||||||
|
# the same (backend-keyed, not user-keyed) cache entry.
|
||||||
refresh_suggestions_cache(doc.pk)
|
refresh_suggestions_cache(doc.pk)
|
||||||
return Response(cached_llm_suggestions.suggestions)
|
llm_suggestions = cached_llm_suggestions.suggestions
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
llm_suggestions = get_ai_document_classification(
|
||||||
|
doc,
|
||||||
|
request.user,
|
||||||
|
output_language,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Invalid AI configuration while generating suggestions for "
|
||||||
|
"document %s: %s",
|
||||||
|
doc.pk,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise ValidationError(
|
||||||
|
{"ai": [_("Invalid AI configuration.")]},
|
||||||
|
) from exc
|
||||||
|
except LLMTimeoutError as exc:
|
||||||
|
logger.exception(
|
||||||
|
"AI backend timed out while generating suggestions for "
|
||||||
|
"document %s: %s",
|
||||||
|
doc.pk,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{"ai": [_("AI backend request timed out.")]},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
set_llm_suggestions_cache(
|
||||||
|
doc.pk,
|
||||||
|
llm_suggestions,
|
||||||
|
backend=llm_cache_backend,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
tags_choice: TaxonomyChoiceDict = llm_suggestions["tags"]
|
||||||
llm_suggestions = get_ai_document_classification(
|
correspondents_choice: TaxonomyChoiceDict = llm_suggestions["correspondents"]
|
||||||
doc,
|
document_types_choice: TaxonomyChoiceDict = llm_suggestions["document_types"]
|
||||||
|
storage_paths_choice: TaxonomyChoiceDict = llm_suggestions["storage_paths"]
|
||||||
|
|
||||||
|
def resolve_choice(
|
||||||
|
choice: "TaxonomyChoiceDict",
|
||||||
|
resolve_ids: Callable[[list[int], User], list],
|
||||||
|
match_names: Callable[[list[str], User], list],
|
||||||
|
) -> list:
|
||||||
|
"""The ids the model picked from the candidates it was shown, plus
|
||||||
|
name matches for the values it proposed as new. The schema allows
|
||||||
|
the same object to satisfy both an existing_id and a new_name in
|
||||||
|
one valid response, so results are deduplicated by pk (keeping
|
||||||
|
first-seen order) rather than trusting the two lookups to be
|
||||||
|
disjoint.
|
||||||
|
"""
|
||||||
|
matched = resolve_ids(choice["existing_ids"], request.user) + match_names(
|
||||||
|
choice["new_names"],
|
||||||
request.user,
|
request.user,
|
||||||
output_language,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
logger.exception(
|
|
||||||
"Invalid AI configuration while generating suggestions for "
|
|
||||||
"document %s: %s",
|
|
||||||
doc.pk,
|
|
||||||
exc,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
raise ValidationError({"ai": [_("Invalid AI configuration.")]}) from exc
|
|
||||||
except LLMTimeoutError as exc:
|
|
||||||
logger.exception(
|
|
||||||
"AI backend timed out while generating suggestions for document %s: %s",
|
|
||||||
doc.pk,
|
|
||||||
exc,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
{"ai": [_("AI backend request timed out.")]},
|
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
)
|
)
|
||||||
|
seen_ids: set[int] = set()
|
||||||
|
deduped = []
|
||||||
|
for obj in matched:
|
||||||
|
if obj.pk in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(obj.pk)
|
||||||
|
deduped.append(obj)
|
||||||
|
return deduped
|
||||||
|
|
||||||
matched_tags = match_tags_by_name(
|
matched_tags = resolve_choice(
|
||||||
llm_suggestions.get("tags", []),
|
tags_choice,
|
||||||
request.user,
|
resolve_tag_ids,
|
||||||
|
match_tags_by_name,
|
||||||
)
|
)
|
||||||
matched_correspondents = match_correspondents_by_name(
|
matched_correspondents = resolve_choice(
|
||||||
llm_suggestions.get("correspondents", []),
|
correspondents_choice,
|
||||||
request.user,
|
resolve_correspondent_ids,
|
||||||
|
match_correspondents_by_name,
|
||||||
)
|
)
|
||||||
matched_types = match_document_types_by_name(
|
matched_types = resolve_choice(
|
||||||
llm_suggestions.get("document_types", []),
|
document_types_choice,
|
||||||
request.user,
|
resolve_document_type_ids,
|
||||||
|
match_document_types_by_name,
|
||||||
)
|
)
|
||||||
matched_paths = match_storage_paths_by_name(
|
matched_paths = resolve_choice(
|
||||||
llm_suggestions.get("storage_paths", []),
|
storage_paths_choice,
|
||||||
request.user,
|
resolve_storage_path_ids,
|
||||||
|
match_storage_paths_by_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
resp_data = {
|
resp_data = {
|
||||||
"title": llm_suggestions.get("title"),
|
"title": llm_suggestions["title"],
|
||||||
"tags": [t.id for t in matched_tags],
|
"tags": [t.id for t in matched_tags],
|
||||||
"suggested_tags": extract_unmatched_names(
|
"suggested_tags": extract_unmatched_names(
|
||||||
llm_suggestions.get("tags", []),
|
tags_choice["new_names"],
|
||||||
matched_tags,
|
matched_tags,
|
||||||
),
|
),
|
||||||
"correspondents": [c.id for c in matched_correspondents],
|
"correspondents": [c.id for c in matched_correspondents],
|
||||||
"suggested_correspondents": extract_unmatched_names(
|
"suggested_correspondents": extract_unmatched_names(
|
||||||
llm_suggestions.get("correspondents", []),
|
correspondents_choice["new_names"],
|
||||||
matched_correspondents,
|
matched_correspondents,
|
||||||
),
|
),
|
||||||
"document_types": [d.id for d in matched_types],
|
"document_types": [d.id for d in matched_types],
|
||||||
"suggested_document_types": extract_unmatched_names(
|
"suggested_document_types": extract_unmatched_names(
|
||||||
llm_suggestions.get("document_types", []),
|
document_types_choice["new_names"],
|
||||||
matched_types,
|
matched_types,
|
||||||
),
|
),
|
||||||
"storage_paths": [s.id for s in matched_paths],
|
"storage_paths": [s.id for s in matched_paths],
|
||||||
"suggested_storage_paths": extract_unmatched_names(
|
"suggested_storage_paths": extract_unmatched_names(
|
||||||
llm_suggestions.get("storage_paths", []),
|
storage_paths_choice["new_names"],
|
||||||
matched_paths,
|
matched_paths,
|
||||||
),
|
),
|
||||||
"dates": llm_suggestions.get("dates", []),
|
"dates": llm_suggestions["dates"],
|
||||||
}
|
}
|
||||||
|
|
||||||
set_llm_suggestions_cache(doc.pk, resp_data, backend=llm_cache_backend)
|
|
||||||
|
|
||||||
return Response(resp_data)
|
return Response(resp_data)
|
||||||
|
|
||||||
@action(methods=["get"], detail=True, filter_backends=[])
|
@action(methods=["get"], detail=True, filter_backends=[])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-08-13 19:47+0000\n"
|
"POT-Creation-Date: 2026-08-14 22:52+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1576,7 +1576,7 @@ msgid "workflow runs"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:522 documents/serialisers.py:874
|
#: documents/serialisers.py:522 documents/serialisers.py:874
|
||||||
#: documents/serialisers.py:2769 documents/views.py:299 documents/views.py:2555
|
#: documents/serialisers.py:2769 documents/views.py:307 documents/views.py:2609
|
||||||
#: paperless_mail/serialisers.py:155
|
#: paperless_mail/serialisers.py:155
|
||||||
msgid "Insufficient permissions."
|
msgid "Insufficient permissions."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1617,7 +1617,7 @@ msgstr ""
|
|||||||
msgid "Duplicate document identifiers are not allowed."
|
msgid "Duplicate document identifiers are not allowed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/serialisers.py:2855 documents/views.py:4509
|
#: documents/serialisers.py:2855 documents/views.py:4563
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Documents not found: %(ids)s"
|
msgid "Documents not found: %(ids)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -1885,36 +1885,36 @@ msgstr ""
|
|||||||
msgid "Unable to parse URI {value}"
|
msgid "Unable to parse URI {value}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:292 documents/views.py:2552
|
#: documents/views.py:300 documents/views.py:2606
|
||||||
msgid "Invalid more_like_id"
|
msgid "Invalid more_like_id"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1566
|
#: documents/views.py:1581
|
||||||
msgid "Invalid AI configuration."
|
msgid "Invalid AI configuration."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:1575
|
#: documents/views.py:1592
|
||||||
msgid "AI backend request timed out."
|
msgid "AI backend request timed out."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:2377 documents/views.py:2698
|
#: documents/views.py:2431 documents/views.py:2752
|
||||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4522
|
#: documents/views.py:4576
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Insufficient permissions to share document %(id)s."
|
msgid "Insufficient permissions to share document %(id)s."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4568
|
#: documents/views.py:4622
|
||||||
msgid "Bundle is already being processed."
|
msgid "Bundle is already being processed."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4629
|
#: documents/views.py:4683
|
||||||
msgid "The share link bundle is still being prepared. Please try again later."
|
msgid "The share link bundle is still being prepared. Please try again later."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: documents/views.py:4639
|
#: documents/views.py:4693
|
||||||
msgid "The share link bundle is unavailable."
|
msgid "The share link bundle is unavailable."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user