Compare commits

..
Author SHA1 Message Date
shamoon 31caf01ae5 Bump version to 3.3.3 2026-10-19 10:06:39 -07:00
shamoon e756f5985f Merge branch 'dev' 2026-10-19 10:05:59 -07:00
github-actions[bot]andCrowdin Bot ce1dee7549 New Crowdin translations by GitHub Action (#14474)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-19 16:58:46 +00:00
shamoon fd187cd09a Fix: fix global search input layout shifting (#14491) 2026-10-19 09:39:26 -07:00
GitHub Actions 814b208280 Auto translate strings 2026-10-19 15:41:29 +00:00
shamoon 11c32d352e Fix: dont include doc content on global search results (#14488) 2026-10-19 08:40:05 -07:00
GitHub Actions c21deaa452 Auto translate strings 2026-10-19 06:20:40 +00:00
shamoon c47766121d Fix: remove some redundant pagers (#14487) 2026-10-18 23:19:24 -07:00
GitHub Actions 979bcd3896 Auto translate strings 2026-10-19 06:05:02 +00:00
shamoon d450f03d5f Fix: prevent weird wrapping of dashboard widget column titles (#14486) 2026-10-18 23:03:43 -07:00
GitHub Actions 730c2d8688 Auto translate strings 2026-10-19 03:23:47 +00:00
shamoon b1738c85d7 Fix: dont paginate 'all' tasks view across sections (#14484) 2026-10-18 20:22:18 -07:00
Trenton H a8b835fcd0 Fix: use float parsing for importer manifest records with a passphrase (#14483) 2026-10-18 15:16:13 -07:00
GitHub Actions 41498113d3 Auto translate strings 2026-10-18 14:29:34 +00:00
309a4b004d Fix: respect subpath in logout, share link and auto-login redirects (#14476)
---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-10-18 14:28:15 +00:00
GitHub Actions 961f7fd672 Auto translate strings 2026-10-18 00:30:41 +00:00
shamoon de94657e17 Fix: dont tie suggestions button to ai enabled any more (#14478) 2026-10-17 17:29:29 -07:00
Trenton H 0cb2cdbfdd Fix: handle single-class classifier in threshold prediction (#14471) (#14473)
scikit-learn treats a single-class fit as binary, so predict_proba returns
[1 - p, p] for one class and argmax could index past classes_. Use only
column 0, the real class, so the confidence threshold still applies.
2026-10-16 10:47:58 -07:00
GitHub Actions 1f3d451a3a Auto translate strings 2026-10-16 17:12:50 +00:00
Trenton H 183cfd2da4 Fix: keep counter and extension when shortening long public filenames (#14472)
get_public_filename relied on sanitize_filename to truncate, which cuts
from the end and so dropped the counter and extension of long titles.
Multi-byte titles could also exceed the 255 byte filesystem limit.

Shorten the title by UTF-8 bytes before appending the counter and
extension. The exporter reserves room for its longer derived thumbnail
and archive suffixes.
2026-10-16 10:11:34 -07:00
github-actions[bot] 15e9a7a017 Changelog v3.3.2 - GHA (#14468)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-15 23:21:41 -07:00
shamoon 04a927c25f Bump version to 3.3.2 2026-10-15 22:52:43 -07:00
shamoon 30a24ee02e Chore: disable deprecated ts-jest isolation override 2026-10-15 22:51:55 -07:00
github-actions[bot]andCrowdin Bot 1943c99b27 New Crowdin translations by GitHub Action (#14449)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-10-15 22:51:00 -07:00
shamoon 8b26ecafb6 Fix: prevent ownerless docs leaking others in to LLM prompts 2026-10-15 22:42:34 -07:00
Trenton H d4a57204eb Fix: copy only direct user grants in DocumentMetadataOverrides.from_document (#14452)
get_users_with_perms includes group members by default, so PDF actions such
as merge and split gave them direct grants on the new documents, which
outlived their group membership.
2026-10-15 15:11:28 -07:00
dependabot[bot] c50c6f5517 Chore(deps): Bump filelock from 3.32.6 to 4.0.12 (#14336)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.32.6 to 4.0.12.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.32.6...4.0.12)

---
updated-dependencies:
- dependency-name: filelock
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 16:11:12 -07:00
GitHub Actions 34a6c718fa Auto translate strings 2026-10-14 15:18:54 +00:00
shamoon 14aaa42777 Fix: support combined text/query searches (#14443) 2026-10-14 08:17:22 -07:00
shamoon c86c822bc7 Fix ai auto-close message 2026-10-13 12:15:28 -07:00
dependabot[bot] b23b118a49 Chore(deps): Bump the uv group across 1 directory with 3 updates (#14418)
Bumps the uv group with 3 updates in the / directory: [banks](https://github.com/masci/banks), [fsspec](https://github.com/fsspec/filesystem_spec) and [multidict](https://github.com/aio-libs/multidict).


Updates `banks` from 2.4.5 to 2.5.1
- [Release notes](https://github.com/masci/banks/releases)
- [Commits](https://github.com/masci/banks/compare/v2.4.5...v2.5.1)

Updates `fsspec` from 2026.1.0 to 2026.6.0
- [Commits](https://github.com/fsspec/filesystem_spec/compare/2026.1.0...2026.6.0)

Updates `multidict` from 6.7.1 to 6.9.1
- [Release notes](https://github.com/aio-libs/multidict/releases)
- [Changelog](https://github.com/aio-libs/multidict/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/multidict/compare/v6.7.1...v6.9.1)

---
updated-dependencies:
- dependency-name: banks
  dependency-version: 2.5.1
  dependency-type: indirect
- dependency-name: fsspec
  dependency-version: 2026.6.0
  dependency-type: indirect
- dependency-name: multidict
  dependency-version: 6.9.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-13 17:25:40 +00:00
186 changed files with 30079 additions and 28674 deletions

No files matched your search

+5 -4
View File
@@ -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' +
+26
View File
@@ -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
View File
@@ -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
+1 -3
View File
@@ -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(
+2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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">&bull;</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);
@@ -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')
@@ -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)
}
@@ -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()) {
@@ -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)
@@ -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
}
@@ -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
@@ -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(
@@ -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>
@@ -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>
+1 -1
View File
@@ -8,7 +8,7 @@ export const environment = {
apiVersion: '10', // match src/paperless/settings.py
appTitle: DEFAULT_APP_TITLE,
tag: 'prod',
version: '3.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
+2
View File
@@ -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,
-38
View File
@@ -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
+4
View File
@@ -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])
+2
View File
@@ -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
View File
@@ -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):
+22
View File
@@ -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,
*,
+9
View File
@@ -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()
+10 -6
View File
@@ -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.

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.
+1
View File
@@ -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)
+2 -1
View File
@@ -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},
+33 -49
View File
@@ -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)
+5 -8
View File
@@ -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,
+61 -24
View File
@@ -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