mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-19 22:38:13 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31caf01ae5 | ||
|
|
e756f5985f | ||
|
|
ce1dee7549 | ||
|
|
fd187cd09a | ||
|
|
814b208280 | ||
|
|
11c32d352e | ||
|
|
c21deaa452 | ||
|
|
c47766121d | ||
|
|
979bcd3896 | ||
|
|
d450f03d5f | ||
|
|
730c2d8688 | ||
|
|
b1738c85d7 | ||
|
|
a8b835fcd0 | ||
|
|
41498113d3 | ||
|
|
309a4b004d | ||
|
|
961f7fd672 | ||
|
|
de94657e17 | ||
|
|
0cb2cdbfdd | ||
|
|
1f3d451a3a | ||
|
|
183cfd2da4 | ||
|
|
15e9a7a017 | ||
|
|
04a927c25f | ||
|
|
30a24ee02e | ||
|
|
1943c99b27 | ||
|
|
8b26ecafb6 | ||
|
|
d4a57204eb | ||
|
|
c50c6f5517 | ||
|
|
34a6c718fa | ||
|
|
14aaa42777 | ||
|
|
c86c822bc7 | ||
|
|
b23b118a49 |
No files matched your search
@@ -76,11 +76,12 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Reaching here with the honeypot means AI usage was not disclosed in the form, so that
|
||||
// message can name the cause.
|
||||
// A missing template label only tells us the issue did not come from the form, which
|
||||
// is reason enough to close it, but not proof of how it was written.
|
||||
const body = honeypot
|
||||
// is reason enough to close it, but not proof of how it was written. It takes priority:
|
||||
// a hand-built body can carry the honeypot *and* a disclosure, and the form message is
|
||||
// the only one that is accurate for both. Reaching the honeypot message means the issue
|
||||
// came from the form without disclosing AI usage, so that message can name the cause.
|
||||
const body = honeypot && !bypassedTemplate
|
||||
? 'This issue was automatically closed because it contains a marker that is only visible to ' +
|
||||
'automated tools, which indicates it was generated by an AI agent, but the "AI usage" field does not ' +
|
||||
'disclose this.\n\n' +
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.3.2
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
|
||||
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
|
||||
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
|
||||
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>5 changes</summary>
|
||||
|
||||
- Fix: copy only direct user grants in DocumentMetadataOverrides.from\_document [@stumpylog](https://github.com/stumpylog) ([#14452](https://github.com/paperless-ngx/paperless-ngx/pull/14452))
|
||||
- Chore(deps): Bump filelock from 3.32.6 to 4.0.12 @[dependabot[bot]](https://github.com/apps/dependabot) ([#14336](https://github.com/paperless-ngx/paperless-ngx/pull/14336))
|
||||
- Fix: support combined text/query searches [@shamoon](https://github.com/shamoon) ([#14443](https://github.com/paperless-ngx/paperless-ngx/pull/14443))
|
||||
- Chore(deps): Bump the uv group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14418](https://github.com/paperless-ngx/paperless-ngx/pull/14418))
|
||||
- Fix: make exporter derived file names unique when originals differ only by extension [@stumpylog](https://github.com/stumpylog) ([#14429](https://github.com/paperless-ngx/paperless-ngx/pull/14429))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.3.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.3.1"
|
||||
version = "3.3.3"
|
||||
description = """\
|
||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||
"""
|
||||
@@ -43,7 +43,7 @@ dependencies = [
|
||||
"drf-spectacular~=0.30",
|
||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.32.0",
|
||||
"filelock>=3.32,<4.1",
|
||||
"flower>=2.0.1,<2.3",
|
||||
"gotenberg-client[httpx]~=1.0",
|
||||
"httpx-oauth~=0.17",
|
||||
@@ -256,8 +256,8 @@ isort.force-single-line = true
|
||||
[tool.codespell]
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
|
||||
skip = """\
|
||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless/tests/samples/mail/*,src/documents/tests/samples\
|
||||
/*,src/paperless_testing/sample_files/*,*.po,*.json\
|
||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||
"""
|
||||
write-changes = true
|
||||
|
||||
|
||||
@@ -134,9 +134,7 @@ def seed_database() -> None:
|
||||
originals.mkdir(parents=True)
|
||||
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
|
||||
thumbnails.mkdir(parents=True)
|
||||
sample_pdf = (
|
||||
SOURCE_ROOT / "paperless_testing" / "sample_files" / "simple-digital.pdf"
|
||||
)
|
||||
sample_pdf = SOURCE_ROOT / "documents" / "tests" / "samples" / "simple.pdf"
|
||||
for document in documents:
|
||||
shutil.copyfile(sample_pdf, originals / document.filename)
|
||||
shutil.copyfile(
|
||||
|
||||
@@ -15,6 +15,8 @@ module.exports = {
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
useESM: true,
|
||||
// Disable the builder's deprecated override; tsconfig.spec.json enables isolation.
|
||||
isolatedModules: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+178
-167
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.3",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
||||
@@ -102,25 +102,34 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
<ngb-pagination
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="totalTasks()"
|
||||
[page]="page"
|
||||
[maxSize]="5"
|
||||
[rotate]="true"
|
||||
size="sm"
|
||||
aria-label="Tasks pagination"
|
||||
(pageChange)="setPage($event)">
|
||||
</ngb-pagination>
|
||||
@if (selectedSection !== TaskSection.All && totalTasks() > pageSize) {
|
||||
<ngb-pagination
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="totalTasks()"
|
||||
[page]="page"
|
||||
[maxSize]="5"
|
||||
[rotate]="true"
|
||||
size="sm"
|
||||
aria-label="Tasks pagination"
|
||||
(pageChange)="setPage($event)">
|
||||
</ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
|
||||
<div class="section-header d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
||||
<div class="small text-muted">
|
||||
<ng-container i18n>{tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}}</ng-container>
|
||||
</div>
|
||||
<div class="section-header mb-2">
|
||||
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
||||
@let count = sectionCount(section);
|
||||
@let hasMore = selectedSection === TaskSection.All && count > tasks.length;
|
||||
<div class="small text-muted">
|
||||
@if (hasMore) {
|
||||
<ng-container i18n>{{tasks.length}} of {{count}} tasks</ng-container>
|
||||
<button class="btn btn-link show-all p-0 ms-2 align-baseline" type="button" (click)="setSection(section)">
|
||||
<ng-container i18n>Show all</ng-container><i-bs class="ms-1" width="1em" height="1em" name="arrow-right"></i-bs>
|
||||
</button>
|
||||
} @else {
|
||||
<ng-container i18n>{count, plural, =1 {1 task} other {{{count}} tasks}}</ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,10 +178,9 @@
|
||||
<div>{{ taskDisplayName(task) }}</div>
|
||||
<div class="small text-muted">
|
||||
@if (taskShowsSeparateTypeLabel(task)) {
|
||||
<span>{{ task.task_type_display }}</span>
|
||||
<span class="mx-1">•</span>
|
||||
<span class="me-2">{{ task.task_type_display }}</span>
|
||||
}
|
||||
<span>{{ task.trigger_source_display }}</span>
|
||||
<span class="text-nowrap"><i-bs class="me-1" width="1em" height="1em" name="box-arrow-in-right"></i-bs>{{ task.trigger_source_display }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
|
||||
|
||||
@@ -72,3 +72,7 @@ pre {
|
||||
tbody tr:nth-last-child(2) td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.section-header .show-all {
|
||||
font-size: inherit;
|
||||
}
|
||||
@@ -223,16 +223,6 @@ describe('TasksComponent', () => {
|
||||
)
|
||||
.flush(paginatedTasks)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.params.get('acknowledged') === 'false' &&
|
||||
req.params.get('page_size') === '25' &&
|
||||
req.params.get('page') === '1'
|
||||
)
|
||||
.flush(paginatedTasks)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
(req) =>
|
||||
@@ -241,8 +231,30 @@ describe('TasksComponent', () => {
|
||||
!req.params.has('status')
|
||||
)
|
||||
.flush(sectionCountResponse)
|
||||
|
||||
flushTaskLists(10)
|
||||
})
|
||||
|
||||
// answers task list requests with the fixture tasks matching their status filter
|
||||
function flushTaskLists(pageSize: number, count?: number) {
|
||||
httpTestingController
|
||||
.match(
|
||||
(req) =>
|
||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
req.params.get('page_size') === `${pageSize}`
|
||||
)
|
||||
.forEach((req) => {
|
||||
const statuses = req.request.params.getAll('status')
|
||||
const results = tasks.filter((task) => statuses.includes(task.status))
|
||||
req.flush({ count: count ?? results.length, results })
|
||||
})
|
||||
}
|
||||
|
||||
function selectSection(section: TaskSection, count: number) {
|
||||
component.setSection(section)
|
||||
flushTaskLists(25, count)
|
||||
}
|
||||
|
||||
it('should display task sections with counts', () => {
|
||||
expect(component.selectedSection).toBe(TaskSection.All)
|
||||
expect(component.selectedTaskType).toBeNull()
|
||||
@@ -338,6 +350,7 @@ describe('TasksComponent', () => {
|
||||
})
|
||||
|
||||
it('should render pagination controls next to the task filter', () => {
|
||||
selectSection(TaskSection.Completed, 30)
|
||||
fixture.detectChanges()
|
||||
|
||||
const controls = fixture.debugElement.query(By.css('.task-controls'))
|
||||
@@ -348,6 +361,51 @@ describe('TasksComponent', () => {
|
||||
expect(pagination).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should only paginate a section with more than one page of tasks', () => {
|
||||
const pagination = () =>
|
||||
fixture.debugElement.query(By.css('ngb-pagination'))
|
||||
|
||||
fixture.detectChanges()
|
||||
expect(pagination()).toBeNull()
|
||||
|
||||
selectSection(TaskSection.Completed, 2)
|
||||
fixture.detectChanges()
|
||||
expect(pagination()).toBeNull()
|
||||
})
|
||||
|
||||
it('should load the all tasks overview one section at a time', () => {
|
||||
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
||||
2
|
||||
)
|
||||
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(3)
|
||||
expect(component.tasksForSection(TaskSection.Completed)).toHaveLength(2)
|
||||
expect(component.totalTasks()).toBe(7)
|
||||
})
|
||||
|
||||
it('should show section totals and link truncated sections to their full list', () => {
|
||||
component.sectionCounts.update((counts) => ({
|
||||
...counts,
|
||||
[TaskSection.Completed]: 27,
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
|
||||
const header = (label: string) =>
|
||||
fixture.debugElement
|
||||
.queryAll(By.css('.section-header'))
|
||||
.find((h) => h.nativeElement.textContent.includes(label))
|
||||
|
||||
expect(header('Needs attention').nativeElement.textContent).toContain(
|
||||
'2 tasks'
|
||||
)
|
||||
expect(header('Needs attention').query(By.css('button'))).toBeNull()
|
||||
|
||||
expect(header('Recently completed').nativeElement.textContent).toContain(
|
||||
'2 of 27 tasks'
|
||||
)
|
||||
header('Recently completed').query(By.css('button')).nativeElement.click()
|
||||
expect(component.selectedSection).toBe(TaskSection.Completed)
|
||||
})
|
||||
|
||||
it('should apply the selected section to the server-side task query', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
|
||||
@@ -368,55 +426,56 @@ describe('TasksComponent', () => {
|
||||
it('should apply task type and trigger source filters to the server-side task query', () => {
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
expect(
|
||||
httpTestingController.match(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('page_size') === '10' &&
|
||||
request.params.get('task_type') === PaperlessTaskType.SanityCheck
|
||||
)
|
||||
.flush({ count: 1, results: [tasks[6]] })
|
||||
).toHaveLength(3)
|
||||
|
||||
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
expect(
|
||||
httpTestingController.match(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('page_size') === '10' &&
|
||||
request.params.get('task_type') === PaperlessTaskType.SanityCheck &&
|
||||
request.params.get('trigger_source') ===
|
||||
PaperlessTaskTriggerSource.System
|
||||
)
|
||||
.flush({ count: 1, results: [tasks[6]] })
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should apply text filters to the server-side task query', () => {
|
||||
component.filterText = 'invoice'
|
||||
jest.advanceTimersByTime(150)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
expect(
|
||||
httpTestingController.match(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('page_size') === '10' &&
|
||||
request.params.get('name') === 'invoice'
|
||||
)
|
||||
.flush({ count: 1, results: [tasks[0]] })
|
||||
).toHaveLength(3)
|
||||
|
||||
component.setFilterTarget(TaskFilterTargetID.Result)
|
||||
|
||||
httpTestingController
|
||||
.expectOne(
|
||||
expect(
|
||||
httpTestingController.match(
|
||||
(request) =>
|
||||
request.url === `${environment.apiBaseUrl}tasks/` &&
|
||||
request.params.get('page_size') === '25' &&
|
||||
request.params.get('page_size') === '10' &&
|
||||
request.params.get('result') === 'invoice'
|
||||
)
|
||||
.flush({ count: 0, results: [] })
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should load a different task page when pagination changes', () => {
|
||||
selectSection(TaskSection.Completed, 30)
|
||||
component.setPage(2)
|
||||
|
||||
const pageTwoTasks = {
|
||||
@@ -440,6 +499,7 @@ describe('TasksComponent', () => {
|
||||
})
|
||||
|
||||
it('should not replace section counts with current-page counts', () => {
|
||||
selectSection(TaskSection.Completed, 30)
|
||||
component.setPage(2)
|
||||
|
||||
httpTestingController
|
||||
@@ -455,11 +515,21 @@ describe('TasksComponent', () => {
|
||||
results: [tasks[0]],
|
||||
})
|
||||
|
||||
expect(component.sectionCount(TaskSection.All)).toBe(7)
|
||||
expect(component.sectionCount(TaskSection.NeedsAttention)).toBe(2)
|
||||
expect(component.sectionCount(TaskSection.InProgress)).toBe(3)
|
||||
expect(component.sectionCount(TaskSection.Completed)).toBe(2)
|
||||
})
|
||||
|
||||
it('should stop loading when the overview fails to load', () => {
|
||||
component.setSection(TaskSection.All)
|
||||
httpTestingController
|
||||
.match((req) => req.params.get('page_size') === '10')[0]
|
||||
.flush('', { status: 500, statusText: 'Error' })
|
||||
|
||||
expect(component.loading()).toBe(false)
|
||||
})
|
||||
|
||||
it('should expose stable task type options and disable empty ones', () => {
|
||||
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
|
||||
PaperlessTaskType.TrainClassifier
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
first,
|
||||
forkJoin,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
takeUntil,
|
||||
timer,
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
PaperlessTaskTriggerSource,
|
||||
PaperlessTaskType,
|
||||
} from 'src/app/data/paperless-task'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
@@ -169,6 +173,7 @@ export class TasksComponent
|
||||
public expandedTask: number
|
||||
public autoRefreshEnabled: boolean = true
|
||||
public readonly pageSize = 25
|
||||
public readonly overviewSize = 10
|
||||
public page: number = 1
|
||||
readonly totalTasks = signal(0)
|
||||
readonly sectionCounts = signal<Record<TaskSection, number>>({
|
||||
@@ -484,13 +489,6 @@ export class TasksComponent
|
||||
return this.sectionCounts()[section]
|
||||
}
|
||||
|
||||
private setSectionCount(section: TaskSection, count: number) {
|
||||
this.sectionCounts.update((counts) => ({
|
||||
...counts,
|
||||
[section]: count,
|
||||
}))
|
||||
}
|
||||
|
||||
sectionShowsResults(section: TaskSection): boolean {
|
||||
return section !== TaskSection.InProgress
|
||||
}
|
||||
@@ -737,21 +735,12 @@ export class TasksComponent
|
||||
this.reloadSectionCounts()
|
||||
|
||||
this.loading.set(true)
|
||||
this.tasksService
|
||||
.list(
|
||||
this.page,
|
||||
this.pageSize,
|
||||
this.getParamsForSection(this.selectedSection)
|
||||
)
|
||||
this.listTasks()
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.pagedTasks.set(result.results)
|
||||
this.totalTasks.set(result.count)
|
||||
this.setSectionCount(TaskSection.All, result.count)
|
||||
if (this.selectedSection !== TaskSection.All) {
|
||||
this.setSectionCount(this.selectedSection, result.count)
|
||||
}
|
||||
this.loading.set(false)
|
||||
if (
|
||||
this.page > 1 &&
|
||||
@@ -767,4 +756,30 @@ export class TasksComponent
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private listTasks(): Observable<Results<PaperlessTask>> {
|
||||
if (this.selectedSection !== TaskSection.All) {
|
||||
return this.tasksService.list(
|
||||
this.page,
|
||||
this.pageSize,
|
||||
this.getParamsForSection(this.selectedSection)
|
||||
)
|
||||
}
|
||||
|
||||
// All is an overview of each section to avoid splitting sections across pages
|
||||
return forkJoin(
|
||||
this.sections.map((section) =>
|
||||
this.tasksService.list(
|
||||
1,
|
||||
this.overviewSize,
|
||||
this.getParamsForSection(section)
|
||||
)
|
||||
)
|
||||
).pipe(
|
||||
map((results) => ({
|
||||
count: results.reduce((sum, result) => sum + result.count, 0),
|
||||
results: results.flatMap((result) => result.results),
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,31 @@ describe('UsersAndGroupsComponent', () => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should logout current user under base href if password changed', () => {
|
||||
jest.useFakeTimers()
|
||||
const base = document.createElement('base')
|
||||
base.href = '/paperless/'
|
||||
document.head.appendChild(base)
|
||||
completeSetup()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||
component.editUser(users[0])
|
||||
const navSpy = jest
|
||||
.spyOn(navUtils, 'setLocationHref')
|
||||
.mockImplementation(() => {})
|
||||
const editDialog = modal.componentInstance as UserEditDialogComponent
|
||||
editDialog.passwordIsSet = true
|
||||
settingsService.currentUser.set(users[0]) // simulate logged in as same user
|
||||
editDialog.succeeded.emit(users[0])
|
||||
fixture.detectChanges()
|
||||
jest.advanceTimersByTime(2600)
|
||||
expect(navSpy).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
|
||||
)
|
||||
base.remove()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should support edit / create group, show error if needed', () => {
|
||||
completeSetup()
|
||||
let modal: NgbModalRef
|
||||
|
||||
@@ -116,8 +116,9 @@ export class UsersAndGroupsComponent
|
||||
$localize`Password has been changed, you will be logged out momentarily.`
|
||||
)
|
||||
setTimeout(() => {
|
||||
const basePath = new URL(document.baseURI).pathname
|
||||
setLocationHref(
|
||||
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
|
||||
)
|
||||
}, 2500)
|
||||
} else {
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
}
|
||||
|
||||
.search-container {
|
||||
min-width: 0;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
@@ -499,6 +500,11 @@ main {
|
||||
|
||||
// true center the search with equal flex widths
|
||||
@media (min-width: 768px) {
|
||||
.search-container {
|
||||
// Prevent input padding and the conditional search button from resizing
|
||||
flex-basis: 55rem;
|
||||
}
|
||||
|
||||
.navbar-brand-container,
|
||||
.navbar > ul {
|
||||
display: flex; // so the brand link stays as wide as its contents
|
||||
|
||||
@@ -70,6 +70,17 @@ form {
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
.dropdown.show {
|
||||
// Hide the search icon when the dropdown is shown
|
||||
i-bs[name="search"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
&:has(button:focus) {
|
||||
background-color: var(--pngx-bg-darker);
|
||||
|
||||
+32
@@ -218,6 +218,38 @@ describe('ProfileEditDialogComponent', () => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should logout under base href on save if password changed', () => {
|
||||
jest.useFakeTimers()
|
||||
const base = document.createElement('base')
|
||||
base.href = '/paperless/'
|
||||
document.head.appendChild(base)
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockReturnValue(of(profile))
|
||||
const getProvidersSpy = jest.spyOn(
|
||||
profileService,
|
||||
'getSocialAccountProviders'
|
||||
)
|
||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||
component.ngOnInit()
|
||||
component['newPassword'] = 'new*pass'
|
||||
component.form.get('password').patchValue('new*pass')
|
||||
component.form.get('password_confirm').patchValue('new*pass')
|
||||
|
||||
const updateSpy = jest.spyOn(profileService, 'update')
|
||||
updateSpy.mockReturnValue(of(null))
|
||||
const navSpy = jest
|
||||
.spyOn(navUtils, 'setLocationHref')
|
||||
.mockImplementation(() => {})
|
||||
component.save()
|
||||
expect(updateSpy).toHaveBeenCalled()
|
||||
jest.advanceTimersByTime(2600)
|
||||
expect(navSpy).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/paperless/accounts/logout/?next=/paperless/accounts/login/?next=/paperless/`
|
||||
)
|
||||
base.remove()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should support auth token copy', () => {
|
||||
jest.useFakeTimers()
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
|
||||
+2
-1
@@ -210,8 +210,9 @@ export class ProfileEditDialogComponent
|
||||
$localize`Password has been changed, you will be logged out momentarily.`
|
||||
)
|
||||
setTimeout(() => {
|
||||
const basePath = new URL(document.baseURI).pathname
|
||||
setLocationHref(
|
||||
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||
`${window.location.origin}${basePath}accounts/logout/?next=${basePath}accounts/login/?next=${basePath}`
|
||||
)
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
+33
-35
@@ -1,6 +1,6 @@
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading()" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||
@if (loading()) {
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
} @else if (noSuggestions) {
|
||||
@@ -18,46 +18,44 @@
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (aiEnabled()) {
|
||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||
</button>
|
||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="disabled() || loading() || !suggestions()" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
||||
</button>
|
||||
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (novelSuggestions === 0 && fieldSuggestions === 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
}
|
||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||
@for (tag of suggestions().suggested_tags; track tag) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||
}
|
||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||
@for (tag of suggestions().suggested_tags; track tag) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||
@for (type of suggestions().suggested_document_types; track type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||
}
|
||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||
@for (type of suggestions().suggested_document_types; track type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||
}
|
||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||
}
|
||||
}
|
||||
@if (fieldSuggestions > 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (fieldSuggestions > 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>{fieldSuggestions, plural, =1 {1 suggestion available below} other {{{fieldSuggestions}} suggestions available below}}</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (aiEnabled()) {
|
||||
|
||||
+31
@@ -251,6 +251,37 @@ describe('SuggestionsDropdownComponent', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||
})
|
||||
|
||||
it('should open ML date suggestions with AI disabled', () => {
|
||||
const getSuggestions = jest.spyOn(component.getSuggestions, 'emit')
|
||||
fixture.componentRef.setInput('aiEnabled', false)
|
||||
fixture.componentRef.setInput('fetchedSources', [SuggestionSource.ML])
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
dates: ['2026-01-04', '2026-02-01'],
|
||||
})
|
||||
fixture.componentRef.setInput('appliedCreated', '2026-01-04')
|
||||
fixture.detectChanges()
|
||||
|
||||
const button = fixture.nativeElement.querySelector('button')
|
||||
expect(button.disabled).toBeFalsy()
|
||||
expect(button.querySelector('.badge').textContent.trim()).toBe('1')
|
||||
|
||||
button.click()
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'1 suggestion available below'
|
||||
)
|
||||
expect(getSuggestions).not.toHaveBeenCalled()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('button[title="Suggestion options"]')
|
||||
).toBeNull()
|
||||
|
||||
button.click()
|
||||
fixture.detectChanges()
|
||||
expect(component.dropdown.isOpen()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should fetch unfetched sources and show existing suggestions', () => {
|
||||
jest.spyOn(component.getSuggestions, 'emit')
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
|
||||
+1
-5
@@ -53,11 +53,7 @@ export class SuggestionsDropdownComponent {
|
||||
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
||||
|
||||
public clickSuggest(): void {
|
||||
if (
|
||||
this.disabled() ||
|
||||
this.loading() ||
|
||||
(this.suggestions() && !this.aiEnabled())
|
||||
) {
|
||||
if (this.disabled() || this.loading()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -25,6 +25,8 @@
|
||||
@if (displayFields().includes(field)) {
|
||||
<th
|
||||
scope="col"
|
||||
class="text-truncate"
|
||||
[title]="getColumnTitle(field)"
|
||||
[ngClass]="{
|
||||
'd-none d-md-table-cell': i > 1,
|
||||
'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
|
||||
|
||||
+46
-10
@@ -484,6 +484,22 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.textFilter).toBeNull()
|
||||
})
|
||||
|
||||
it('should ingest title filter rules alongside a fulltext query with date created', () => {
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'created:[-1 week to now]',
|
||||
},
|
||||
]
|
||||
expect(component.dateCreatedRelativeDate).toEqual(1) // RELATIVE_DATE_QUERYSTRINGS['-1 week to now']
|
||||
expect(component.textFilter).toEqual('foo')
|
||||
expect(component.textFilterTarget).toEqual('title') // TEXT_FILTER_TARGET_TITLE
|
||||
})
|
||||
|
||||
it('should ingest text filter rules for fulltext query that include date added', () => {
|
||||
expect(component.dateAddedRelativeDate).toBeNull()
|
||||
component.filterRules = [
|
||||
@@ -1845,7 +1861,7 @@ describe('FilterEditorComponent', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should carry over text filtering on date created with relative date', () => {
|
||||
it('should keep text filtering separate from date created relative date', () => {
|
||||
component.textFilter = 'foo'
|
||||
const dateCreatedDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(DatesDropdownComponent)
|
||||
@@ -1855,14 +1871,18 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TEXT,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'foo,created:[-1 week to now]',
|
||||
value: 'created:[-1 week to now]',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should carry over text filtering once with created and added relative dates', () => {
|
||||
it('should keep text filtering separate from created and added relative dates', () => {
|
||||
component.textFilter = 'foo'
|
||||
const datesDropdown = fixture.debugElement.query(
|
||||
By.directive(DatesDropdownComponent)
|
||||
@@ -1873,14 +1893,18 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TEXT,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'foo,created:[-1 week to now],added:[-1 month to now]',
|
||||
value: 'created:[-1 week to now],added:[-1 month to now]',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should convert legacy title filters into full text query when adding a created relative date', () => {
|
||||
it('should keep legacy title filters as a title search when adding a created relative date', () => {
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_TITLE,
|
||||
@@ -1895,14 +1919,18 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'foo,created:[-1 week to now]',
|
||||
value: 'created:[-1 week to now]',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should convert simple title filters into full text query when adding a created relative date', () => {
|
||||
it('should keep simple title filters as a title search when adding a created relative date', () => {
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
@@ -1917,9 +1945,13 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'foo,created:[-1 week to now]',
|
||||
value: 'created:[-1 week to now]',
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -2005,7 +2037,7 @@ describe('FilterEditorComponent', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should carry over text filtering on date added with relative date', () => {
|
||||
it('should keep text filtering separate from date added relative date', () => {
|
||||
component.textFilter = 'foo'
|
||||
const datesDropdown = fixture.debugElement.query(
|
||||
By.directive(DatesDropdownComponent)
|
||||
@@ -2015,9 +2047,13 @@ describe('FilterEditorComponent', () => {
|
||||
fixture.detectChanges()
|
||||
tick(400)
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_SIMPLE_TEXT,
|
||||
value: 'foo',
|
||||
},
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: 'foo,added:[-1 week to now]',
|
||||
value: 'added:[-1 week to now]',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1083,23 +1083,6 @@ export class FilterEditorComponent
|
||||
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
|
||||
)
|
||||
|
||||
// if had a title / content search and added a relative date we need to carry it over...
|
||||
if (
|
||||
!existingRule &&
|
||||
this._textFilter?.length > 0 &&
|
||||
(this.textFilterTarget == TEXT_FILTER_TARGET_TITLE_CONTENT ||
|
||||
this.textFilterTarget == TEXT_FILTER_TARGET_TITLE)
|
||||
) {
|
||||
existingRule = filterRules.find(
|
||||
(fr) =>
|
||||
fr.rule_type == FILTER_TITLE_CONTENT ||
|
||||
fr.rule_type == FILTER_SIMPLE_TEXT ||
|
||||
fr.rule_type == FILTER_TITLE ||
|
||||
fr.rule_type == FILTER_SIMPLE_TITLE
|
||||
)
|
||||
existingRule.rule_type = FILTER_FULLTEXT_QUERY
|
||||
}
|
||||
|
||||
let queryArgs = existingRule?.value.split(',') ?? []
|
||||
if (this.dateCreatedRelativeDate !== null) {
|
||||
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
|
||||
|
||||
+5
-3
@@ -11,7 +11,7 @@
|
||||
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
||||
<label class="input-group-text bg-transparent border-0" for="managementPageSize" i18n>Show:</label>
|
||||
</div>
|
||||
<div class="input-group input-group-sm w-auto me-3">
|
||||
<div class="input-group input-group-sm w-auto">
|
||||
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
@@ -19,7 +19,9 @@
|
||||
</select>
|
||||
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
|
||||
</div>
|
||||
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
@if (collectionSize() > pageSize) {
|
||||
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reloadData()" class="ms-3" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +72,7 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (collectionSize() > 20) {
|
||||
@if (collectionSize() > pageSize) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="collectionSize()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
|
||||
+11
-9
@@ -93,15 +93,17 @@
|
||||
[disabled]="selectedMailIds().size === 0"
|
||||
(confirm)="deleteSelected()">
|
||||
</pngx-confirm-button>
|
||||
<div class="ms-auto">
|
||||
<ngb-pagination
|
||||
[collectionSize]="processedMails().length"
|
||||
[(page)]="page"
|
||||
[pageSize]="50"
|
||||
[maxSize]="5"
|
||||
(pageChange)="loadProcessedMails()">
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
@if (processedMails().length > 50) {
|
||||
<div class="ms-auto">
|
||||
<ngb-pagination
|
||||
[collectionSize]="processedMails().length"
|
||||
[(page)]="page"
|
||||
[pageSize]="50"
|
||||
[maxSize]="5"
|
||||
(pageChange)="loadProcessedMails()">
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -8,7 +8,7 @@ export const environment = {
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: DEFAULT_APP_TITLE,
|
||||
tag: 'prod',
|
||||
version: '3.3.1',
|
||||
version: '3.3.3',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -41,6 +41,7 @@ import {
|
||||
bell,
|
||||
bodyText,
|
||||
bookmark,
|
||||
boxArrowInRight,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
boxes,
|
||||
@@ -293,6 +294,7 @@ const icons = {
|
||||
braces,
|
||||
bodyText,
|
||||
bookmark,
|
||||
boxArrowInRight,
|
||||
boxArrowUp,
|
||||
boxArrowUpRight,
|
||||
boxes,
|
||||
|
||||
@@ -11,8 +11,6 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from paperless_testing import samples
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
@@ -151,39 +149,3 @@ def fake_progress_manager(
|
||||
|
||||
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
|
||||
return FakeProgressManager
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def shared_samples_dir() -> Path:
|
||||
"""Directory of sample files used by more than one app's tests."""
|
||||
return samples.SHARED_SAMPLES_DIR
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def simple_digital_pdf_file() -> Path:
|
||||
"""One-page PDF with a text layer."""
|
||||
return samples.SIMPLE_DIGITAL_PDF
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def multi_page_digital_pdf_file() -> Path:
|
||||
"""Three-page PDF with a text layer."""
|
||||
return samples.MULTI_PAGE_DIGITAL_PDF
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def with_form_pdf_file() -> Path:
|
||||
"""PDF containing a fillable form."""
|
||||
return samples.WITH_FORM_PDF
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def multi_page_images_pdf_file() -> Path:
|
||||
"""Multi-page PDF of scanned images, no text layer."""
|
||||
return samples.MULTI_PAGE_IMAGES_PDF
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def thumbnail_webp_file() -> Path:
|
||||
"""Small WebP thumbnail."""
|
||||
return samples.THUMBNAIL_WEBP
|
||||
@@ -54,6 +54,10 @@ def _predict_with_threshold(classifier, X, threshold: float) -> int | None:
|
||||
cutoff so that uncertain predictions are discarded rather than assigned.
|
||||
"""
|
||||
probas = classifier.predict_proba(X)[0]
|
||||
if len(classifier.classes_) == 1:
|
||||
# scikit-learn treats a single-class fit as binary, so predict_proba
|
||||
# returns [1 - p, p] for one class. Column 0 is the real class.
|
||||
probas = probas[:1]
|
||||
best_idx = int(probas.argmax())
|
||||
best_class = int(classifier.classes_[best_idx])
|
||||
|
||||
|
||||
@@ -130,12 +130,14 @@ class DocumentMetadataOverrides:
|
||||
get_users_with_perms(
|
||||
doc,
|
||||
only_with_perms_in=["view_document"],
|
||||
with_group_users=False,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
overrides.change_users = list(
|
||||
get_users_with_perms(
|
||||
doc,
|
||||
only_with_perms_in=["change_document"],
|
||||
with_group_users=False,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
overrides.custom_fields = {
|
||||
|
||||
@@ -3,6 +3,7 @@ from itertools import islice
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
@@ -70,6 +71,15 @@ from paperless.models import ApplicationConfiguration
|
||||
from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
# Derived names replace the original's extension with these suffixes
|
||||
THUMBNAIL_SUFFIX: Final[str] = "-thumbnail.webp"
|
||||
ARCHIVE_SUFFIX: Final[str] = "-archive.pdf"
|
||||
# Leave room for the longest derived suffix, which replaces the original's extension
|
||||
MAX_BASE_NAME_BYTES: Final[int] = Document.MAX_PUBLIC_FILENAME_BYTES - max(
|
||||
len(THUMBNAIL_SUFFIX),
|
||||
len(ARCHIVE_SUFFIX),
|
||||
)
|
||||
|
||||
|
||||
def serialize_queryset_batched(
|
||||
queryset: "QuerySet[Any]",
|
||||
@@ -512,7 +522,10 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
counter=filename_counter,
|
||||
)
|
||||
else:
|
||||
base_name = document.get_public_filename(counter=filename_counter)
|
||||
base_name = document.get_public_filename(
|
||||
counter=filename_counter,
|
||||
max_bytes=MAX_BASE_NAME_BYTES,
|
||||
)
|
||||
|
||||
# The thumbnail and archive names are derived from the stem, so
|
||||
# uniqueness must hold without the original's extension
|
||||
@@ -541,7 +554,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
document_dict[EXPORTER_FILE_NAME] = original_arc
|
||||
|
||||
if not self.no_thumbnail:
|
||||
thumbnail_name = base_name.parent / (base_name.stem + "-thumbnail.webp")
|
||||
thumbnail_name = base_name.parent / (base_name.stem + THUMBNAIL_SUFFIX)
|
||||
if self.use_folder_prefix:
|
||||
thumbnail_name = Path("thumbnails") / thumbnail_name
|
||||
thumbnail_arc = thumbnail_name.as_posix()
|
||||
@@ -550,7 +563,7 @@ class Command(CryptMixin, PaperlessCommand):
|
||||
thumbnail_arc = None
|
||||
|
||||
if not self.no_archive and document.has_archive_version:
|
||||
archive_name = base_name.parent / (base_name.stem + "-archive.pdf")
|
||||
archive_name = base_name.parent / (base_name.stem + ARCHIVE_SUFFIX)
|
||||
if self.use_folder_prefix:
|
||||
archive_name = Path("archive") / archive_name
|
||||
archive_arc = archive_name.as_posix()
|
||||
|
||||
@@ -66,7 +66,7 @@ def iter_manifest_records(path: Path) -> Generator[dict, None, None]:
|
||||
"""Yield records one at a time from a manifest JSON array via ijson."""
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
yield from ijson.items(f, "item")
|
||||
yield from ijson.items(f, "item", use_float=True)
|
||||
except ijson.JSONError as e:
|
||||
raise CommandError(f"Failed to parse manifest file {path}: {e}") from e
|
||||
|
||||
|
||||
+23
-12
@@ -157,6 +157,8 @@ class StoragePath(MatchingModel):
|
||||
|
||||
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
|
||||
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
|
||||
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
|
||||
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
|
||||
|
||||
correspondent = models.ForeignKey(
|
||||
Correspondent,
|
||||
@@ -484,28 +486,37 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
def archive_file(self):
|
||||
return Path(self.archive_path).open("rb")
|
||||
|
||||
def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
|
||||
def get_public_filename(
|
||||
self,
|
||||
*,
|
||||
archive: bool = False,
|
||||
counter: int = 0,
|
||||
max_bytes: int = MAX_PUBLIC_FILENAME_BYTES,
|
||||
) -> str:
|
||||
"""
|
||||
Returns a sanitized filename for the document, not including any paths.
|
||||
|
||||
The name fits in max_bytes (UTF-8). A long title is shortened before
|
||||
the counter and extension are appended, because sanitize_filename
|
||||
truncates from the end. The tail is cut only when max_bytes is
|
||||
smaller than the tail itself.
|
||||
"""
|
||||
# Root owns metadata for all versions
|
||||
context_document = (
|
||||
self.root_document if self.root_document_id is not None else self
|
||||
)
|
||||
result = str(context_document)
|
||||
|
||||
if counter:
|
||||
result += f"_{counter:02}"
|
||||
extension = ".pdf" if archive else self.file_type
|
||||
tail = f"_{counter:02}{extension}" if counter else extension
|
||||
|
||||
if suffix:
|
||||
result += suffix
|
||||
head_bytes = max(0, max_bytes - len(tail.encode()))
|
||||
head = str(context_document).encode()[:head_bytes].decode(errors="ignore")
|
||||
|
||||
if archive:
|
||||
result += ".pdf"
|
||||
else:
|
||||
result += self.file_type
|
||||
|
||||
return pathvalidate.sanitize_filename(result, replacement_text="-")
|
||||
return pathvalidate.sanitize_filename(
|
||||
head + tail,
|
||||
replacement_text="-",
|
||||
max_len=max_bytes,
|
||||
)
|
||||
|
||||
@property
|
||||
def file_type(self):
|
||||
|
||||
@@ -511,6 +511,28 @@ def restrict_queryset_to_visible(
|
||||
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
||||
|
||||
|
||||
def user_is_active_superuser(user: User | None) -> bool:
|
||||
"""
|
||||
True only for an active, authenticated superuser.
|
||||
"""
|
||||
return user is not None and user_is_unrestricted(user)
|
||||
|
||||
|
||||
def restrict_queryset_to_owner_visible(
|
||||
queryset: QuerySet[ModelT],
|
||||
user: User | None,
|
||||
perm: str,
|
||||
) -> QuerySet[ModelT]:
|
||||
"""
|
||||
Like ``restrict_queryset_to_visible``, but ``user=None`` sees only unowned
|
||||
rows (``permitted_object_ids(None, ...)`` semantics) instead of every row.
|
||||
See ``user_is_active_superuser``.
|
||||
"""
|
||||
if user_is_active_superuser(user):
|
||||
return queryset
|
||||
return queryset.filter(pk__in=permitted_object_ids(user, queryset.model, perm))
|
||||
|
||||
|
||||
def permitted_document_ids(
|
||||
user: User | None,
|
||||
*,
|
||||
|
||||
@@ -926,6 +926,7 @@ class TantivyBackend:
|
||||
sort_field: str | None = None,
|
||||
sort_reverse: bool = False,
|
||||
search_mode: SearchMode = SearchMode.QUERY,
|
||||
filter_query: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[int]:
|
||||
"""
|
||||
@@ -941,6 +942,7 @@ class TantivyBackend:
|
||||
sort_field: Field to sort by (None for relevance ranking)
|
||||
sort_reverse: Whether to reverse the sort order
|
||||
search_mode: Query parsing mode (QUERY, TEXT, or TITLE)
|
||||
filter_query: Optional advanced query results must also match
|
||||
limit: Maximum number of IDs to return (None = all matching docs)
|
||||
|
||||
Returns:
|
||||
@@ -948,6 +950,13 @@ class TantivyBackend:
|
||||
"""
|
||||
self._ensure_open()
|
||||
user_query = self._parse_query(query, search_mode)
|
||||
if filter_query is not None:
|
||||
user_query = user_query.and_must_match(
|
||||
tantivy.Query.const_score_query(
|
||||
self._parse_query(filter_query, SearchMode.QUERY),
|
||||
0.0, # zero so doesn't affect relevance
|
||||
),
|
||||
)
|
||||
final_query = self._apply_permission_filter(user_query, user)
|
||||
|
||||
searcher = self._index.searcher()
|
||||
|
||||
@@ -12,25 +12,29 @@ if TYPE_CHECKING:
|
||||
from paperless_testing.dirs import PaperlessDirs
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def document_samples_dir() -> Path:
|
||||
"""Path to the shared test sample documents."""
|
||||
return Path(__file__).parent / "samples" / "documents"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_doc(
|
||||
paperless_dirs: "PaperlessDirs",
|
||||
simple_digital_pdf_file: Path,
|
||||
multi_page_images_pdf_file: Path,
|
||||
thumbnail_webp_file: Path,
|
||||
document_samples_dir: Path,
|
||||
) -> "Document":
|
||||
"""Create a document with valid files and matching checksums."""
|
||||
with filelock.FileLock(paperless_dirs.media_lock):
|
||||
shutil.copy(
|
||||
simple_digital_pdf_file,
|
||||
document_samples_dir / "originals" / "0000001.pdf",
|
||||
paperless_dirs.originals_dir / "0000001.pdf",
|
||||
)
|
||||
shutil.copy(
|
||||
multi_page_images_pdf_file,
|
||||
document_samples_dir / "archive" / "0000001.pdf",
|
||||
paperless_dirs.archive_dir / "0000001.pdf",
|
||||
)
|
||||
shutil.copy(
|
||||
thumbnail_webp_file,
|
||||
document_samples_dir / "thumbnails" / "0000001.webp",
|
||||
paperless_dirs.thumbnail_dir / "0000001.webp",
|
||||
)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This is a test file.
|
||||
@@ -53,7 +53,7 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
archive_checksum="D",
|
||||
)
|
||||
|
||||
shutil.copy(self.SIMPLE_PDF, self.doc2.source_path)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc2.source_path)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.png", self.doc2b.source_path)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.jpg", self.doc3.source_path)
|
||||
shutil.copy(self.SAMPLE_DIR / "test_with_bom.pdf", self.doc3.archive_path)
|
||||
|
||||
@@ -835,6 +835,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
for filters in (
|
||||
{"text": "new doc 2017-03-16"},
|
||||
{"title_search": "apple"},
|
||||
{"title_search": "apple", "query": "created:[-1 year to now]"},
|
||||
):
|
||||
with self.subTest(filters=filters):
|
||||
get_backend.return_value.search_ids.return_value = [self.doc2.id]
|
||||
@@ -886,7 +887,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
"all": True,
|
||||
"filters": {
|
||||
"text": "B",
|
||||
"query": "c1",
|
||||
"title_search": "c1",
|
||||
},
|
||||
"method": "set_storage_path",
|
||||
"parameters": {"storage_path": self.sp1.id},
|
||||
|
||||
@@ -56,8 +56,6 @@ from paperless_testing.http import read_streaming_response
|
||||
from paperless_testing.permissions import grant_all_global
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.permissions import grant_object
|
||||
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
|
||||
from paperless_testing.samples import THUMBNAIL_WEBP
|
||||
|
||||
|
||||
class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
@@ -1854,11 +1852,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with SimpleUploadedFile(
|
||||
"simple.pdf",
|
||||
SIMPLE_DIGITAL_PDF.read_bytes(),
|
||||
content_type="application/pdf",
|
||||
) as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f},
|
||||
@@ -1886,7 +1880,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
|
||||
payload = SimpleUploadedFile(
|
||||
"../../outside.pdf",
|
||||
SIMPLE_DIGITAL_PDF.read_bytes(),
|
||||
(Path(__file__).parent / "samples" / "simple.pdf").read_bytes(),
|
||||
content_type="application/pdf",
|
||||
)
|
||||
|
||||
@@ -1915,7 +1909,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
pdf_bytes = SIMPLE_DIGITAL_PDF.read_bytes()
|
||||
pdf_bytes = (Path(__file__).parent / "samples" / "simple.pdf").read_bytes()
|
||||
boundary = "paperless-boundary"
|
||||
payload = (
|
||||
(
|
||||
@@ -1991,7 +1985,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
def test_upload_insufficient_permissions(self) -> None:
|
||||
self.client.force_authenticate(user=UserFactory(username="testuser2"))
|
||||
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f},
|
||||
@@ -2004,11 +1998,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with SimpleUploadedFile(
|
||||
"simple.pdf",
|
||||
SIMPLE_DIGITAL_PDF.read_bytes(),
|
||||
content_type="application/pdf",
|
||||
) as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{
|
||||
@@ -2041,7 +2031,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"documenst": f},
|
||||
@@ -2067,7 +2057,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "title": "my custom title"},
|
||||
@@ -2087,7 +2077,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
)
|
||||
|
||||
c = Correspondent.objects.create(name="test-corres")
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "correspondent": c.id},
|
||||
@@ -2106,7 +2096,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "correspondent": 3456},
|
||||
@@ -2121,7 +2111,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
)
|
||||
|
||||
dt = DocumentType.objects.create(name="invoice")
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "document_type": dt.id},
|
||||
@@ -2140,7 +2130,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "document_type": 34578},
|
||||
@@ -2155,7 +2145,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
)
|
||||
|
||||
sp = StoragePath.objects.create(name="invoices")
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "storage_path": sp.id},
|
||||
@@ -2174,7 +2164,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "storage_path": 34578},
|
||||
@@ -2190,7 +2180,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
|
||||
t1 = Tag.objects.create(name="tag1")
|
||||
t2 = Tag.objects.create(name="tag2")
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "tags": [t2.id, t1.id]},
|
||||
@@ -2211,7 +2201,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
|
||||
t1 = Tag.objects.create(name="tag1")
|
||||
t2 = Tag.objects.create(name="tag2")
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "tags": [t2.id, t1.id, 734563]},
|
||||
@@ -2235,7 +2225,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
0,
|
||||
tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles"),
|
||||
)
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "created": created},
|
||||
@@ -2251,11 +2241,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with SimpleUploadedFile(
|
||||
"simple.pdf",
|
||||
SIMPLE_DIGITAL_PDF.read_bytes(),
|
||||
content_type="application/pdf",
|
||||
) as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "archive_serial_number": 500},
|
||||
@@ -2282,11 +2268,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
|
||||
with SimpleUploadedFile(
|
||||
"simple.pdf",
|
||||
SIMPLE_DIGITAL_PDF.read_bytes(),
|
||||
content_type="application/pdf",
|
||||
) as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{
|
||||
@@ -2341,7 +2323,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
w1.actions.add(action1)
|
||||
w1.save()
|
||||
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{
|
||||
@@ -2382,11 +2364,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
data_type=CustomField.FieldDataType.INT,
|
||||
)
|
||||
|
||||
with SimpleUploadedFile(
|
||||
"simple.pdf",
|
||||
SIMPLE_DIGITAL_PDF.read_bytes(),
|
||||
content_type="application/pdf",
|
||||
) as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{
|
||||
@@ -2435,7 +2413,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
]
|
||||
|
||||
for payload in error_payloads:
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
data = {"document": f, **payload}
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
@@ -2493,7 +2471,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
with SIMPLE_DIGITAL_PDF.open("rb") as f:
|
||||
with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": f, "from_webui": True},
|
||||
@@ -2532,8 +2510,14 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
archive_filename="archive.pdf",
|
||||
)
|
||||
|
||||
source_file: Path = THUMBNAIL_WEBP
|
||||
archive_file: Path = SIMPLE_DIGITAL_PDF
|
||||
source_file: Path = (
|
||||
Path(__file__).parent
|
||||
/ "samples"
|
||||
/ "documents"
|
||||
/ "thumbnails"
|
||||
/ "0000001.webp"
|
||||
)
|
||||
archive_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
|
||||
|
||||
shutil.copy(source_file, doc.source_path)
|
||||
shutil.copy(archive_file, doc.archive_path)
|
||||
@@ -2566,7 +2550,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
shutil.copy(SIMPLE_DIGITAL_PDF, doc.source_path)
|
||||
shutil.copy(Path(__file__).parent / "samples" / "simple.pdf", doc.source_path)
|
||||
|
||||
response = self.client.get(f"/api/documents/{doc.pk}/metadata/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
@@ -4304,8 +4288,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
filename="test2.pdf",
|
||||
)
|
||||
|
||||
archive_file = SIMPLE_DIGITAL_PDF
|
||||
source_file = SIMPLE_DIGITAL_PDF
|
||||
archive_file = Path(__file__).parent / "samples" / "simple.pdf"
|
||||
source_file = Path(__file__).parent / "samples" / "simple.pdf"
|
||||
|
||||
shutil.copy(archive_file, doc.archive_path)
|
||||
shutil.copy(source_file, doc2.source_path)
|
||||
|
||||
@@ -12,9 +12,6 @@ from documents.tests.utils import SampleDirMixin
|
||||
from paperless_testing.dirs import DirectoriesMixin
|
||||
from paperless_testing.factories import UserFactory
|
||||
from paperless_testing.permissions import grant_global
|
||||
from paperless_testing.samples import MULTI_PAGE_DIGITAL_PDF
|
||||
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
|
||||
from paperless_testing.samples import WITH_FORM_PDF
|
||||
|
||||
|
||||
class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
@@ -45,15 +42,15 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
|
||||
# Copy sample files to document paths (using different files to distinguish versions)
|
||||
shutil.copy(
|
||||
SIMPLE_DIGITAL_PDF,
|
||||
self.SAMPLE_DIR / "documents" / "originals" / "0000001.pdf",
|
||||
self.doc1.archive_path,
|
||||
)
|
||||
shutil.copy(
|
||||
MULTI_PAGE_DIGITAL_PDF,
|
||||
self.SAMPLE_DIR / "documents" / "originals" / "0000002.pdf",
|
||||
self.doc1.source_path,
|
||||
)
|
||||
shutil.copy(
|
||||
WITH_FORM_PDF,
|
||||
self.SAMPLE_DIR / "documents" / "originals" / "0000003.pdf",
|
||||
self.doc2.source_path,
|
||||
)
|
||||
|
||||
@@ -380,7 +377,7 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
checksum="3",
|
||||
filename="test3.pdf",
|
||||
)
|
||||
shutil.copy(self.SIMPLE_PDF, doc3.source_path)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.pdf", doc3.source_path)
|
||||
|
||||
doc4 = Document.objects.create(
|
||||
title="test1",
|
||||
@@ -389,7 +386,7 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||
checksum="4",
|
||||
filename="test4.pdf",
|
||||
)
|
||||
shutil.copy(self.SIMPLE_PDF, doc4.source_path)
|
||||
shutil.copy(self.SAMPLE_DIR / "simple.pdf", doc4.source_path)
|
||||
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
|
||||
@@ -245,8 +245,68 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertEqual(response.data["results"][0]["id"], title_match.id)
|
||||
|
||||
def test_simple_search_combined_with_query_filter(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document with the term in its title, created this year
|
||||
- A document with the term only in its content, created this year
|
||||
- A document with the term in its title, created years ago
|
||||
WHEN:
|
||||
- A title or text search is combined with a relative created
|
||||
date query, as the filter editor sends it
|
||||
THEN:
|
||||
- The simple search keeps its own field scope and the query only
|
||||
narrows the results (issue #14436)
|
||||
"""
|
||||
this_year = timezone.localdate().replace(month=1, day=1)
|
||||
title_match = Document.objects.create(
|
||||
title="foo report",
|
||||
content="nothing relevant",
|
||||
checksum="F1",
|
||||
created=this_year,
|
||||
pk=21,
|
||||
)
|
||||
content_match = Document.objects.create(
|
||||
title="something else",
|
||||
content="foo appears here",
|
||||
checksum="F2",
|
||||
created=this_year,
|
||||
pk=22,
|
||||
)
|
||||
old_title_match = Document.objects.create(
|
||||
title="foo archive",
|
||||
content="nothing relevant",
|
||||
checksum="F3",
|
||||
created=datetime.date(2001, 1, 1),
|
||||
pk=23,
|
||||
)
|
||||
backend = get_backend()
|
||||
for doc in (title_match, content_match, old_title_match):
|
||||
backend.add_or_update(doc)
|
||||
|
||||
query = "created:[-1 year to now]"
|
||||
response = self.client.get(
|
||||
"/api/documents/",
|
||||
{"title_search": "foo", "query": query},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
[r["id"] for r in response.data["results"]],
|
||||
[title_match.id],
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
"/api/documents/",
|
||||
{"text": "foo", "query": query},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertCountEqual(
|
||||
[r["id"] for r in response.data["results"]],
|
||||
[title_match.id, content_match.id],
|
||||
)
|
||||
|
||||
def test_search_rejects_multiple_search_modes(self) -> None:
|
||||
response = self.client.get("/api/documents/?text=bank&query=bank")
|
||||
response = self.client.get("/api/documents/?text=bank&title_search=bank")
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.data["detail"],
|
||||
@@ -2077,29 +2137,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(len(response.data["documents"]), 1)
|
||||
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
|
||||
|
||||
def test_global_search_returns_latest_version_content(self) -> None:
|
||||
root = Document.objects.create(
|
||||
title="bank statement",
|
||||
content="superseded content",
|
||||
checksum="GSV1",
|
||||
pk=23,
|
||||
)
|
||||
Document.objects.create(
|
||||
title="bank statement v2",
|
||||
content="latest content",
|
||||
checksum="GSV2",
|
||||
pk=24,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
response = self.client.get("/api/search/?query=bank&db_only=true")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
|
||||
self.assertEqual(returned.get(root.id), "latest content")
|
||||
|
||||
def test_global_search_filters_owned_mail_objects(self) -> None:
|
||||
user1 = UserFactory(username="mail-search-user")
|
||||
user2 = UserFactory(username="other-mail-search-user")
|
||||
|
||||
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user