Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d25400a23 | ||
|
|
279e075f58 | ||
|
|
2654dc46cf | ||
|
|
1f6a4ce402 | ||
|
|
8152c50288 | ||
|
|
ef8b87571f | ||
|
|
ce1dffbbd5 | ||
|
|
26b288f61a | ||
|
|
abd08e8000 | ||
|
|
649923d639 | ||
|
|
c869deed0e | ||
|
|
007316edf7 | ||
|
|
ade66f4228 | ||
|
|
4feeaea460 | ||
|
|
e13fb69913 | ||
|
|
80be118e05 | ||
|
|
5f4ac876e4 |
@@ -72,9 +72,11 @@ jobs:
|
||||
'You are welcome to open a new issue that describes the problem you observed in your own words.'
|
||||
: 'This issue was automatically closed because it was not opened using our bug report form. ' +
|
||||
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
|
||||
`If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` +
|
||||
`If the problem is still there, please [open a new issue](${newIssue}) using the form — that is all it takes ` +
|
||||
'to get it looked at, and no other action is needed here.\n\n' +
|
||||
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
|
||||
`contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
|
||||
`contributions are a violation of our [Code of Conduct](${codeOfConduct}), and such reports must describe the ` +
|
||||
`behavior you observed only, without code analysis or suggested fixes. See our [contributing guidelines](${contributing}).`;
|
||||
|
||||
await github.rest.issues.createComment({ ...common, body });
|
||||
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
|
||||
|
||||
@@ -25,10 +25,6 @@ jobs:
|
||||
pr-bot:
|
||||
name: Automated PR Bot
|
||||
runs-on: ubuntu-latest
|
||||
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
|
||||
# instead of racing it. Still runs if that job fails, so labeling is not lost.
|
||||
needs: Anti-slop
|
||||
if: ${{ !cancelled() }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@@ -103,25 +99,8 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const user = context.payload.pull_request.user.login;
|
||||
|
||||
// Re-read the PR: Anti-slop may have closed and labeled it after the webhook
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
});
|
||||
|
||||
if (pr.state === 'closed') {
|
||||
core.info('Skipping comment: PR is already closed');
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name));
|
||||
if (labels.includes('ai')) {
|
||||
core.info('Skipping comment: PR is labeled ai');
|
||||
return;
|
||||
}
|
||||
const pr = context.payload.pull_request;
|
||||
const user = pr.user.login;
|
||||
|
||||
const { data: members } = await github.rest.orgs.listMembers({
|
||||
org: 'paperless-ngx',
|
||||
|
||||
@@ -138,9 +138,7 @@ for suggested generation and embedding models.
|
||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
||||
and surfaces through the "Suggest" control on the document detail page, alongside the
|
||||
classic classifier-based suggestions — it does not disable them. Suggestions are requested
|
||||
automatically when you open a document that carries an inbox tag unless "Automatically request
|
||||
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
|
||||
classic classifier-based suggestions — it does not disable them. Suggestion output
|
||||
language can be steered with
|
||||
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
||||
(otherwise it follows the user's UI language).
|
||||
|
||||
@@ -317,8 +317,6 @@ a "document already exists" message.
|
||||
|
||||
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
||||
|
||||
Suggestions are requested automatically when you open a document that still has an inbox tag. To only request them by pressing the "Suggest" button instead, turn off "Automatically request suggestions for inbox documents" under Settings > Documents.
|
||||
|
||||
## AI Features
|
||||
|
||||
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
||||
@@ -686,8 +684,7 @@ It requires [AI features](configuration.md#ai) to be enabled. You can specify:
|
||||
never replace the document's existing tags.
|
||||
|
||||
The action works with every trigger **except Consumption Started**, because suggestions are made from
|
||||
the document's text, which does not exist until after the document has been processed. Documents whose
|
||||
processed text is empty or contains only whitespace are skipped.
|
||||
the document's text, which does not exist until after the document has been processed.
|
||||
|
||||
Because the query to the AI service is slow, the action is queued and runs in the background rather
|
||||
than as part of the workflow run itself. The document is updated once the suggestions come back.
|
||||
|
||||
@@ -32,21 +32,21 @@ dependencies = [
|
||||
"django-cors-headers~=4.9.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian>=3.3.3,<3.5",
|
||||
"django-guardian~=3.3.3",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-rich~=2.2.0",
|
||||
"django-soft-delete~=1.0.18",
|
||||
"django-treenode>=0.24",
|
||||
"djangorestframework~=3.16",
|
||||
"drf-spectacular~=0.30",
|
||||
"drf-spectacular-sidecar>=2026.7.1,<2026.9",
|
||||
"drf-spectacular-sidecar~=2026.7.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.32.0",
|
||||
"flower>=2.0.1,<2.2",
|
||||
"gotenberg-client[httpx]~=1.0",
|
||||
"httpx-oauth~=0.17",
|
||||
"ijson>=3.5.1",
|
||||
"imap-tools>=1.14,<1.16",
|
||||
"imap-tools~=1.14.0",
|
||||
"jinja2~=3.1.6",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.14.23",
|
||||
@@ -56,7 +56,7 @@ dependencies = [
|
||||
"llama-index-llms-ollama>=0.9.1",
|
||||
"llama-index-llms-openai-like>=0.7.1",
|
||||
"nltk~=3.10.0",
|
||||
"ocrmypdf>=17.7,<17.12",
|
||||
"ocrmypdf>=17.7,<17.11",
|
||||
"openai>=2.48",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
@@ -103,17 +103,17 @@ docs = [
|
||||
"zensical>=0.0.51",
|
||||
]
|
||||
lint = [
|
||||
"prek>=0.4.11,<0.6",
|
||||
"prek~=0.4.11",
|
||||
"ruff~=0.16.1",
|
||||
]
|
||||
testing = [
|
||||
"daphne",
|
||||
"factory-boy~=3.3.1",
|
||||
"faker>=40.36,<40.38",
|
||||
"faker~=40.36.0",
|
||||
"imagehash",
|
||||
"pytest~=9.1.1",
|
||||
"pytest-cov~=7.1.0",
|
||||
"pytest-django>=4.12,<4.15",
|
||||
"pytest-django~=4.12.0",
|
||||
"pytest-env~=1.7.0",
|
||||
"pytest-httpx",
|
||||
"pytest-mock~=3.15.1",
|
||||
|
||||
@@ -71,10 +71,8 @@
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"localize": true,
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/apple-touch-icon.png",
|
||||
"src/icon-192.png",
|
||||
"src/icon-512.png",
|
||||
"src/icon-512-maskable.png",
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest",
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DocumentListComponent } from './components/document-list/document-list.
|
||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||
import { MailComponent } from './components/manage/mail/mail.component'
|
||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
||||
@@ -310,6 +311,24 @@ export const routes: Routes = [
|
||||
componentName: 'SavedViewsComponent',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'share-links',
|
||||
component: ShareLinksComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermissionAny: [
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.ShareLink,
|
||||
},
|
||||
{
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.ShareLinkBundle,
|
||||
},
|
||||
],
|
||||
componentName: 'ShareLinksComponent',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -23,30 +23,17 @@
|
||||
<div class="col">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="card-title d-flex align-items-center flex-wrap">
|
||||
<div class="card-title d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
{{option.title}}
|
||||
</h6>
|
||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||
<i-bs name="info-circle"></i-bs>
|
||||
</a>
|
||||
@if (isExternallyConfigured(option.config_key)) {
|
||||
@if (isSet(option.key)) {
|
||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="This value overrides {{option.config_key}}, which is set outside Paperless." i18n-title>Overrides external</span>
|
||||
} @else {
|
||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="{{option.config_key}} is set outside Paperless. Enter a value here to override it." i18n-title>Set externally</span>
|
||||
}
|
||||
}
|
||||
@if (isSet(option.key)) {
|
||||
@if (isExternallyConfigured(option.config_key)) {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="mb-n3">
|
||||
|
||||
@@ -163,19 +163,6 @@ describe('ConfigComponent', () => {
|
||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||
})
|
||||
|
||||
it('should identify externally configured options', () => {
|
||||
component.externallyConfiguredVariables = new Set([
|
||||
'PAPERLESS_OCR_LANGUAGE',
|
||||
])
|
||||
|
||||
expect(
|
||||
component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE')
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should group options into sections within a category, or not', () => {
|
||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||
|
||||
@@ -69,7 +69,6 @@ export class ConfigComponent
|
||||
public configForm = new FormGroup({})
|
||||
|
||||
public errors = {}
|
||||
public externallyConfiguredVariables = new Set<string>()
|
||||
|
||||
get optionCategories(): string[] {
|
||||
return Object.values(ConfigCategory)
|
||||
@@ -153,9 +152,6 @@ export class ConfigComponent
|
||||
}
|
||||
|
||||
private initialize(config: PaperlessConfig) {
|
||||
this.externallyConfiguredVariables = new Set(
|
||||
config.externally_configured_variables ?? []
|
||||
)
|
||||
if (!this.store) {
|
||||
this.store = new BehaviorSubject(config)
|
||||
|
||||
@@ -166,9 +162,7 @@ export class ConfigComponent
|
||||
this.configForm.patchValue(state, { emitEvent: false })
|
||||
})
|
||||
|
||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), {
|
||||
excludeKeys: ['externally_configured_variables'],
|
||||
})
|
||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
|
||||
}
|
||||
this.configForm.patchValue(config)
|
||||
|
||||
@@ -233,10 +227,6 @@ export class ConfigComponent
|
||||
return this.configForm.get(key).value != null
|
||||
}
|
||||
|
||||
public isExternallyConfigured(configKey: string): boolean {
|
||||
return this.externallyConfiguredVariables.has(configKey)
|
||||
}
|
||||
|
||||
public resetOption(key: string) {
|
||||
this.configForm.get(key).setValue(null)
|
||||
}
|
||||
|
||||
@@ -237,12 +237,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
||||
|
||||
@@ -267,7 +267,7 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||
expect(setSpy).toHaveBeenCalledTimes(32)
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
|
||||
@@ -168,7 +168,6 @@ export class SettingsComponent
|
||||
pdfEditorDefaultEditMode: new FormControl(null),
|
||||
documentEditingRemoveInboxTags: new FormControl(null),
|
||||
documentEditingOverlayThumbnail: new FormControl(null),
|
||||
documentEditingAutoSuggest: new FormControl(null),
|
||||
documentDetailsHiddenFields: new FormControl([]),
|
||||
searchDbOnly: new FormControl(null),
|
||||
searchLink: new FormControl(null),
|
||||
@@ -369,9 +368,6 @@ export class SettingsComponent
|
||||
documentEditingOverlayThumbnail: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||
),
|
||||
documentEditingAutoSuggest: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||
),
|
||||
documentDetailsHiddenFields: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
||||
),
|
||||
@@ -569,10 +565,6 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
||||
this.settingsForm.value.documentEditingOverlayThumbnail
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
||||
this.settingsForm.value.documentEditingAutoSuggest
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||
this.settingsForm.value.documentDetailsHiddenFields
|
||||
|
||||
@@ -244,6 +244,15 @@
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
@if (canManageShareLinks) {
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="share-links" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Share links" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="link"></i-bs><span class="nav-link-label"><ng-container i18n>Share links</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
|
||||
@@ -221,6 +221,19 @@ export class AppFrameComponent
|
||||
return this.appTitleSetting() || environment.appTitle
|
||||
}
|
||||
|
||||
get canManageShareLinks(): boolean {
|
||||
return (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLink
|
||||
) ||
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLinkBundle
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get customAppTitle(): string {
|
||||
return this.appTitleSetting()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { of } from 'rxjs'
|
||||
import {
|
||||
MailAction,
|
||||
MailMetadataCorrespondentOption,
|
||||
@@ -15,7 +15,6 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CheckComponent } from '../../input/check/check.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
|
||||
@@ -82,41 +81,6 @@ describe('MailRuleEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should use empty related object lists when retrieval fails', () => {
|
||||
const failed = () => throwError(() => new Error('Forbidden'))
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(MailAccountService), 'listAll')
|
||||
.mockReturnValue(failed())
|
||||
jest
|
||||
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
|
||||
.mockReturnValue(failed())
|
||||
jest
|
||||
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
|
||||
.mockReturnValue(failed())
|
||||
|
||||
const failedFixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.accounts()).toEqual([])
|
||||
expect(failedComponent.correspondents()).toEqual([])
|
||||
expect(failedComponent.documentTypes()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledTimes(3)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving mail accounts',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving correspondents',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving document types',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { map } from 'rxjs'
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@@ -26,7 +26,6 @@ import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CheckComponent } from '../../input/check/check.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
@@ -159,45 +158,17 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
||||
private readonly accountService = inject(MailAccountService)
|
||||
private readonly correspondentService = inject(CorrespondentService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
readonly accounts = toSignal(
|
||||
this.accountService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving mail accounts`,
|
||||
error
|
||||
)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
this.accountService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as MailAccount[] }
|
||||
)
|
||||
readonly correspondents = toSignal(
|
||||
this.correspondentService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving correspondents`,
|
||||
error
|
||||
)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as Correspondent[] }
|
||||
)
|
||||
readonly documentTypes = toSignal(
|
||||
this.documentTypeService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving document types`,
|
||||
error
|
||||
)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as DocumentType[] }
|
||||
)
|
||||
|
||||
|
||||
@@ -81,23 +81,6 @@ describe('UserEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should use an empty group list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(GroupService), 'listAll')
|
||||
.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(UserEditDialogComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.groups()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving groups',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create and edit modes', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { catchError, first, map, of } from 'rxjs'
|
||||
import { first, map } from 'rxjs'
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { Group } from 'src/app/data/group'
|
||||
import { User } from 'src/app/data/user'
|
||||
@@ -42,13 +42,7 @@ export class UserEditDialogComponent
|
||||
private readonly groupsService = inject(GroupService)
|
||||
|
||||
readonly groups = toSignal(
|
||||
this.groupsService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
this.groupsService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as Group[] }
|
||||
)
|
||||
readonly passwordIsSet = signal(false)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { of } from 'rxjs'
|
||||
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
|
||||
@@ -39,7 +39,6 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
@@ -207,44 +206,6 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
||||
}
|
||||
|
||||
it('should use empty related object lists when access is forbidden', () => {
|
||||
const forbidden = () => throwError(() => new Error('Forbidden'))
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(StoragePathService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(MailRuleService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
jest
|
||||
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
|
||||
.mockReturnValue(forbidden())
|
||||
|
||||
const forbiddenFixture = TestBed.createComponent(
|
||||
WorkflowEditDialogComponent
|
||||
)
|
||||
const forbiddenComponent = forbiddenFixture.componentInstance
|
||||
|
||||
expect(forbiddenComponent.correspondents()).toEqual([])
|
||||
expect(forbiddenComponent.documentTypes()).toEqual([])
|
||||
expect(forbiddenComponent.storagePaths()).toEqual([])
|
||||
expect(forbiddenComponent.mailRules()).toEqual([])
|
||||
expect(forbiddenComponent.customFields()).toEqual([])
|
||||
expect(forbiddenComponent.dateCustomFields()).toEqual([])
|
||||
expect(() => forbiddenFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledTimes(1)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Some workflow options could not be loaded.',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@angular/forms'
|
||||
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subscription, catchError, map, of, takeUntil } from 'rxjs'
|
||||
import { Subscription, map, takeUntil } from 'rxjs'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@@ -48,7 +48,6 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
import {
|
||||
@@ -513,43 +512,26 @@ export class WorkflowEditDialogComponent
|
||||
private readonly storagePathService = inject(StoragePathService)
|
||||
private readonly mailRuleService = inject(MailRuleService)
|
||||
private readonly customFieldsService = inject(CustomFieldsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private relatedObjectLoadErrorShown = false
|
||||
|
||||
readonly templates = signal<Workflow[]>(undefined)
|
||||
readonly correspondents = toSignal(
|
||||
this.correspondentService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as Correspondent[] }
|
||||
)
|
||||
readonly documentTypes = toSignal(
|
||||
this.documentTypeService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as DocumentType[] }
|
||||
)
|
||||
readonly storagePaths = toSignal(
|
||||
this.storagePathService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
this.storagePathService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as StoragePath[] }
|
||||
)
|
||||
readonly mailRules = toSignal(
|
||||
this.mailRuleService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
this.mailRuleService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as MailRule[] }
|
||||
)
|
||||
readonly customFields = toSignal(
|
||||
this.customFieldsService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
||||
),
|
||||
this.customFieldsService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as CustomField[] }
|
||||
)
|
||||
readonly dateCustomFields = computed(() =>
|
||||
@@ -563,17 +545,6 @@ export class WorkflowEditDialogComponent
|
||||
SETTINGS_KEYS.AI_ENABLED
|
||||
)
|
||||
|
||||
private handleRelatedObjectLoadError(error) {
|
||||
if (!this.relatedObjectLoadErrorShown) {
|
||||
this.relatedObjectLoadErrorShown = true
|
||||
this.toastService.showError(
|
||||
$localize`Some workflow options could not be loaded.`,
|
||||
error
|
||||
)
|
||||
}
|
||||
return of([])
|
||||
}
|
||||
|
||||
expandedItem: number = null
|
||||
|
||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||
|
||||
@@ -7,9 +7,8 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { of } from 'rxjs'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsGroupComponent } from './permissions-group.component'
|
||||
|
||||
describe('PermissionsGroupComponent', () => {
|
||||
@@ -61,19 +60,4 @@ describe('PermissionsGroupComponent', () => {
|
||||
expect(component.value).toEqual({ id: 2, name: 'Group 2' })
|
||||
expect(groupServiceSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use an empty group list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
groupServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(PermissionsGroupComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.groups()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving groups',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { Group } from 'src/app/data/group'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { AbstractInputComponent } from '../../abstract-input'
|
||||
|
||||
@Component({
|
||||
@@ -27,15 +26,8 @@ import { AbstractInputComponent } from '../../abstract-input'
|
||||
})
|
||||
export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
|
||||
private readonly groupService = inject(GroupService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
readonly groups = toSignal(
|
||||
this.groupService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
this.groupService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as Group[] }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,8 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { of } from 'rxjs'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsUserComponent } from './permissions-user.component'
|
||||
|
||||
describe('PermissionsUserComponent', () => {
|
||||
@@ -61,19 +60,4 @@ describe('PermissionsUserComponent', () => {
|
||||
expect(component.value).toEqual({ id: 2, name: 'User 2' })
|
||||
expect(userServiceSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use an empty user list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
userServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(PermissionsUserComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.users()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving users',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,10 +6,9 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { AbstractInputComponent } from '../../abstract-input'
|
||||
|
||||
@Component({
|
||||
@@ -27,15 +26,8 @@ import { AbstractInputComponent } from '../../abstract-input'
|
||||
})
|
||||
export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
|
||||
private readonly userService = inject(UserService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
readonly users = toSignal(
|
||||
this.userService.listAll().pipe(
|
||||
map((result) => result.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
this.userService.listAll().pipe(map((result) => result.results)),
|
||||
{ initialValue: undefined as User[] }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { of } from 'rxjs'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
||||
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
||||
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
||||
@@ -78,23 +77,6 @@ describe('PermissionsDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should use an empty user list when retrieval fails', () => {
|
||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
||||
jest
|
||||
.spyOn(TestBed.inject(UserService), 'listAll')
|
||||
.mockReturnValue(throwError(() => new Error('Forbidden')))
|
||||
|
||||
const failedFixture = TestBed.createComponent(PermissionsDialogComponent)
|
||||
const failedComponent = failedFixture.componentInstance
|
||||
|
||||
expect(failedComponent.users()).toEqual([])
|
||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error retrieving users',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('should return permissions', () => {
|
||||
expect(component.permissions).toEqual({
|
||||
owner: null,
|
||||
|
||||
@@ -14,11 +14,10 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { catchError, map, of } from 'rxjs'
|
||||
import { map } from 'rxjs'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
||||
import { SwitchComponent } from '../input/switch/switch.component'
|
||||
|
||||
@@ -36,16 +35,9 @@ import { SwitchComponent } from '../input/switch/switch.component'
|
||||
export class PermissionsDialogComponent {
|
||||
activeModal = inject(NgbActiveModal)
|
||||
private userService = inject(UserService)
|
||||
private toastService = inject(ToastService)
|
||||
|
||||
readonly users = toSignal(
|
||||
this.userService.listAll().pipe(
|
||||
map((r) => r.results),
|
||||
catchError((error) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, error)
|
||||
return of([])
|
||||
})
|
||||
),
|
||||
this.userService.listAll().pipe(map((r) => r.results)),
|
||||
{ initialValue: undefined as User[] }
|
||||
)
|
||||
readonly title = signal($localize`Set permissions`)
|
||||
|
||||
@@ -1473,35 +1473,6 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should not automatically get suggestions if auto-suggest is disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||
suggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
||||
initNormally()
|
||||
expect(suggestionsSpy).not.toHaveBeenCalled()
|
||||
|
||||
// still available on demand
|
||||
component.getSuggestions()
|
||||
expect(suggestionsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not automatically get AI suggestions if auto-suggest is disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) =>
|
||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||
)
|
||||
const aiSuggestionsSpy = jest.spyOn(documentService, 'getAiSuggestions')
|
||||
aiSuggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
||||
initNormally()
|
||||
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.getSuggestions()
|
||||
expect(aiSuggestionsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset the suggestions loading state if the document changes mid-request', () => {
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
|
||||
@@ -237,9 +237,6 @@ export class DocumentDetailComponent
|
||||
this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||
)
|
||||
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||
)
|
||||
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||
DocumentDetailFieldID[]
|
||||
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||
@@ -360,10 +357,6 @@ export class DocumentDetailComponent
|
||||
return this.aiEnabledSetting()
|
||||
}
|
||||
|
||||
get autoSuggest(): boolean {
|
||||
return this.autoSuggestSetting()
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
const hasArchiveVersion =
|
||||
this.metadata()?.has_archive_version ??
|
||||
@@ -911,7 +904,6 @@ export class DocumentDetailComponent
|
||||
this.updateFormForCustomFields()
|
||||
this.loadMetadataForSelectedVersion()
|
||||
if (
|
||||
this.autoSuggest &&
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
doc
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { EventEmitter, signal } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
@@ -46,7 +47,6 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
||||
import { BulkEditorComponent } from './bulk-editor.component'
|
||||
|
||||
const selectionData: SelectionData = {
|
||||
@@ -82,6 +82,7 @@ describe('BulkEditorComponent', () => {
|
||||
let customFieldsService: CustomFieldsService
|
||||
let httpTestingController: HttpTestingController
|
||||
let shareLinkBundleService: ShareLinkBundleService
|
||||
let router: Router
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -167,11 +168,14 @@ describe('BulkEditorComponent', () => {
|
||||
provide: ShareLinkBundleService,
|
||||
useValue: {
|
||||
createBundle: jest.fn(),
|
||||
listAllBundles: jest.fn(),
|
||||
rebuildBundle: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
@@ -189,6 +193,7 @@ describe('BulkEditorComponent', () => {
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||
router = TestBed.inject(Router)
|
||||
|
||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||
component = fixture.componentInstance
|
||||
@@ -1824,9 +1829,9 @@ describe('BulkEditorComponent', () => {
|
||||
},
|
||||
}
|
||||
|
||||
const openSpy = jest.spyOn(modalService, 'open')
|
||||
openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||
openSpy.mockReturnValueOnce({} as NgbModalRef)
|
||||
const openSpy = jest
|
||||
.spyOn(modalService, 'open')
|
||||
.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
||||
of({ id: 42 })
|
||||
)
|
||||
@@ -1860,11 +1865,9 @@ describe('BulkEditorComponent', () => {
|
||||
|
||||
dialogInstance.onOpenManage()
|
||||
expect(modalRef.close).toHaveBeenCalled()
|
||||
expect(openSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
||||
)
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||
queryParams: { type: 'bundles' },
|
||||
})
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -1917,13 +1920,10 @@ describe('BulkEditorComponent', () => {
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should open share link bundle management dialog', () => {
|
||||
const openSpy = jest.spyOn(modalService, 'open')
|
||||
it('should navigate to share link bundle management', () => {
|
||||
component.manageShareLinkBundles()
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
||||
)
|
||||
openSpy.mockRestore()
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||
queryParams: { type: 'bundles' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
@@ -69,7 +70,6 @@ import {
|
||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||
|
||||
@@ -104,6 +104,7 @@ export class BulkEditorComponent
|
||||
public readonly permissionService = inject(PermissionsService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@@ -1135,9 +1136,8 @@ export class BulkEditorComponent
|
||||
}
|
||||
|
||||
manageShareLinkBundles() {
|
||||
this.modalService.open(ShareLinkBundleManageDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
void this.router.navigate(['/share-links'], {
|
||||
queryParams: { type: 'bundles' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (textFilterTarget === 'asn' || textFilterTarget === 'duplicates') {
|
||||
@if (textFilterTarget === 'asn') {
|
||||
<select class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
|
||||
@for (m of textFilterModifiers; track m) {
|
||||
<option ngbDropdownItem [value]="m.id">{{m.label}}</option>
|
||||
@@ -23,7 +23,7 @@
|
||||
</button>
|
||||
}
|
||||
<input #textFilterInput class="form-control form-control-sm" type="text"
|
||||
[disabled]="textFilterInputDisabled"
|
||||
[disabled]="textFilterModifierIsNull"
|
||||
[(ngModel)]="textFilter"
|
||||
(keydown)="textFilterKeydown($event)"
|
||||
[ngbTypeahead]="searchAutoComplete"
|
||||
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_DUPLICATES,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
@@ -428,38 +427,6 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.textFilterTarget).toEqual('mime-type') // TEXT_FILTER_TARGET_MIME_TYPE
|
||||
})
|
||||
|
||||
it('should ingest filter rules for documents with duplicates', () => {
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
]
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
expect(component.textFilterModifier).toEqual('has-duplicates')
|
||||
expect(component.textFilterInputDisabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for documents without duplicates', () => {
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
expect(component.textFilterModifier).toEqual('does-not-have-duplicates')
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should ingest text filter rules for fulltext query', () => {
|
||||
expect(component.textFilter).toEqual(null)
|
||||
component.filterRules = [
|
||||
@@ -1423,33 +1390,6 @@ describe('FilterEditorComponent', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should convert duplicate target input to the correct filter rule', () => {
|
||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
||||
By.directive(NgbDropdownItem)
|
||||
)[5]
|
||||
textFieldTargetDropdown.triggerEventHandler('click')
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.textFilterTarget).toEqual('duplicates')
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
])
|
||||
|
||||
const textFieldModifierSelect = fixture.debugElement.query(By.css('select'))
|
||||
textFieldModifierSelect.nativeElement.value = 'does-not-have-duplicates'
|
||||
textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change'))
|
||||
fixture.detectChanges()
|
||||
expect(component.filterRules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should convert user input to correct filter rules on full text query', () => {
|
||||
component.textFilterInput.nativeElement.value = 'foo'
|
||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
@@ -2238,22 +2178,6 @@ describe('FilterEditorComponent', () => {
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('Without any tag')
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('With duplicates')
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
]
|
||||
expect(component.generateFilterName()).toEqual('Without duplicates')
|
||||
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
|
||||
@@ -65,7 +65,6 @@ import {
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_DUPLICATES,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
@@ -130,15 +129,12 @@ const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
|
||||
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
|
||||
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
|
||||
const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type'
|
||||
const TEXT_FILTER_TARGET_DUPLICATES = 'duplicates'
|
||||
|
||||
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
|
||||
const TEXT_FILTER_MODIFIER_NULL = 'is null'
|
||||
const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
|
||||
const TEXT_FILTER_MODIFIER_GT = 'greater'
|
||||
const TEXT_FILTER_MODIFIER_LT = 'less'
|
||||
const TEXT_FILTER_MODIFIER_HAS_DUPLICATES = 'has-duplicates'
|
||||
const TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES = 'does-not-have-duplicates'
|
||||
|
||||
const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g
|
||||
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g
|
||||
@@ -209,7 +205,6 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
|
||||
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
|
||||
name: $localize`Advanced search`,
|
||||
},
|
||||
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
|
||||
]
|
||||
|
||||
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
|
||||
@@ -246,17 +241,6 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
||||
},
|
||||
]
|
||||
|
||||
const DUPLICATES_FILTER_MODIFIER_OPTIONS = [
|
||||
{
|
||||
id: TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
|
||||
label: $localize`exist`,
|
||||
},
|
||||
{
|
||||
id: TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
|
||||
label: $localize`do not exist`,
|
||||
},
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-filter-editor',
|
||||
templateUrl: './filter-editor.component.html',
|
||||
@@ -336,12 +320,6 @@ export class FilterEditorComponent
|
||||
if (rule.value == 'false') {
|
||||
return $localize`Without any tag`
|
||||
}
|
||||
break
|
||||
|
||||
case FILTER_HAS_DUPLICATES:
|
||||
return rule.value == 'false'
|
||||
? $localize`Without duplicates`
|
||||
: $localize`With duplicates`
|
||||
|
||||
case FILTER_CUSTOM_FIELDS_QUERY:
|
||||
return $localize`Custom fields query`
|
||||
@@ -412,9 +390,7 @@ export class FilterEditorComponent
|
||||
public textFilterModifier: string
|
||||
|
||||
get textFilterModifiers() {
|
||||
return this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
|
||||
? DUPLICATES_FILTER_MODIFIER_OPTIONS
|
||||
: DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS
|
||||
return DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS
|
||||
}
|
||||
|
||||
get textFilterModifierIsNull(): boolean {
|
||||
@@ -423,13 +399,6 @@ export class FilterEditorComponent
|
||||
)
|
||||
}
|
||||
|
||||
get textFilterInputDisabled(): boolean {
|
||||
return (
|
||||
this.textFilterModifierIsNull ||
|
||||
this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
|
||||
)
|
||||
}
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@@ -475,7 +444,6 @@ export class FilterEditorComponent
|
||||
this.customFieldQueriesModel.clear(false)
|
||||
this._textFilter = null
|
||||
this._moreLikeId = null
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
|
||||
this.dateAddedTo = null
|
||||
this.dateAddedFrom = null
|
||||
this.dateCreatedTo = null
|
||||
@@ -509,13 +477,6 @@ export class FilterEditorComponent
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE
|
||||
this._textFilter = rule.value
|
||||
break
|
||||
case FILTER_HAS_DUPLICATES:
|
||||
this.textFilterTarget = TEXT_FILTER_TARGET_DUPLICATES
|
||||
this.textFilterModifier =
|
||||
rule.value == 'false' || rule.value == '0'
|
||||
? TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES
|
||||
: TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
||||
break
|
||||
case FILTER_FULLTEXT_QUERY:
|
||||
let allQueryArgs = rule.value.split(',')
|
||||
let textQueryArgs = []
|
||||
@@ -839,14 +800,6 @@ export class FilterEditorComponent
|
||||
value: this._textFilter.trim(),
|
||||
})
|
||||
}
|
||||
if (this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: (
|
||||
this.textFilterModifier == TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
||||
).toString(),
|
||||
})
|
||||
}
|
||||
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
|
||||
filterRules.push({
|
||||
rule_type: FILTER_SIMPLE_TITLE,
|
||||
@@ -1210,7 +1163,7 @@ export class FilterEditorComponent
|
||||
}
|
||||
|
||||
get textFilter() {
|
||||
return this.textFilterInputDisabled ? '' : this._textFilter
|
||||
return this.textFilterModifierIsNull ? '' : this._textFilter
|
||||
}
|
||||
|
||||
set textFilter(value) {
|
||||
@@ -1410,24 +1363,12 @@ export class FilterEditorComponent
|
||||
this._textFilter = ''
|
||||
}
|
||||
this.textFilterTarget = target
|
||||
if (target == TEXT_FILTER_TARGET_DUPLICATES) {
|
||||
this._textFilter = ''
|
||||
this.textFilterModifier = TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
||||
} else if (
|
||||
[
|
||||
TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
|
||||
TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
|
||||
].includes(this.textFilterModifier)
|
||||
) {
|
||||
this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS
|
||||
}
|
||||
this.textFilterInput.nativeElement.focus()
|
||||
this.updateRules()
|
||||
}
|
||||
|
||||
textFilterModifierChange() {
|
||||
if (
|
||||
this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES ||
|
||||
this.textFilterModifierIsNull ||
|
||||
([
|
||||
TEXT_FILTER_MODIFIER_EQUALS,
|
||||
|
||||
@@ -1,38 +1,22 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ title }}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
@if (loading()) {
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span i18n>Loading share link bundles…</span>
|
||||
</div>
|
||||
}
|
||||
<div class="border border-top-0 rounded-bottom p-3">
|
||||
@if (!loading() && error()) {
|
||||
<div class="alert alert-danger mb-0" role="alert">
|
||||
{{ error() }}
|
||||
</div>
|
||||
}
|
||||
@if (!loading() && !error()) {
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<p class="mb-0 text-muted small">
|
||||
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
|
||||
</p>
|
||||
</div>
|
||||
@if (bundles().length === 0) {
|
||||
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
||||
}
|
||||
@if (bundles().length > 0) {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<table class="table table-sm align-middle mb-0 bg-body">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" i18n>Created</th>
|
||||
<th scope="col" i18n>Status</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="status" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Status</th>
|
||||
<th scope="col" i18n>Size</th>
|
||||
<th scope="col" i18n>Expires</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||
<th scope="col" i18n>Documents</th>
|
||||
<th scope="col" i18n>File version</th>
|
||||
<th scope="col" class="text-end" i18n>Actions</th>
|
||||
@@ -96,6 +80,9 @@
|
||||
<td>
|
||||
@if (bundle.expiration) {
|
||||
{{ bundle.expiration | date: 'short' }}
|
||||
@if (isExpired(bundle.expiration)) {
|
||||
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||
}
|
||||
}
|
||||
@if (!bundle.expiration) {
|
||||
<span i18n>Never</span>
|
||||
@@ -104,42 +91,49 @@
|
||||
<td>{{ bundle.document_count }}</td>
|
||||
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
[disabled]="bundle.status !== statuses.Ready"
|
||||
(click)="copy(bundle)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedSlug() === bundle.slug) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
@if (copiedSlug() !== bundle.slug) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
@if (bundle.status === statuses.Failed) {
|
||||
<div class="d-inline-block position-relative">
|
||||
<span
|
||||
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||
[class.show]="copiedSlug() === bundle.slug"
|
||||
i18n
|
||||
>Copied!</span>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-warning"
|
||||
[disabled]="loading()"
|
||||
(click)="retry(bundle)"
|
||||
class="btn btn-outline-primary"
|
||||
[disabled]="bundle.status !== statuses.Ready"
|
||||
(click)="copy(bundle)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
<span class="visually-hidden" i18n>Retry</span>
|
||||
@if (copiedSlug() === bundle.slug) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
}
|
||||
@if (copiedSlug() !== bundle.slug) {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
[disabled]="loading()"
|
||||
(confirm)="delete(bundle)"
|
||||
iconName="trash"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||
</pngx-confirm-button>
|
||||
@if (bundle.status === statuses.Failed) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-warning"
|
||||
[disabled]="loading()"
|
||||
(click)="retry(bundle)"
|
||||
>
|
||||
<i-bs name="arrow-clockwise"></i-bs>
|
||||
<span class="visually-hidden" i18n>Retry</span>
|
||||
</button>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
[disabled]="loading()"
|
||||
(confirm)="delete(bundle)"
|
||||
iconName="trash"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -147,10 +141,32 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="small text-muted me-2" for="shareLinkBundlePageSize" i18n>Show:</label>
|
||||
<select id="shareLinkBundlePageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||
</div>
|
||||
@if (total() > pageSize) {
|
||||
<ngb-pagination
|
||||
class="mb-0"
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="total()"
|
||||
[page]="page()"
|
||||
[maxSize]="5"
|
||||
(pageChange)="setPage($event)"
|
||||
size="sm"
|
||||
aria-label="Share link bundles pagination"
|
||||
i18n-aria-label
|
||||
></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
|
||||
</div>
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FileVersion } from 'src/app/data/share-link'
|
||||
@@ -8,13 +7,15 @@ import {
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
|
||||
import { ShareLinkBundleListComponent } from './share-link-bundle-list.component'
|
||||
|
||||
class MockShareLinkBundleService {
|
||||
listAllBundles = jest.fn()
|
||||
list = jest.fn()
|
||||
delete = jest.fn()
|
||||
rebuildBundle = jest.fn()
|
||||
}
|
||||
@@ -24,13 +25,12 @@ class MockToastService {
|
||||
showError = jest.fn()
|
||||
}
|
||||
|
||||
describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
let component: ShareLinkBundleManageDialogComponent
|
||||
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
|
||||
describe('ShareLinkBundleListComponent', () => {
|
||||
let component: ShareLinkBundleListComponent
|
||||
let fixture: ComponentFixture<ShareLinkBundleListComponent>
|
||||
let service: MockShareLinkBundleService
|
||||
let toastService: MockToastService
|
||||
let clipboard: Clipboard
|
||||
let activeModal: NgbActiveModal
|
||||
let originalApiBaseUrl: string
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,26 +38,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
toastService = new MockToastService()
|
||||
originalApiBaseUrl = environment.apiBaseUrl
|
||||
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.delete.mockReturnValue(of(true))
|
||||
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinkBundleManageDialogComponent,
|
||||
ShareLinkBundleListComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
{ provide: ShareLinkBundleService, useValue: service },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
|
||||
fixture = TestBed.createComponent(ShareLinkBundleListComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
activeModal = TestBed.inject(NgbActiveModal)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -84,28 +82,28 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
it('loads bundles on init and polls periodically', () => {
|
||||
jest.useFakeTimers()
|
||||
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
||||
service.listAllBundles.mockReset()
|
||||
service.listAllBundles
|
||||
.mockReturnValueOnce(of(bundles))
|
||||
.mockReturnValue(of(bundles))
|
||||
service.list.mockReset()
|
||||
service.list
|
||||
.mockReturnValueOnce(of({ count: bundles.length, results: bundles }))
|
||||
.mockReturnValue(of({ count: bundles.length, results: bundles }))
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
|
||||
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||
expect(component.bundles()).toEqual(bundles)
|
||||
expect(component.loading()).toBe(false)
|
||||
expect(component.error()).toBeNull()
|
||||
|
||||
jest.advanceTimersByTime(5000)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('handles errors when loading bundles', () => {
|
||||
jest.useFakeTimers()
|
||||
service.listAllBundles.mockReset()
|
||||
service.listAllBundles
|
||||
service.list.mockReset()
|
||||
service.list
|
||||
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
||||
.mockReturnValue(of([]))
|
||||
.mockReturnValue(of({ count: 0, results: [] }))
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
@@ -114,7 +112,57 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
expect(component.loading()).toBe(false)
|
||||
|
||||
jest.advanceTimersByTime(5000)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('loads another page', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
component.setPage(2)
|
||||
|
||||
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||
})
|
||||
|
||||
it('sorts bundles and returns to the first page', () => {
|
||||
fixture.detectChanges()
|
||||
component.page.set(2)
|
||||
|
||||
component.onSort({ column: 'status', reverse: false })
|
||||
|
||||
expect(component.page()).toBe(1)
|
||||
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'status', false)
|
||||
})
|
||||
|
||||
it('marks expired share link bundles', () => {
|
||||
service.list.mockReturnValue(
|
||||
of({
|
||||
count: 1,
|
||||
results: [sampleBundle({ expiration: '2000-01-01T00:00:00.000Z' })],
|
||||
})
|
||||
)
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||
})
|
||||
|
||||
it('stores a changed page size and reloads from the first page', () => {
|
||||
fixture.detectChanges()
|
||||
const settingsService = TestBed.inject(SettingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockReturnValueOnce({ share_link_bundles: 25 })
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||
component.page.set(2)
|
||||
|
||||
component.pageSize = 100
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
share_link_bundles: 100,
|
||||
})
|
||||
expect(component.page()).toBe(1)
|
||||
expect(service.list).toHaveBeenLastCalledWith(1, 100, 'created', true)
|
||||
})
|
||||
|
||||
it('copies bundle links when ready', () => {
|
||||
@@ -126,16 +174,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
slug: 'ready-slug',
|
||||
status: ShareLinkBundleStatus.Ready,
|
||||
})
|
||||
component.bundles.set([readyBundle])
|
||||
fixture.detectChanges()
|
||||
component.copy(readyBundle)
|
||||
|
||||
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||
component.getShareUrl(readyBundle)
|
||||
)
|
||||
expect(component.copiedSlug()).toBe('ready-slug')
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.badge.show').textContent
|
||||
).toContain('Copied!')
|
||||
|
||||
jest.advanceTimersByTime(3000)
|
||||
expect(component.copiedSlug()).toBeNull()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.nativeElement.querySelector('.badge.show')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores copy requests for non-ready bundles', () => {
|
||||
@@ -146,7 +202,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('deletes bundles and refreshes list', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.delete.mockReturnValue(of(true))
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -157,12 +213,12 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
expect(toastService.showInfo).toHaveBeenCalledWith(
|
||||
expect.stringContaining('deleted.')
|
||||
)
|
||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
expect(component.loading()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles delete errors gracefully', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -174,7 +230,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('retries bundle build and replaces existing entry', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
||||
service.rebuildBundle.mockReturnValue(of(updated))
|
||||
|
||||
@@ -189,7 +245,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('adds new bundle when retry returns unknown entry', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.rebuildBundle.mockReturnValue(
|
||||
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
||||
)
|
||||
@@ -203,7 +259,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('handles retry errors', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -213,8 +269,8 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
expect(toastService.showError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps helpers and closes dialog', () => {
|
||||
service.listAllBundles.mockReturnValue(of([]))
|
||||
it('maps status and file version helpers', () => {
|
||||
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||
@@ -227,9 +283,5 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
||||
environment.apiBaseUrl = 'https://example.com/api/'
|
||||
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
||||
expect(url).toBe('https://example.com/share/sluggy')
|
||||
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
||||
import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
||||
import { FileVersion } from 'src/app/data/share-link'
|
||||
@@ -11,42 +15,77 @@ import {
|
||||
ShareLinkBundleStatus,
|
||||
ShareLinkBundleSummary,
|
||||
} from 'src/app/data/share-link-bundle'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
SortEvent,
|
||||
SortableDirective,
|
||||
} from 'src/app/directives/sortable.directive'
|
||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
||||
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-link-bundle-manage-dialog',
|
||||
templateUrl: './share-link-bundle-manage-dialog.component.html',
|
||||
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
|
||||
selector: 'pngx-share-link-bundle-list',
|
||||
templateUrl: './share-link-bundle-list.component.html',
|
||||
styleUrls: ['./share-link-bundle-list.component.scss'],
|
||||
imports: [
|
||||
ConfirmButtonComponent,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
NgxBootstrapIconsModule,
|
||||
SortableDirective,
|
||||
FileSizePipe,
|
||||
],
|
||||
})
|
||||
export class ShareLinkBundleManageDialogComponent
|
||||
export class ShareLinkBundleListComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
private readonly activeModal = inject(NgbActiveModal)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
|
||||
title = $localize`Share link bundles`
|
||||
readonly bundles = signal<ShareLinkBundleSummary[]>([])
|
||||
readonly error = signal<string | null>(null)
|
||||
readonly copiedSlug = signal<string | null>(null)
|
||||
readonly total = signal(0)
|
||||
readonly page = signal(1)
|
||||
readonly sortField = signal('created')
|
||||
readonly sortReverse = signal(true)
|
||||
|
||||
readonly statuses = ShareLinkBundleStatus
|
||||
readonly fileVersions = FileVersion
|
||||
|
||||
get pageSize(): number {
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)
|
||||
?.share_link_bundles || 25
|
||||
)
|
||||
}
|
||||
|
||||
set pageSize(pageSize: number) {
|
||||
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||
share_link_bundles: pageSize,
|
||||
})
|
||||
this.settingsService.storeSettings().subscribe({
|
||||
next: () => {
|
||||
this.page.set(1)
|
||||
this.triggerRefresh(false)
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving settings`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private readonly refresh$ = new Subject<boolean>()
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -57,25 +96,33 @@ export class ShareLinkBundleManageDialogComponent
|
||||
this.loading.set(true)
|
||||
}
|
||||
this.error.set(null)
|
||||
return this.shareLinkBundleService.listAllBundles().pipe(
|
||||
catchError((error) => {
|
||||
if (!silent) {
|
||||
this.loading.set(false)
|
||||
}
|
||||
this.error.set($localize`Failed to load share link bundles.`)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share link bundles.`,
|
||||
error
|
||||
)
|
||||
return of(null)
|
||||
})
|
||||
)
|
||||
return this.shareLinkBundleService
|
||||
.list(
|
||||
this.page(),
|
||||
this.pageSize,
|
||||
this.sortField(),
|
||||
this.sortReverse()
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
if (!silent) {
|
||||
this.loading.set(false)
|
||||
}
|
||||
this.error.set($localize`Failed to load share link bundles.`)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share link bundles.`,
|
||||
error
|
||||
)
|
||||
return of(null)
|
||||
})
|
||||
)
|
||||
}),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
)
|
||||
.subscribe((results) => {
|
||||
if (results) {
|
||||
this.bundles.set(results)
|
||||
this.bundles.set(results.results)
|
||||
this.total.set(results.count)
|
||||
this.copiedSlug.set(null)
|
||||
}
|
||||
this.loading.set(false)
|
||||
@@ -98,6 +145,18 @@ export class ShareLinkBundleManageDialogComponent
|
||||
}`
|
||||
}
|
||||
|
||||
setPage(page: number): void {
|
||||
this.page.set(page)
|
||||
this.triggerRefresh(false)
|
||||
}
|
||||
|
||||
onSort(event: SortEvent): void {
|
||||
this.sortField.set(event.column || 'created')
|
||||
this.sortReverse.set(event.column ? event.reverse : true)
|
||||
this.page.set(1)
|
||||
this.triggerRefresh(false)
|
||||
}
|
||||
|
||||
copy(bundle: ShareLinkBundleSummary): void {
|
||||
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
||||
return
|
||||
@@ -108,7 +167,6 @@ export class ShareLinkBundleManageDialogComponent
|
||||
setTimeout(() => {
|
||||
this.copiedSlug.set(null)
|
||||
}, 3000)
|
||||
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +175,9 @@ export class ShareLinkBundleManageDialogComponent
|
||||
this.loading.set(true)
|
||||
this.shareLinkBundleService.delete(bundle).subscribe({
|
||||
next: () => {
|
||||
if (this.bundles().length === 1 && this.page() > 1) {
|
||||
this.page.update((page) => page - 1)
|
||||
}
|
||||
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
||||
this.triggerRefresh(false)
|
||||
},
|
||||
@@ -153,8 +214,8 @@ export class ShareLinkBundleManageDialogComponent
|
||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.activeModal.close()
|
||||
isExpired(expiration?: string): boolean {
|
||||
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||
}
|
||||
|
||||
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
||||
@@ -0,0 +1,110 @@
|
||||
<div class="border border-top-0 rounded-bottom p-3">
|
||||
@if (!loading() && error()) {
|
||||
<div class="alert alert-danger mb-0" role="alert">{{ error() }}</div>
|
||||
}
|
||||
@if (!loading() && !error() && links().length === 0) {
|
||||
<p class="mb-0 text-muted fst-italic" i18n>
|
||||
No document share links currently exist.
|
||||
</p>
|
||||
}
|
||||
@if (!loading() && !error() && links().length > 0) {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0 bg-body">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="fw-normal" pngxSortable="document__title" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Document</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||
<th scope="col" i18n>File version</th>
|
||||
<th scope="col" class="text-end" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (link of links(); track link.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a routerLink="/documents/{{ link.document }}">{{ link.document_title | documentTitle }}</a>
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyDocumentID(link.document)">
|
||||
@if (copiedDocumentID() === link.document) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
||||
} @else {
|
||||
ID: {{link.document}}
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ link.created | date: 'short' }}</td>
|
||||
<td>
|
||||
@if (link.expiration) {
|
||||
{{ link.expiration | date: 'short' }}
|
||||
@if (isExpired(link.expiration)) {
|
||||
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||
}
|
||||
} @else {
|
||||
<span i18n>Never</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ fileVersionLabel(link.file_version) }}</td>
|
||||
<td class="text-end">
|
||||
<div class="d-inline-block position-relative">
|
||||
<span
|
||||
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||
[class.show]="copiedID() === link.id"
|
||||
i18n
|
||||
>Copied!</span>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-primary"
|
||||
(click)="copy(link)"
|
||||
title="Copy share link"
|
||||
i18n-title
|
||||
>
|
||||
@if (copiedID() === link.id) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="clipboard"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy share link</span>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ShareLink }"
|
||||
buttonClasses="btn btn-sm btn-outline-danger"
|
||||
(confirm)="delete(link)"
|
||||
iconName="trash"
|
||||
>
|
||||
<span class="visually-hidden" i18n>Delete share link</span>
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="small text-muted me-2" for="shareLinkPageSize" i18n>Show:</label>
|
||||
<select id="shareLinkPageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||
</div>
|
||||
@if (total() > pageSize) {
|
||||
<ngb-pagination
|
||||
class="mb-0"
|
||||
[pageSize]="pageSize"
|
||||
[collectionSize]="total()"
|
||||
[page]="page()"
|
||||
[maxSize]="5"
|
||||
(pageChange)="setPage($event)"
|
||||
size="sm"
|
||||
aria-label="Share links pagination"
|
||||
i18n-aria-label
|
||||
></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ShareLinkListComponent } from './share-link-list.component'
|
||||
|
||||
describe('ShareLinkListComponent', () => {
|
||||
let component: ShareLinkListComponent
|
||||
let fixture: ComponentFixture<ShareLinkListComponent>
|
||||
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
|
||||
let clipboard: Clipboard
|
||||
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
|
||||
|
||||
const link = {
|
||||
id: 1,
|
||||
document: 42,
|
||||
document_title: 'Test document',
|
||||
slug: 'share-slug',
|
||||
created: new Date().toISOString(),
|
||||
expiration: null,
|
||||
file_version: FileVersion.Archive,
|
||||
} as ShareLink
|
||||
|
||||
beforeEach(() => {
|
||||
service = {
|
||||
list: jest.fn().mockReturnValue(of({ count: 1, results: [link] })),
|
||||
delete: jest.fn().mockReturnValue(of(true)),
|
||||
}
|
||||
toastService = {
|
||||
showInfo: jest.fn(),
|
||||
showError: jest.fn(),
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinkListComponent,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
RouterTestingModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ShareLinkService, useValue: service },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
],
|
||||
})
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinkListComponent)
|
||||
component = fixture.componentInstance
|
||||
clipboard = TestBed.inject(Clipboard)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('loads and renders document share links', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||
expect(component.links()).toEqual([link])
|
||||
expect(fixture.nativeElement.textContent).toContain('Test document')
|
||||
expect(fixture.nativeElement.textContent).toContain('ID: 42')
|
||||
})
|
||||
|
||||
it('loads another page', () => {
|
||||
fixture.detectChanges()
|
||||
component.setPage(2)
|
||||
|
||||
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||
})
|
||||
|
||||
it('sorts links and returns to the first page', () => {
|
||||
fixture.detectChanges()
|
||||
component.page.set(2)
|
||||
|
||||
component.onSort({ column: 'expiration', reverse: false })
|
||||
|
||||
expect(component.page()).toBe(1)
|
||||
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'expiration', false)
|
||||
})
|
||||
|
||||
it('marks expired share links', () => {
|
||||
service.list.mockReturnValue(
|
||||
of({
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
...link,
|
||||
expiration: '2000-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||
})
|
||||
|
||||
it('stores a changed page size and reloads from the first page', () => {
|
||||
const settingsService = TestBed.inject(SettingsService)
|
||||
jest.spyOn(settingsService, 'get').mockReturnValueOnce({ share_links: 25 })
|
||||
const setSpy = jest.spyOn(settingsService, 'set')
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
component.page.set(2)
|
||||
|
||||
component.pageSize = 50
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
share_links: 50,
|
||||
})
|
||||
expect(component.page()).toBe(1)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows local copy feedback without a toast', () => {
|
||||
jest.useFakeTimers()
|
||||
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
|
||||
component.copy(link)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.copiedID()).toBe(link.id)
|
||||
expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
|
||||
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||
|
||||
jest.advanceTimersByTime(3000)
|
||||
expect(component.copiedID()).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes a link and reloads the list', () => {
|
||||
fixture.detectChanges()
|
||||
component.delete(link)
|
||||
|
||||
expect(service.delete).toHaveBeenCalledWith(link)
|
||||
expect(service.list).toHaveBeenCalledTimes(2)
|
||||
expect(toastService.showInfo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error when loading fails', () => {
|
||||
service.list.mockReturnValue(throwError(() => new Error('load failed')))
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.error()).toContain('Failed to load share links.')
|
||||
expect(toastService.showError).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||
import { SHARE_LINK_BUNDLE_FILE_VERSION_LABELS } from 'src/app/data/share-link-bundle'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import {
|
||||
SortEvent,
|
||||
SortableDirective,
|
||||
} from 'src/app/directives/sortable.directive'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-link-list',
|
||||
templateUrl: './share-link-list.component.html',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ConfirmButtonComponent,
|
||||
DocumentTitlePipe,
|
||||
FormsModule,
|
||||
IfPermissionsDirective,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterModule,
|
||||
SortableDirective,
|
||||
],
|
||||
})
|
||||
export class ShareLinkListComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
private readonly shareLinkService = inject(ShareLinkService)
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
readonly links = signal<ShareLink[]>([])
|
||||
readonly total = signal(0)
|
||||
readonly page = signal(1)
|
||||
readonly sortField = signal('created')
|
||||
readonly sortReverse = signal(true)
|
||||
readonly copiedID = signal<number | null>(null)
|
||||
readonly copiedDocumentID = signal<number | null>(null)
|
||||
readonly error = signal<string | null>(null)
|
||||
readonly PermissionAction = PermissionAction
|
||||
readonly PermissionType = PermissionType
|
||||
|
||||
get pageSize(): number {
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)?.share_links ||
|
||||
25
|
||||
)
|
||||
}
|
||||
|
||||
set pageSize(pageSize: number) {
|
||||
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||
share_links: pageSize,
|
||||
})
|
||||
this.settingsService.storeSettings().subscribe({
|
||||
next: () => {
|
||||
this.page.set(1)
|
||||
this.reload()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving settings`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.loading.set(true)
|
||||
this.error.set(null)
|
||||
this.shareLinkService
|
||||
.list(this.page(), this.pageSize, this.sortField(), this.sortReverse())
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (results) => {
|
||||
this.links.set(results.results)
|
||||
this.total.set(results.count)
|
||||
this.loading.set(false)
|
||||
},
|
||||
error: (error) => {
|
||||
this.loading.set(false)
|
||||
this.error.set($localize`Failed to load share links.`)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving share links.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setPage(page: number): void {
|
||||
this.page.set(page)
|
||||
this.reload()
|
||||
}
|
||||
|
||||
onSort(event: SortEvent): void {
|
||||
this.sortField.set(event.column || 'created')
|
||||
this.sortReverse.set(event.column ? event.reverse : true)
|
||||
this.page.set(1)
|
||||
this.reload()
|
||||
}
|
||||
|
||||
getShareUrl(link: ShareLink): string {
|
||||
const apiURL = new URL(environment.apiBaseUrl)
|
||||
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
|
||||
link.slug
|
||||
}`
|
||||
}
|
||||
|
||||
fileVersionLabel(version: FileVersion): string {
|
||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||
}
|
||||
|
||||
isExpired(expiration?: string): boolean {
|
||||
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||
}
|
||||
|
||||
copy(link: ShareLink): void {
|
||||
if (this.clipboard.copy(this.getShareUrl(link))) {
|
||||
this.copiedID.set(link.id)
|
||||
setTimeout(() => this.copiedID.set(null), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
delete(link: ShareLink): void {
|
||||
this.shareLinkService.delete(link).subscribe({
|
||||
next: () => {
|
||||
if (this.links().length === 1 && this.page() > 1) {
|
||||
this.page.update((page) => page - 1)
|
||||
}
|
||||
this.toastService.showInfo($localize`Share link deleted.`)
|
||||
this.reload()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error deleting share link.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
copyDocumentID(documentID: number): void {
|
||||
if (this.clipboard.copy(documentID.toString())) {
|
||||
this.copiedDocumentID.set(documentID)
|
||||
setTimeout(() => this.copiedDocumentID.set(null), 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<pngx-page-header
|
||||
title="Share links"
|
||||
i18n-title
|
||||
info="Manage public links to individual documents and document bundles."
|
||||
i18n-info
|
||||
[loading]="loading()"
|
||||
></pngx-page-header>
|
||||
|
||||
<ul
|
||||
ngbNav
|
||||
#nav="ngbNav"
|
||||
class="nav-tabs"
|
||||
[activeId]="activeNavID()"
|
||||
(activeIdChange)="selectTab($event)"
|
||||
>
|
||||
@if (canViewDocumentLinks) {
|
||||
<li [ngbNavItem]="ShareLinksNavIDs.DocumentLinks">
|
||||
<button ngbNavLink i18n>Document links</button>
|
||||
<ng-template ngbNavContent>
|
||||
<pngx-share-link-list></pngx-share-link-list>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
@if (canViewBundles) {
|
||||
<li [ngbNavItem]="ShareLinksNavIDs.Bundles">
|
||||
<button ngbNavLink i18n>Bundles</button>
|
||||
<ng-template ngbNavContent>
|
||||
<pngx-share-link-bundle-list></pngx-share-link-bundle-list>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="bg-body" [ngbNavOutlet]="nav"></div>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ShareLinksComponent, ShareLinksNavIDs } from './share-links.component'
|
||||
|
||||
describe('ShareLinksComponent', () => {
|
||||
let fixture: ComponentFixture<ShareLinksComponent>
|
||||
let permissionsService: PermissionsService
|
||||
let router: Router
|
||||
|
||||
const configure = async (type: string = null) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ShareLinksComponent,
|
||||
NgbNavModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
PageHeaderComponent,
|
||||
],
|
||||
providers: [
|
||||
PermissionsService,
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: { queryParamMap: convertToParamMap({ type }) },
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||
},
|
||||
{
|
||||
provide: ShareLinkBundleService,
|
||||
useValue: {
|
||||
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||
rebuildBundle: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ShareLinkService,
|
||||
useValue: {
|
||||
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: { showInfo: jest.fn(), showError: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
router = TestBed.inject(Router)
|
||||
}
|
||||
|
||||
afterEach(() => TestBed.resetTestingModule())
|
||||
|
||||
it('uses the requested bundles tab when permitted', async () => {
|
||||
await configure(ShareLinksNavIDs.Bundles)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockImplementation(
|
||||
(action, type) =>
|
||||
action === PermissionAction.View &&
|
||||
type === PermissionType.ShareLinkBundle
|
||||
)
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.componentInstance.activeNavID()).toBe(
|
||||
ShareLinksNavIDs.Bundles
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Document links')
|
||||
})
|
||||
|
||||
it('updates the URL when a tab is selected', async () => {
|
||||
await configure()
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
|
||||
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||
fixture.componentInstance.selectTab(ShareLinksNavIDs.Bundles)
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||
relativeTo: TestBed.inject(ActivatedRoute),
|
||||
queryParams: { type: ShareLinksNavIDs.Bundles },
|
||||
queryParamsHandling: 'merge',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, computed, inject, signal, viewChild } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { ShareLinkBundleListComponent } from './share-link-bundle-list/share-link-bundle-list.component'
|
||||
import { ShareLinkListComponent } from './share-link-list/share-link-list.component'
|
||||
|
||||
export enum ShareLinksNavIDs {
|
||||
DocumentLinks = 'documents',
|
||||
Bundles = 'bundles',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-share-links',
|
||||
templateUrl: './share-links.component.html',
|
||||
imports: [
|
||||
NgbNavModule,
|
||||
PageHeaderComponent,
|
||||
ShareLinkBundleListComponent,
|
||||
ShareLinkListComponent,
|
||||
],
|
||||
})
|
||||
export class ShareLinksComponent {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly permissionsService = inject(PermissionsService)
|
||||
|
||||
readonly ShareLinksNavIDs = ShareLinksNavIDs
|
||||
readonly activeNavID = signal(this.getInitialNavID())
|
||||
private readonly documentLinks = viewChild(ShareLinkListComponent)
|
||||
private readonly bundles = viewChild(ShareLinkBundleListComponent)
|
||||
readonly loading = computed(() => {
|
||||
const activeList =
|
||||
this.activeNavID() === ShareLinksNavIDs.DocumentLinks
|
||||
? this.documentLinks()
|
||||
: this.bundles()
|
||||
return activeList?.loading() ?? true
|
||||
})
|
||||
|
||||
get canViewDocumentLinks(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLink
|
||||
)
|
||||
}
|
||||
|
||||
get canViewBundles(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLinkBundle
|
||||
)
|
||||
}
|
||||
|
||||
selectTab(tab: ShareLinksNavIDs): void {
|
||||
this.activeNavID.set(tab)
|
||||
void this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { type: tab },
|
||||
queryParamsHandling: 'merge',
|
||||
})
|
||||
}
|
||||
|
||||
private getInitialNavID(): ShareLinksNavIDs {
|
||||
const requestedTab = this.route.snapshot.queryParamMap.get('type')
|
||||
if (requestedTab === ShareLinksNavIDs.Bundles && this.canViewBundles) {
|
||||
return ShareLinksNavIDs.Bundles
|
||||
}
|
||||
if (this.canViewDocumentLinks) {
|
||||
return ShareLinksNavIDs.DocumentLinks
|
||||
}
|
||||
return ShareLinksNavIDs.Bundles
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,6 @@ export const FILTER_MODIFIED_AFTER = 16
|
||||
export const FILTER_TITLE_CONTENT = 19 // Deprecated in favor of Tantivy-backed `text` filtervar. Keep for now for existing saved views
|
||||
export const FILTER_SIMPLE_TITLE = 48
|
||||
export const FILTER_SIMPLE_TEXT = 49
|
||||
export const FILTER_HAS_DUPLICATES = 50
|
||||
export const FILTER_FULLTEXT_QUERY = 20
|
||||
export const FILTER_FULLTEXT_MORELIKE = 21
|
||||
|
||||
@@ -383,13 +382,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
||||
datatype: 'string',
|
||||
multi: false,
|
||||
},
|
||||
{
|
||||
id: FILTER_HAS_DUPLICATES,
|
||||
filtervar: 'has_duplicates',
|
||||
datatype: 'boolean',
|
||||
multi: false,
|
||||
default: true,
|
||||
},
|
||||
]
|
||||
|
||||
export interface FilterRuleType {
|
||||
|
||||
@@ -422,7 +422,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
||||
]
|
||||
|
||||
export interface PaperlessConfig extends ObjectWithId {
|
||||
externally_configured_variables: string[]
|
||||
output_type: OutputTypeConfig
|
||||
pages: number
|
||||
language: string
|
||||
|
||||
@@ -26,5 +26,7 @@ export interface ShareLink extends ObjectWithPermissions {
|
||||
|
||||
document: number // Document
|
||||
|
||||
document_title?: string
|
||||
|
||||
file_version: string
|
||||
}
|
||||
|
||||
@@ -84,8 +84,6 @@ export const SETTINGS_KEYS = {
|
||||
'general-settings:document-editing:remove-inbox-tags',
|
||||
DOCUMENT_EDITING_OVERLAY_THUMBNAIL:
|
||||
'general-settings:document-editing:overlay-thumbnail',
|
||||
DOCUMENT_EDITING_AUTO_SUGGEST:
|
||||
'general-settings:document-editing:auto-suggest',
|
||||
DOCUMENT_DETAILS_HIDDEN_FIELDS:
|
||||
'general-settings:document-details:hidden-fields',
|
||||
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
||||
@@ -230,6 +228,8 @@ export const SETTINGS: UiSetting[] = [
|
||||
document_types: 25,
|
||||
tags: 25,
|
||||
storage_paths: 25,
|
||||
share_links: 25,
|
||||
share_link_bundles: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -302,11 +302,6 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||
type: 'array',
|
||||
|
||||
@@ -48,13 +48,4 @@ describe('ShareLinkBundleService', () => {
|
||||
expect(req.request.body).toEqual({})
|
||||
req.flush({})
|
||||
})
|
||||
|
||||
it('lists bundles with expected parameters', () => {
|
||||
subscription = service.listAllBundles().subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created`
|
||||
)
|
||||
expect(req.request.method).toBe('GET')
|
||||
req.flush({ results: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import {
|
||||
ShareLinkBundleCreatePayload,
|
||||
ShareLinkBundleSummary,
|
||||
@@ -32,10 +31,4 @@ export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkB
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
|
||||
return this.list(1, 1000, 'created', true).pipe(
|
||||
map((response) => response.results)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
FILTER_HAS_ANY_TAG,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||
FILTER_HAS_DUPLICATES,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_SIMPLE_TEXT,
|
||||
FILTER_SIMPLE_TITLE,
|
||||
@@ -133,16 +132,6 @@ describe('QueryParams Utils', () => {
|
||||
is_tagged: 0,
|
||||
})
|
||||
|
||||
params = queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'false',
|
||||
},
|
||||
])
|
||||
expect(params).toEqual({
|
||||
has_duplicates: 0,
|
||||
})
|
||||
|
||||
params = queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_TITLE_CONTENT,
|
||||
@@ -258,18 +247,6 @@ describe('QueryParams Utils', () => {
|
||||
},
|
||||
])
|
||||
|
||||
rules = filterRulesFromQueryParams(
|
||||
convertToParamMap({
|
||||
has_duplicates: 'true',
|
||||
})
|
||||
)
|
||||
expect(rules).toEqual([
|
||||
{
|
||||
rule_type: FILTER_HAS_DUPLICATES,
|
||||
value: 'true',
|
||||
},
|
||||
])
|
||||
|
||||
rules = filterRulesFromQueryParams(
|
||||
convertToParamMap({
|
||||
correspondent__isnull: '1',
|
||||
|
||||
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.4 238.9" style="enable-background:new 0 0 198.4 238.9" xml:space="preserve">
|
||||
<path d="M194.7 0C164.211 70.943 17.64 79.733 64.55 194.06c.59 1.468-10.848 17-18.47 29.897-1.758-6.453-3.816-13.486-3.516-14.075 38.109-45.141-27.26-70.643-30.776-107.583-16.423 29.318-22.286 80.623 27.25 110.23.29 0 2.637 11.138 3.816 16.712-1.169 2.348-2.348 4.695-2.927 6.454-1.168 2.926 7.622 2.637 7.622 3.226.879-.29 21.697-36.94 22.276-37.23C187.667 174.711 208.485 68.596 194.699 0zm-60.096 74.749c-55.11 49.246-64.49 85.897-62.732 103.777-18.47-43.682 35.772-91.76 62.732-103.777zM28.2 145.102c10.548 9.67 28.14 39.278 13.196 56.58 3.506-7.912 4.684-25.793-13.196-56.58z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 727 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
|
||||
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
|
||||
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
|
||||
<path style="fill:#17541f;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#17541F"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 855 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
|
||||
<path style="fill:#fff;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 849 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
|
||||
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
|
||||
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
@@ -8,6 +8,7 @@
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="theme-color" content="#17541f" />
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -4,20 +4,12 @@
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"src": "favicon.ico",
|
||||
"sizes": "256x256"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"src": "assets/logo-notext.svg",
|
||||
"sizes": "any"
|
||||
}
|
||||
],
|
||||
"name": "Paperless-ngx",
|
||||
|
||||
@@ -626,6 +626,10 @@ ul.pagination {
|
||||
table.table {
|
||||
--bs-table-color: var(--bs-body-color);
|
||||
--bs-table-bg: var(--bs-light-rgb);
|
||||
|
||||
&.bg-body {
|
||||
--bs-table-bg: var(--bs-body-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Literal
|
||||
@@ -28,7 +27,7 @@ from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.tasks import bulk_update_documents
|
||||
from documents.tasks import consume_file
|
||||
@@ -380,7 +379,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
||||
)
|
||||
delete_ids = list({*doc_ids, *version_ids})
|
||||
|
||||
Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4())
|
||||
Document.objects.filter(id__in=delete_ids).delete()
|
||||
|
||||
from documents.search import get_backend
|
||||
|
||||
@@ -431,13 +430,10 @@ def set_permissions(
|
||||
else:
|
||||
qs.update(owner=owner)
|
||||
|
||||
for doc in qs:
|
||||
set_permissions_for_object(permissions=set_permissions, object=doc, merge=merge)
|
||||
|
||||
affected_docs = list(qs.values_list("pk", flat=True))
|
||||
set_permissions_for_objects(
|
||||
permissions=set_permissions,
|
||||
model=Document,
|
||||
pks=affected_docs,
|
||||
merge=merge,
|
||||
)
|
||||
|
||||
bulk_update_documents.apply_async(
|
||||
kwargs={"document_ids": affected_docs},
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.db.models import Case
|
||||
from django.db.models import CharField
|
||||
from django.db.models import Count
|
||||
@@ -24,7 +25,6 @@ from django.db.models import Sum
|
||||
from django.db.models import Value
|
||||
from django.db.models import When
|
||||
from django.db.models.functions import Cast
|
||||
from django.db.models.functions import NullIf
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import DateFilter
|
||||
from django_filters.rest_framework import BooleanFilter
|
||||
@@ -50,9 +50,7 @@ from documents.models import ShareLink
|
||||
from documents.models import ShareLinkBundle
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.versioning import annotate_effective_content
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -182,9 +180,14 @@ class TitleContentFilter(Filter):
|
||||
logger.warning(
|
||||
"Deprecated document filter parameter 'title_content' used; use `text` instead.",
|
||||
)
|
||||
return annotate_effective_content(qs).filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
try:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(effective_content__icontains=value),
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
Q(title__icontains=value) | Q(content__icontains=value),
|
||||
)
|
||||
else:
|
||||
return qs
|
||||
|
||||
@@ -195,9 +198,14 @@ class EffectiveContentFilter(Filter):
|
||||
value = value.strip() if isinstance(value, str) else value
|
||||
if not value:
|
||||
return qs
|
||||
return annotate_effective_content(qs).filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
try:
|
||||
return qs.filter(
|
||||
**{f"effective_content__{self.lookup_expr}": value},
|
||||
)
|
||||
except FieldError:
|
||||
return qs.filter(
|
||||
**{f"content__{self.lookup_expr}": value},
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
@@ -785,12 +793,6 @@ class CustomFieldQueryFilter(Filter):
|
||||
|
||||
|
||||
class DocumentFilterSet(FilterSet):
|
||||
has_duplicates = BooleanFilter(method="filter_has_duplicates")
|
||||
|
||||
def __init__(self, *args: Any, user: Any = None, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._user = user
|
||||
|
||||
is_tagged = BooleanFilter(
|
||||
label="Is tagged",
|
||||
field_name="tags",
|
||||
@@ -850,38 +852,6 @@ class DocumentFilterSet(FilterSet):
|
||||
|
||||
mime_type = MimeTypeFilter()
|
||||
|
||||
def filter_has_duplicates(self, queryset, name, value):
|
||||
if value is None:
|
||||
return queryset
|
||||
|
||||
user = (
|
||||
self._user
|
||||
if self._user is not None
|
||||
else getattr(self.request, "user", None)
|
||||
)
|
||||
queryset = queryset.alias(
|
||||
nonempty_archive_checksum=NullIf("archive_checksum", Value("")),
|
||||
)
|
||||
|
||||
visible_root_documents = Document.global_objects.filter(
|
||||
root_document__isnull=True,
|
||||
pk__in=permitted_document_ids(
|
||||
user,
|
||||
include_deleted=True,
|
||||
),
|
||||
).exclude(pk=OuterRef("pk"))
|
||||
# see serialisers._get_viewable_duplicates().
|
||||
matching_duplicates = visible_root_documents.filter(
|
||||
Q(checksum=OuterRef("checksum"))
|
||||
| Q(checksum=OuterRef("nonempty_archive_checksum"))
|
||||
| Q(archive_checksum=OuterRef("checksum"))
|
||||
| Q(archive_checksum=OuterRef("nonempty_archive_checksum")),
|
||||
)
|
||||
|
||||
return queryset.alias(
|
||||
has_visible_duplicates=Exists(matching_duplicates),
|
||||
).filter(has_visible_duplicates=value)
|
||||
|
||||
# Backwards compatibility
|
||||
created__date__gt = DateFilter(field_name="created", lookup_expr="gt")
|
||||
created__date__gte = DateFilter(field_name="created", lookup_expr="gte")
|
||||
|
||||
@@ -156,15 +156,6 @@ class FileStabilityTracker:
|
||||
logger.debug(f"File disappeared during stability check: {path}")
|
||||
continue
|
||||
|
||||
# Stable, but empty: some scanners create a zero byte placeholder
|
||||
# and only write the page some time later. Consuming it now can
|
||||
# only fail so drop it and let the writer's next event
|
||||
# (or the periodic rescan) bring it back once it has content
|
||||
if not tracked.last_size:
|
||||
to_remove.append(path)
|
||||
logger.debug("Ignoring stable but empty file: %s", path)
|
||||
continue
|
||||
|
||||
# File is stable, we can return it
|
||||
to_yield.append(path)
|
||||
logger.info(f"File is stable: {path}")
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-05 16:29
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "0025_workflowaction_apply_ai_suggestions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="document",
|
||||
name="archive_checksum",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
editable=False,
|
||||
help_text="The checksum of the archived document.",
|
||||
max_length=64,
|
||||
null=True,
|
||||
verbose_name="archive checksum",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="savedviewfilterrule",
|
||||
name="rule_type",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, "title contains"),
|
||||
(1, "content contains"),
|
||||
(2, "ASN is"),
|
||||
(3, "correspondent is"),
|
||||
(4, "document type is"),
|
||||
(5, "is in inbox"),
|
||||
(6, "has tag"),
|
||||
(7, "has any tag"),
|
||||
(8, "created before"),
|
||||
(9, "created after"),
|
||||
(10, "created year is"),
|
||||
(11, "created month is"),
|
||||
(12, "created day is"),
|
||||
(13, "added before"),
|
||||
(14, "added after"),
|
||||
(15, "modified before"),
|
||||
(16, "modified after"),
|
||||
(17, "does not have tag"),
|
||||
(18, "does not have ASN"),
|
||||
(19, "title or content contains"),
|
||||
(20, "fulltext query"),
|
||||
(21, "more like this"),
|
||||
(22, "has tags in"),
|
||||
(23, "ASN greater than"),
|
||||
(24, "ASN less than"),
|
||||
(25, "storage path is"),
|
||||
(26, "has correspondent in"),
|
||||
(27, "does not have correspondent in"),
|
||||
(28, "has document type in"),
|
||||
(29, "does not have document type in"),
|
||||
(30, "has storage path in"),
|
||||
(31, "does not have storage path in"),
|
||||
(32, "owner is"),
|
||||
(33, "has owner in"),
|
||||
(34, "does not have owner"),
|
||||
(35, "does not have owner in"),
|
||||
(36, "has custom field value"),
|
||||
(37, "is shared by me"),
|
||||
(38, "has custom fields"),
|
||||
(39, "has custom field in"),
|
||||
(40, "does not have custom field in"),
|
||||
(41, "does not have custom field"),
|
||||
(42, "custom fields query"),
|
||||
(43, "created to"),
|
||||
(44, "created from"),
|
||||
(45, "added to"),
|
||||
(46, "added from"),
|
||||
(47, "mime type is"),
|
||||
(48, "simple title search"),
|
||||
(49, "simple text search"),
|
||||
(50, "has duplicates"),
|
||||
],
|
||||
verbose_name="rule type",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,4 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
@@ -228,7 +227,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
editable=False,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text=_("The checksum of the archived document."),
|
||||
)
|
||||
|
||||
@@ -515,20 +513,13 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
def delete(
|
||||
self,
|
||||
*args,
|
||||
transaction_id=None,
|
||||
**kwargs,
|
||||
):
|
||||
# Versions must share the root's transaction ID so they are restored
|
||||
# together by django-softdelete.
|
||||
if transaction_id is None:
|
||||
transaction_id = uuid.uuid4()
|
||||
# If deleting a root document, move all its versions to trash as well.
|
||||
if self.root_document_id is None:
|
||||
Document.objects.filter(root_document=self).delete(
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
Document.objects.filter(root_document=self).delete()
|
||||
return super().delete(
|
||||
*args,
|
||||
transaction_id=transaction_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -715,7 +706,6 @@ class SavedViewFilterRule(models.Model):
|
||||
(47, _("mime type is")),
|
||||
(48, _("simple title search")),
|
||||
(49, _("simple text search")),
|
||||
(50, _("has duplicates")),
|
||||
]
|
||||
|
||||
saved_view = models.ForeignKey(
|
||||
|
||||
@@ -173,179 +173,6 @@ def set_permissions_for_object(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_permissions(codenames: set[str], ctype: ContentType) -> list[Permission]:
|
||||
"""
|
||||
Resolves `codenames` to Permission rows, raising like the single-object
|
||||
assign_perm() this bulk path replaces does (via a `.get()` internally)
|
||||
if any codename doesn't exist -- e.g. a client-supplied action name that
|
||||
was never validated (BulkEditObjectsSerializer._validate_permissions
|
||||
calls validate_set_permissions() only for its side-effecting id checks
|
||||
and discards the filtered dict it returns, so an unrecognized action key
|
||||
reaches this function as-is). A plain `.filter()` with no existence
|
||||
check would otherwise silently build zero rows and no-op instead of
|
||||
reporting the bad input.
|
||||
"""
|
||||
permission_objs = list(
|
||||
Permission.objects.filter(content_type=ctype, codename__in=codenames),
|
||||
)
|
||||
missing = codenames - {p.codename for p in permission_objs}
|
||||
if missing:
|
||||
raise Permission.DoesNotExist(
|
||||
f"Permission matching query does not exist for codename(s): "
|
||||
f"{', '.join(sorted(missing))}",
|
||||
)
|
||||
return permission_objs
|
||||
|
||||
|
||||
def _apply_bulk_permission_entry(
|
||||
*,
|
||||
perm_model: type[UserObjectPermission] | type[GroupObjectPermission],
|
||||
identity_model: type[User] | type[Group],
|
||||
identity_field: str,
|
||||
ids: list[int],
|
||||
codename: str,
|
||||
permission_objs: list[Permission],
|
||||
ctype: ContentType,
|
||||
object_pks: list[str],
|
||||
merge: bool,
|
||||
) -> None:
|
||||
# Only the ids are needed to build permission rows (via `<field>_id=`),
|
||||
# so avoid fetching full User/Group rows for identities that may not
|
||||
# even end up being granted anything new.
|
||||
add_ids = set(
|
||||
identity_model.objects.filter(id__in=ids).values_list("id", flat=True),
|
||||
)
|
||||
|
||||
if not merge:
|
||||
existing_ids = set(
|
||||
perm_model.objects.filter(
|
||||
content_type=ctype,
|
||||
object_pk__in=object_pks,
|
||||
permission__codename=codename,
|
||||
)
|
||||
.values_list(f"{identity_field}_id", flat=True)
|
||||
.distinct(),
|
||||
)
|
||||
remove_ids = existing_ids - add_ids
|
||||
if remove_ids:
|
||||
perm_model.objects.filter(
|
||||
content_type=ctype,
|
||||
object_pk__in=object_pks,
|
||||
permission__codename=codename,
|
||||
**{f"{identity_field}_id__in": remove_ids},
|
||||
).delete()
|
||||
|
||||
if not add_ids:
|
||||
return
|
||||
|
||||
rows = [
|
||||
perm_model(
|
||||
content_type=ctype,
|
||||
object_pk=pk,
|
||||
permission=permission_obj,
|
||||
**{f"{identity_field}_id": identity_id},
|
||||
)
|
||||
for permission_obj in permission_objs
|
||||
for pk in object_pks
|
||||
for identity_id in add_ids
|
||||
]
|
||||
# ignore_conflicts skips only rows that already exist as an exact
|
||||
# (identity, permission, object) match -- the same de-dup the
|
||||
# underlying (user|group, permission, object_pk) unique constraint
|
||||
# already enforces for the single-object assign_perm() this replaces,
|
||||
# so it doesn't change what counts as "already granted". batch_size
|
||||
# caps how many rows go into a single INSERT statement.
|
||||
perm_model.objects.bulk_create(rows, ignore_conflicts=True, batch_size=1000)
|
||||
|
||||
|
||||
def set_permissions_for_objects(
|
||||
permissions: dict,
|
||||
model: type[Model],
|
||||
pks: QuerySet | list,
|
||||
*,
|
||||
merge: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Bulk equivalent of set_permissions_for_object: applies the same
|
||||
permission changes to every object identified by `pks` at once.
|
||||
|
||||
Takes a model + pks (rather than model instances) deliberately -- the
|
||||
permission rows built below only ever need `pk`, `content_type`, and
|
||||
identity ids, so callers shouldn't have to fetch full rows (with every
|
||||
other field) just to hand them to this function.
|
||||
|
||||
Deliberately does not use guardian's queryset/list-aware assign_perm:
|
||||
passing a list as the object routes to bulk_assign_perm, which skips
|
||||
creating a direct permission row for anyone who already has the
|
||||
permission via ANY group membership (it checks
|
||||
ObjectPermissionChecker.has_perm, which is group-inheritance-aware) --
|
||||
unlike the single-object assign_perm this replaces, which always
|
||||
ensures a direct row via get_or_create regardless of group-derived
|
||||
access. Losing that guarantee would mean a later revocation of the
|
||||
group's grant silently strips access an admin explicitly asked to be
|
||||
direct. Bulk-creating rows straight against the permission models
|
||||
instead (see _apply_bulk_permission_entry) preserves the original
|
||||
always-create-a-direct-row semantics while still batching every object
|
||||
and every identity into one query per action, rather than one query per
|
||||
(object, user) pair.
|
||||
"""
|
||||
object_pks = [str(pk) for pk in pks]
|
||||
if not object_pks: # pragma: no cover
|
||||
return
|
||||
|
||||
model_name = model.__name__.lower()
|
||||
ctype = ContentType.objects.get_for_model(model)
|
||||
|
||||
# Every action is resolved up front, before anything is written, so an
|
||||
# unrecognized action name (see _resolve_permissions) aborts the whole
|
||||
# call instead of leaving the actions ahead of it already applied --
|
||||
# BulkEditObjectsSerializer lets unknown keys through and its view turns
|
||||
# the exception into a 400, so a half-applied change would otherwise be
|
||||
# reported to the client as a failure.
|
||||
permissions_by_action: dict[str, list[Permission]] = {}
|
||||
for action, entry in permissions.items():
|
||||
if "users" not in entry and "groups" not in entry:
|
||||
continue
|
||||
implied_codenames = {f"{action}_{model_name}"}
|
||||
if action == "change":
|
||||
# change gives view too
|
||||
implied_codenames.add(f"view_{model_name}")
|
||||
permissions_by_action[action] = _resolve_permissions(
|
||||
implied_codenames,
|
||||
ctype,
|
||||
)
|
||||
|
||||
for action, entry in permissions.items():
|
||||
codename = f"{action}_{model_name}"
|
||||
permission_objs = permissions_by_action.get(action, [])
|
||||
|
||||
if "users" in entry:
|
||||
_apply_bulk_permission_entry(
|
||||
perm_model=UserObjectPermission,
|
||||
identity_model=User,
|
||||
identity_field="user",
|
||||
ids=entry["users"],
|
||||
codename=codename,
|
||||
permission_objs=permission_objs,
|
||||
ctype=ctype,
|
||||
object_pks=object_pks,
|
||||
merge=merge,
|
||||
)
|
||||
|
||||
if "groups" in entry:
|
||||
_apply_bulk_permission_entry(
|
||||
perm_model=GroupObjectPermission,
|
||||
identity_model=Group,
|
||||
identity_field="group",
|
||||
ids=entry["groups"],
|
||||
codename=codename,
|
||||
permission_objs=permission_objs,
|
||||
ctype=ctype,
|
||||
object_pks=object_pks,
|
||||
merge=merge,
|
||||
)
|
||||
|
||||
|
||||
def permitted_object_ids(
|
||||
user: User | None,
|
||||
model: type[Model],
|
||||
|
||||
@@ -284,46 +284,6 @@ class WriteBatch:
|
||||
tantivy.Query.term_query(self._backend._schema, "id", doc_id),
|
||||
)
|
||||
|
||||
def add_or_update_ids(self, ids: Sequence[int]) -> None:
|
||||
"""
|
||||
Add or update multiple documents in the batch by primary key.
|
||||
|
||||
Unlike calling ``add_or_update()`` once per document, this resolves
|
||||
viewer permissions and effective (versioned) content in bulk against
|
||||
the ids as a whole, instead of once per document -- see
|
||||
``_DocumentViewerStream`` and ``annotate_effective_content``. Use
|
||||
this whenever more than one document is being written in the same
|
||||
batch.
|
||||
|
||||
An id with no matching document (e.g. deleted between the caller
|
||||
collecting ids and the batch running) is silently skipped, matching
|
||||
``add_or_update()``'s existing single-document deferred-task behavior
|
||||
rather than erroring or leaving a stale index entry.
|
||||
|
||||
Args:
|
||||
ids: Primary keys of Document instances to index
|
||||
"""
|
||||
from documents.models import Document
|
||||
from documents.versioning import annotate_effective_content
|
||||
|
||||
ids = list(ids)
|
||||
if not ids:
|
||||
return
|
||||
|
||||
queryset = annotate_effective_content(
|
||||
Document.objects.filter(pk__in=ids)
|
||||
.select_related("correspondent", "document_type", "storage_path", "owner")
|
||||
.prefetch_related("tags", "notes__user", "custom_fields__field"),
|
||||
)
|
||||
for document, grant in _DocumentViewerStream(queryset, chunk_size=1000):
|
||||
self.remove(document.pk)
|
||||
doc = self._backend._build_tantivy_doc(
|
||||
document,
|
||||
viewer_ids=grant.viewer_ids,
|
||||
viewer_group_ids=grant.viewer_group_ids,
|
||||
)
|
||||
self._writer.add_document(doc)
|
||||
|
||||
|
||||
class TantivyBackend:
|
||||
"""
|
||||
|
||||
@@ -674,9 +674,6 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
ordering = ordering or (Lower("name"),)
|
||||
children = children.order_by(*ordering)
|
||||
|
||||
if not children:
|
||||
return []
|
||||
|
||||
serializer = TagSerializer(
|
||||
children,
|
||||
many=True,
|
||||
@@ -2815,6 +2812,11 @@ class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]):
|
||||
|
||||
|
||||
class ShareLinkSerializer(OwnedObjectSerializer):
|
||||
document_title = serializers.CharField(
|
||||
source="document.title",
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ShareLink
|
||||
fields = (
|
||||
@@ -2823,6 +2825,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
||||
"expiration",
|
||||
"slug",
|
||||
"document",
|
||||
"document_title",
|
||||
"file_version",
|
||||
)
|
||||
|
||||
|
||||
@@ -312,10 +312,7 @@ def bulk_update_documents(document_ids) -> None:
|
||||
from documents.search import get_backend
|
||||
|
||||
document_ids = list(document_ids)
|
||||
# Annotated so the signal handlers below (e.g. matching) don't query the
|
||||
# versions of each document. Indexing re-queries and re-annotates its own
|
||||
# copy via add_or_update_ids() below, after these signals (and any
|
||||
# workflow they trigger) have had a chance to mutate the documents.
|
||||
# Annotated so indexing below doesn't query the versions of each document
|
||||
documents = annotate_effective_content(
|
||||
Document.objects.filter(id__in=document_ids),
|
||||
)
|
||||
@@ -331,7 +328,8 @@ def bulk_update_documents(document_ids) -> None:
|
||||
post_save.send(Document, instance=doc, created=False)
|
||||
|
||||
with get_backend().batch_update() as batch:
|
||||
batch.add_or_update_ids(document_ids)
|
||||
for doc in documents:
|
||||
batch.add_or_update(doc)
|
||||
|
||||
ai_config = AIConfig()
|
||||
if ai_config.llm_index_enabled:
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from guardian.shortcuts import clear_ct_cache
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.tests.factories import DocumentFactory
|
||||
@@ -100,7 +100,7 @@ def sample_doc(
|
||||
@pytest.fixture()
|
||||
def _search_index(
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> Generator[None, None, None]:
|
||||
"""Create a temp index directory and point INDEX_DIR at it.
|
||||
|
||||
@@ -118,7 +118,7 @@ def _search_index(
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def settings_timezone(settings: Settings) -> zoneinfo.ZoneInfo:
|
||||
def settings_timezone(settings: SettingsWrapper) -> zoneinfo.ZoneInfo:
|
||||
return zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
||||
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ def clear_lru_cache() -> Generator[None, None, None]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_date_parser_settings(settings: pytest_django.fixtures.Settings) -> Any:
|
||||
def mock_date_parser_settings(settings: pytest_django.fixtures.SettingsWrapper) -> Any:
|
||||
"""
|
||||
Override Django settings for the duration of date parser tests.
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
from documents.export.sinks import DirectoryExportSink
|
||||
from documents.export.sinks import ExportSink
|
||||
@@ -242,7 +242,7 @@ class TestZipExportSink:
|
||||
self,
|
||||
tmp_path: Path,
|
||||
source_file: Path,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
scratch_dir = tmp_path / "scratch"
|
||||
settings.SCRATCH_DIR = scratch_dir
|
||||
@@ -261,7 +261,7 @@ class TestZipExportSink:
|
||||
def test_abort_after_manifest_written_cleans_up_pending_tmp(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
scratch_dir = tmp_path / "scratch"
|
||||
settings.SCRATCH_DIR = scratch_dir
|
||||
|
||||
@@ -15,11 +15,11 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index_dir(tmp_path: Path, settings: Settings) -> Path:
|
||||
def index_dir(tmp_path: Path, settings: SettingsWrapper) -> Path:
|
||||
path = tmp_path / "index"
|
||||
path.mkdir()
|
||||
settings.INDEX_DIR = path
|
||||
|
||||
@@ -4,8 +4,6 @@ from pathlib import Path
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -104,191 +102,6 @@ class TestWriteBatch:
|
||||
assert len(backend.search_ids("indexable", user=None)) == 1
|
||||
|
||||
|
||||
class TestAddOrUpdateIds:
|
||||
"""Test WriteBatch.add_or_update_ids(), the bulk id-based upsert path.
|
||||
|
||||
Unlike add_or_update() called once per document, this resolves viewer
|
||||
permissions and effective (versioned) content in bulk against the ids as
|
||||
a whole, so it must produce identical indexed output to the per-document
|
||||
path while issuing a constant number of queries regardless of batch size.
|
||||
"""
|
||||
|
||||
def test_missing_id_is_skipped_not_errored(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
content="present",
|
||||
checksum="EXIST1",
|
||||
pk=1,
|
||||
)
|
||||
missing_pk = 999
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk, missing_pk])
|
||||
|
||||
assert backend.search_ids("present", user=None) == [doc.pk]
|
||||
|
||||
def test_query_count_does_not_scale_with_batch_size(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""Each query count must stay far below N, not merely match between
|
||||
two runs -- an exact-equality assertion between two measurements is
|
||||
at the mercy of incidental process-level caches (e.g. Django's
|
||||
ContentType.objects.get_for_model) warming on whichever run happens
|
||||
first, which makes counts differ by a query for reasons unrelated to
|
||||
batch size. A generous fixed bound sidesteps that: the old
|
||||
per-document path issued roughly 8 queries per document, so 50
|
||||
documents under a bound this low proves the fix regardless of cache
|
||||
state.
|
||||
"""
|
||||
max_queries_for_any_batch_size = 15
|
||||
|
||||
small_docs = [
|
||||
Document.objects.create(
|
||||
title="doc",
|
||||
content=f"unique{i}",
|
||||
checksum=f"SMALL{i}",
|
||||
pk=i,
|
||||
)
|
||||
for i in range(1, 3)
|
||||
]
|
||||
with CaptureQueriesContext(connection) as ctx_small:
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([d.pk for d in small_docs])
|
||||
assert len(ctx_small.captured_queries) <= max_queries_for_any_batch_size
|
||||
|
||||
large_docs = [
|
||||
Document.objects.create(
|
||||
title="doc",
|
||||
content=f"unique{i}",
|
||||
checksum=f"LARGE{i}",
|
||||
pk=i,
|
||||
)
|
||||
for i in range(100, 150)
|
||||
]
|
||||
with CaptureQueriesContext(connection) as ctx_large:
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([d.pk for d in large_docs])
|
||||
assert len(ctx_large.captured_queries) <= max_queries_for_any_batch_size
|
||||
|
||||
for doc in large_docs:
|
||||
assert backend.search_ids(f"unique{doc.pk}", user=None) == [doc.pk]
|
||||
|
||||
def test_resolves_direct_user_grant_in_bulk(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
owner = UserFactory()
|
||||
user = UserFactory()
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
checksum="PERM1",
|
||||
pk=1,
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", user, doc)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("doc", user=user) == [doc.pk]
|
||||
other = UserFactory()
|
||||
assert backend.search_ids("doc", user=other) == []
|
||||
|
||||
def test_resolves_group_grant_in_bulk(self, backend: TantivyBackend) -> None:
|
||||
owner = UserFactory()
|
||||
group = Group.objects.create(name="reviewers")
|
||||
user = UserFactory()
|
||||
user.groups.add(group)
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
checksum="GPERM1",
|
||||
pk=1,
|
||||
owner=owner,
|
||||
)
|
||||
assign_perm("view_document", group, doc)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("doc", user=user) == [doc.pk]
|
||||
other = UserFactory()
|
||||
assert backend.search_ids("doc", user=other) == []
|
||||
|
||||
def test_indexes_notes_and_custom_fields(self, backend: TantivyBackend) -> None:
|
||||
note_author = UserFactory(username="noter")
|
||||
field = CustomField.objects.create(
|
||||
name="Invoice Number",
|
||||
data_type=CustomField.FieldDataType.STRING,
|
||||
)
|
||||
doc = Document.objects.create(title="doc", checksum="RICH1", pk=1)
|
||||
Note.objects.create(document=doc, note="Reviewed", user=note_author)
|
||||
CustomFieldInstance.objects.create(
|
||||
document=doc,
|
||||
field=field,
|
||||
value_text="INV-42",
|
||||
)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("notes.user:noter", user=None) == [doc.pk]
|
||||
assert backend.search_ids("custom_fields.value:INV-42", user=None) == [
|
||||
doc.pk,
|
||||
]
|
||||
|
||||
def test_uses_effective_content_for_versioned_documents(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
root = Document.objects.create(
|
||||
title="Statement",
|
||||
content="stale text",
|
||||
checksum="ROOT1",
|
||||
pk=1,
|
||||
)
|
||||
Document.objects.create(
|
||||
title="Statement",
|
||||
content="latest version text",
|
||||
checksum="VER1",
|
||||
pk=2,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([root.pk])
|
||||
|
||||
assert backend.search_ids("latest", user=None) == [root.pk]
|
||||
assert backend.search_ids("stale", user=None) == []
|
||||
|
||||
def test_reindexes_documents_already_in_the_index(
|
||||
self,
|
||||
backend: TantivyBackend,
|
||||
) -> None:
|
||||
"""add_or_update_ids must upsert, matching add_or_update's behaviour."""
|
||||
doc = Document.objects.create(
|
||||
title="doc",
|
||||
content="original",
|
||||
checksum="UP1",
|
||||
pk=1,
|
||||
)
|
||||
backend.add_or_update(doc)
|
||||
assert backend.search_ids("original", user=None) == [doc.pk]
|
||||
|
||||
doc.content = "updated"
|
||||
doc.save()
|
||||
|
||||
with backend.batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk])
|
||||
|
||||
assert backend.search_ids("original", user=None) == []
|
||||
assert backend.search_ids("updated", user=None) == [doc.pk]
|
||||
|
||||
|
||||
class TestSearch:
|
||||
"""Test search query parsing and matching via search_ids."""
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ from documents.search._schema import needs_rebuild
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from pytest_django.fixtures import Settings
|
||||
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
pytestmark = pytest.mark.search
|
||||
|
||||
@@ -26,7 +25,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_false_when_version_and_language_match(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -37,7 +36,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_on_schema_version_mismatch(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -48,7 +47,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_when_version_is_not_an_integer(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = None
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -59,7 +58,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_when_language_key_missing(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "en"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
@@ -70,7 +69,7 @@ class TestNeedsRebuild:
|
||||
def test_returns_true_when_language_differs(
|
||||
self,
|
||||
index_dir: Path,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
settings.SEARCH_LANGUAGE = "de"
|
||||
(index_dir / ".index_settings.json").write_text(
|
||||
|
||||
@@ -35,8 +35,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
THEN:
|
||||
- Existing config
|
||||
"""
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -46,7 +45,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
response.data[0],
|
||||
{
|
||||
"id": 1,
|
||||
"externally_configured_variables": [],
|
||||
"output_type": None,
|
||||
"pages": None,
|
||||
"language": None,
|
||||
@@ -78,7 +76,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
"remote_ocr_api_key": None,
|
||||
"remote_ocr_endpoint": None,
|
||||
"remote_ocr_mode": None,
|
||||
"ai_enabled": None,
|
||||
"ai_enabled": False,
|
||||
"llm_embedding_backend": None,
|
||||
"llm_embedding_model": None,
|
||||
"llm_embedding_endpoint": None,
|
||||
@@ -93,31 +91,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_api_get_config_reports_external_configuration_without_values(self) -> None:
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"PAPERLESS_OCR_LANGUAGE": "eng",
|
||||
"PAPERLESS_REMOTE_OCR_API_KEY": "secret-value",
|
||||
"PAPERLESS_FUTURE_SETTING": "future-value",
|
||||
"UNRELATED_SETTING": "unrelated-value",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertCountEqual(
|
||||
response.data[0]["externally_configured_variables"],
|
||||
[
|
||||
"PAPERLESS_FUTURE_SETTING",
|
||||
"PAPERLESS_OCR_LANGUAGE",
|
||||
"PAPERLESS_REMOTE_OCR_API_KEY",
|
||||
],
|
||||
)
|
||||
self.assertNotContains(response, "secret-value")
|
||||
self.assertNotContains(response, "future-value")
|
||||
self.assertNotContains(response, "UNRELATED_SETTING")
|
||||
|
||||
def test_api_get_ui_settings_with_config(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -976,26 +949,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
mock_update.assert_called_once()
|
||||
|
||||
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND=None)
|
||||
def test_external_ai_setting_triggers_index_update(self) -> None:
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
assert config is not None
|
||||
config.ai_enabled = None
|
||||
config.llm_embedding_backend = None
|
||||
config.save()
|
||||
|
||||
with (
|
||||
patch("documents.tasks.llmindex_index.apply_async") as mock_update,
|
||||
patch("paperless.views.llm_index_exists", return_value=False),
|
||||
):
|
||||
self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
json.dumps({"llm_embedding_backend": "openai-like"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
mock_update.assert_called_once()
|
||||
|
||||
def test_update_llm_embedding_chunk_size_triggers_rebuild(self) -> None:
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
assert config is not None
|
||||
|
||||
@@ -717,44 +717,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(args[0], [self.doc2.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||
def test_api_bulk_edit_with_all_true_resolves_owned_duplicates(self, m) -> None:
|
||||
self.setup_mock(m, "set_storage_path")
|
||||
user = User.objects.create_user(username="duplicate-owner")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="change_document"),
|
||||
)
|
||||
first_duplicate = Document.objects.create(
|
||||
checksum="owned-duplicate",
|
||||
title="First duplicate",
|
||||
owner=user,
|
||||
)
|
||||
second_duplicate = Document.objects.create(
|
||||
checksum="owned-duplicate",
|
||||
title="Second duplicate",
|
||||
owner=user,
|
||||
)
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/bulk_edit/",
|
||||
json.dumps(
|
||||
{
|
||||
"all": True,
|
||||
"filters": {"has_duplicates": True},
|
||||
"method": "set_storage_path",
|
||||
"parameters": {"storage_path": self.sp1.id},
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
m.assert_called_once()
|
||||
args, kwargs = m.call_args
|
||||
self.assertCountEqual(args[0], [first_duplicate.id, second_duplicate.id])
|
||||
self.assertEqual(kwargs["storage_path"], self.sp1.id)
|
||||
|
||||
@mock.patch("documents.search.get_backend")
|
||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||
def test_api_bulk_edit_with_all_true_resolves_documents_from_search_filters(
|
||||
|
||||
@@ -2,12 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import TestCase
|
||||
from unittest import mock
|
||||
|
||||
from auditlog.models import LogEntry # type: ignore[import-untyped]
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase as DjangoTestCase
|
||||
from django.utils import timezone
|
||||
@@ -20,7 +22,6 @@ from documents.filters import TitleContentFilter
|
||||
from documents.models import Document
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.views import DocumentSelectionMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -597,7 +598,6 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(input_doc.root_document_id, root.id)
|
||||
self.assertEqual(input_doc.source, DocumentSource.ApiUpload)
|
||||
self.assertEqual(overrides.version_label, "New Version")
|
||||
self.assertEqual(overrides.owner_id, self.user.id)
|
||||
self.assertEqual(overrides.actor_id, self.user.id)
|
||||
|
||||
def test_update_version_with_version_pk_normalizes_to_root(self) -> None:
|
||||
@@ -891,104 +891,32 @@ class TestDocumentVersioningApi(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestVersionAwareFilters(DjangoTestCase):
|
||||
"""
|
||||
The filters annotate effective_content themselves rather than relying on
|
||||
the caller's queryset carrying it, so they stay version-aware on a plain
|
||||
Document queryset (e.g. the bulk-edit "select all matching" path).
|
||||
"""
|
||||
class TestVersionAwareFilters(TestCase):
|
||||
def test_title_content_filter_falls_back_to_content(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
fallback_queryset = mock.Mock()
|
||||
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.root = Document.objects.create(
|
||||
title="root",
|
||||
checksum="root",
|
||||
mime_type="application/pdf",
|
||||
content="superseded-content",
|
||||
)
|
||||
Document.objects.create(
|
||||
title="version",
|
||||
checksum="version",
|
||||
mime_type="application/pdf",
|
||||
root_document=self.root,
|
||||
version_index=1,
|
||||
content="latest-content",
|
||||
)
|
||||
self.unversioned = Document.objects.create(
|
||||
title="unversioned",
|
||||
checksum="unversioned",
|
||||
mime_type="application/pdf",
|
||||
content="latest-content",
|
||||
)
|
||||
result = TitleContentFilter().filter(queryset, " latest ")
|
||||
|
||||
def test_title_content_filter_matches_latest_version_content(self) -> None:
|
||||
result = TitleContentFilter().filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
self.assertIs(result, fallback_queryset)
|
||||
self.assertEqual(queryset.filter.call_count, 2)
|
||||
|
||||
def test_effective_content_filter_falls_back_to_content_lookup(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
fallback_queryset = mock.Mock()
|
||||
queryset.filter.side_effect = [FieldError("missing field"), fallback_queryset]
|
||||
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
queryset,
|
||||
" latest ",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_effective_content_filter_matches_latest_version_content(self) -> None:
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
" latest ",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_effective_content_filter_ignores_superseded_content(self) -> None:
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
"superseded",
|
||||
)
|
||||
|
||||
self.assertEqual(list(result), [])
|
||||
|
||||
def test_filters_reuse_an_existing_annotation(self) -> None:
|
||||
"""
|
||||
Annotating twice under the same alias is an error, so an already
|
||||
annotated queryset (the search path) has to be left alone.
|
||||
"""
|
||||
annotated = annotate_effective_content(
|
||||
Document.objects.filter(root_document__isnull=True),
|
||||
)
|
||||
self.assertIs(annotate_effective_content(annotated), annotated)
|
||||
|
||||
result = EffectiveContentFilter(lookup_expr="icontains").filter(
|
||||
annotated,
|
||||
"latest",
|
||||
)
|
||||
|
||||
self.assertCountEqual(
|
||||
[doc.id for doc in result],
|
||||
[self.root.id, self.unversioned.id],
|
||||
)
|
||||
|
||||
def test_bulk_selection_does_not_match_superseded_content(self) -> None:
|
||||
"""
|
||||
Bulk edit's "select all matching" builds its own queryset, so before
|
||||
the filters annotated for themselves it matched the root document's
|
||||
superseded content -- selecting documents the list view, filtered by
|
||||
the same term, does not show.
|
||||
"""
|
||||
user = User.objects.create_superuser(username="bulk_selection")
|
||||
|
||||
selected = DocumentSelectionMixin()._resolve_document_ids(
|
||||
user=user,
|
||||
validated_data={
|
||||
"all": True,
|
||||
"filters": {"content__icontains": "superseded"},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(selected, [])
|
||||
self.assertIs(result, fallback_queryset)
|
||||
first_kwargs = queryset.filter.call_args_list[0].kwargs
|
||||
second_kwargs = queryset.filter.call_args_list[1].kwargs
|
||||
self.assertEqual(first_kwargs, {"effective_content__icontains": "latest"})
|
||||
self.assertEqual(second_kwargs, {"content__icontains": "latest"})
|
||||
|
||||
def test_effective_content_filter_returns_input_for_empty_values(self) -> None:
|
||||
queryset = mock.Mock()
|
||||
|
||||
@@ -981,128 +981,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["id"], doc.id)
|
||||
|
||||
def test_has_duplicates_filter(self) -> None:
|
||||
original_match = Document.objects.create(
|
||||
title="original match",
|
||||
checksum="same-original",
|
||||
)
|
||||
second_original_match = Document.objects.create(
|
||||
title="second original match",
|
||||
checksum="same-original",
|
||||
)
|
||||
archive_match = Document.objects.create(
|
||||
title="archive match",
|
||||
checksum="archive-source",
|
||||
archive_checksum="same-archive",
|
||||
)
|
||||
original_to_archive_match = Document.objects.create(
|
||||
title="original to archive match",
|
||||
checksum="same-archive",
|
||||
)
|
||||
first_archive_match = Document.objects.create(
|
||||
title="first archive match",
|
||||
checksum="first-archive-source",
|
||||
archive_checksum="same-archive-only",
|
||||
)
|
||||
second_archive_match = Document.objects.create(
|
||||
title="second archive match",
|
||||
checksum="second-archive-source",
|
||||
archive_checksum="same-archive-only",
|
||||
)
|
||||
first_empty_archive = Document.objects.create(
|
||||
title="first empty archive",
|
||||
checksum="first-empty-archive",
|
||||
archive_checksum="",
|
||||
)
|
||||
second_empty_archive = Document.objects.create(
|
||||
title="second empty archive",
|
||||
checksum="second-empty-archive",
|
||||
archive_checksum="",
|
||||
)
|
||||
unique = Document.objects.create(title="unique", checksum="unique")
|
||||
version_root = Document.objects.create(
|
||||
title="version root",
|
||||
checksum="version-root",
|
||||
)
|
||||
Document.objects.create(
|
||||
title="version",
|
||||
checksum=unique.checksum,
|
||||
root_document=version_root,
|
||||
version_index=1,
|
||||
)
|
||||
trash_match = Document.objects.create(
|
||||
title="trash match",
|
||||
checksum="trash-match",
|
||||
)
|
||||
trashed_duplicate = Document.objects.create(
|
||||
title="trashed duplicate",
|
||||
checksum="trash-match",
|
||||
)
|
||||
trashed_duplicate.delete()
|
||||
|
||||
response = self.client.get("/api/documents/?has_duplicates=true")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertCountEqual(
|
||||
[document["id"] for document in response.data["results"]],
|
||||
[
|
||||
original_match.id,
|
||||
second_original_match.id,
|
||||
archive_match.id,
|
||||
original_to_archive_match.id,
|
||||
first_archive_match.id,
|
||||
second_archive_match.id,
|
||||
trash_match.id,
|
||||
],
|
||||
)
|
||||
|
||||
response = self.client.get("/api/documents/?has_duplicates=false")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertCountEqual(
|
||||
[document["id"] for document in response.data["results"]],
|
||||
[
|
||||
unique.id,
|
||||
version_root.id,
|
||||
first_empty_archive.id,
|
||||
second_empty_archive.id,
|
||||
],
|
||||
)
|
||||
|
||||
response = self.client.get(f"/api/documents/{first_empty_archive.id}/")
|
||||
self.assertEqual(response.data["duplicate_documents"], [])
|
||||
|
||||
def test_has_duplicates_filter_respects_document_permissions(self) -> None:
|
||||
owner = User.objects.create_user(username="duplicate-owner")
|
||||
requester = User.objects.create_user(username="duplicate-requester")
|
||||
requester.user_permissions.add(
|
||||
Permission.objects.get(codename="view_document"),
|
||||
)
|
||||
visible_document = Document.objects.create(
|
||||
title="visible document",
|
||||
checksum="permission-match",
|
||||
owner=requester,
|
||||
)
|
||||
hidden_duplicate = Document.objects.create(
|
||||
title="hidden duplicate",
|
||||
checksum="permission-match",
|
||||
owner=owner,
|
||||
)
|
||||
self.client.force_authenticate(user=requester)
|
||||
|
||||
response = self.client.get("/api/documents/?has_duplicates=true")
|
||||
self.assertNotIn(
|
||||
visible_document.id,
|
||||
[document["id"] for document in response.data["results"]],
|
||||
)
|
||||
|
||||
assign_perm("view_document", requester, hidden_duplicate)
|
||||
response = self.client.get("/api/documents/?has_duplicates=true")
|
||||
self.assertIn(
|
||||
visible_document.id,
|
||||
[document["id"] for document in response.data["results"]],
|
||||
)
|
||||
|
||||
def test_custom_fields_icontains_filter_no_duplicates(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -3857,6 +3735,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(resp.data["document_title"], doc.title)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/share_links/",
|
||||
@@ -3867,6 +3746,17 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(resp.data["document_title"], doc.title)
|
||||
|
||||
response = self.client.get("/api/share_links/", format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 2)
|
||||
self.assertTrue(
|
||||
all(
|
||||
link["document_title"] == doc.title for link in response.data["results"]
|
||||
),
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/documents/{doc.pk}/share_links/",
|
||||
@@ -3878,6 +3768,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
resp_data = response.json()
|
||||
|
||||
self.assertEqual(len(resp_data), 2)
|
||||
self.assertTrue(
|
||||
all(link["document_title"] == doc.title for link in resp_data),
|
||||
)
|
||||
|
||||
self.assertGreater(len(resp_data[1]["slug"]), 0)
|
||||
self.assertIsNone(resp_data[1]["expiration"])
|
||||
@@ -3903,6 +3796,23 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_order_share_links_by_document_title(self) -> None:
|
||||
document_zulu = Document.objects.create(title="Zulu")
|
||||
document_alpha = Document.objects.create(title="Alpha")
|
||||
ShareLink.objects.create(document=document_zulu, slug="zulu-link")
|
||||
ShareLink.objects.create(document=document_alpha, slug="alpha-link")
|
||||
|
||||
response = self.client.get(
|
||||
"/api/share_links/?ordering=document__title",
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
[link["document_title"] for link in response.data["results"]],
|
||||
["Alpha", "Zulu"],
|
||||
)
|
||||
|
||||
def test_share_links_permissions_aware(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -2,15 +2,10 @@ import datetime
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test import override_settings
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_groups_with_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -847,66 +842,6 @@ class TestBulkEditObjects(APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(StoragePath.objects.count(), 0)
|
||||
|
||||
def test_bulk_objects_set_permissions_batched_across_object_count(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Many tags are being bulk-edited to set permissions at once
|
||||
WHEN:
|
||||
- bulk_edit_objects API endpoint is called with set_permissions
|
||||
operation over a small batch vs. a much larger one
|
||||
THEN:
|
||||
- Permissions are applied correctly at both scales
|
||||
- Query count does not grow with the number of tags, i.e. each
|
||||
user/group is applied across all tags with one batched call
|
||||
rather than one call per (tag, identity) pair
|
||||
"""
|
||||
group1 = Group.objects.create(name="perm-group")
|
||||
permissions = {
|
||||
"view": {"users": [self.user1.id, self.user2.id], "groups": [group1.id]},
|
||||
"change": {"users": [self.user1.id], "groups": [group1.id]},
|
||||
}
|
||||
|
||||
def run_with_n_tags(n: int) -> int:
|
||||
tags = [Tag.objects.create(name=f"perm-tag-{n}-{i}") for i in range(n)]
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = self.client.post(
|
||||
"/api/bulk_edit_objects/",
|
||||
json.dumps(
|
||||
{
|
||||
"objects": [t.id for t in tags],
|
||||
"object_type": "tags",
|
||||
"operation": "set_permissions",
|
||||
"permissions": permissions,
|
||||
"merge": False,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
for tag in tags:
|
||||
self.assertEqual(get_users_with_perms(tag).count(), 2)
|
||||
self.assertEqual(get_groups_with_perms(tag).count(), 1)
|
||||
return len(ctx.captured_queries)
|
||||
|
||||
small_batch_queries = run_with_n_tags(5)
|
||||
large_batch_queries = run_with_n_tags(50)
|
||||
|
||||
# A tolerance rather than equality, matching the N+1 check in
|
||||
# test_views.py: bulk_create's batch_size caps rows per INSERT, so a
|
||||
# large enough selection does legitimately add statements, and the
|
||||
# per-process ContentType cache makes the first run carry an extra
|
||||
# query. Neither can hide a regression to per-object assignment,
|
||||
# which would be ~10x the small-batch count here.
|
||||
self.assertLessEqual(
|
||||
large_batch_queries,
|
||||
small_batch_queries + 5,
|
||||
"Permission assignment appears to scale with object count: "
|
||||
f"{small_batch_queries} queries for 5 tags vs. "
|
||||
f"{large_batch_queries} for 50",
|
||||
)
|
||||
|
||||
def test_bulk_objects_delete_all_filtered(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -1947,29 +1947,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(len(response.data["documents"]), 1)
|
||||
self.assertEqual(response.data["documents"][0]["id"], title_match.id)
|
||||
|
||||
def test_global_search_returns_latest_version_content(self) -> None:
|
||||
root = Document.objects.create(
|
||||
title="bank statement",
|
||||
content="superseded content",
|
||||
checksum="GSV1",
|
||||
pk=23,
|
||||
)
|
||||
Document.objects.create(
|
||||
title="bank statement v2",
|
||||
content="latest content",
|
||||
checksum="GSV2",
|
||||
pk=24,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
response = self.client.get("/api/search/?query=bank&db_only=true")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
returned = {doc["id"]: doc["content"] for doc in response.data["documents"]}
|
||||
self.assertEqual(returned.get(root.id), "latest content")
|
||||
|
||||
def test_global_search_filters_owned_mail_objects(self) -> None:
|
||||
user1 = User.objects.create_user("mail-search-user")
|
||||
user2 = User.objects.create_user("other-mail-search-user")
|
||||
|
||||
@@ -207,65 +207,3 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("have not yet been deleted", resp.data["documents"][0])
|
||||
|
||||
def _make_versioned_document(self) -> tuple[Document, list[Document]]:
|
||||
root = Document.objects.create(
|
||||
title="root",
|
||||
content="root-content",
|
||||
checksum="root",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
versions = [
|
||||
Document.objects.create(
|
||||
title=f"v{index}",
|
||||
content=f"v{index}-content",
|
||||
checksum=f"v{index}",
|
||||
mime_type="application/pdf",
|
||||
root_document=root,
|
||||
version_index=index,
|
||||
)
|
||||
for index in range(1, 3)
|
||||
]
|
||||
return root, versions
|
||||
|
||||
def test_api_trash_restore_document_restores_its_versions(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing document with two versions
|
||||
WHEN:
|
||||
- API request to delete the document
|
||||
- API request to restore it from the trash
|
||||
THEN:
|
||||
- Only the document itself is listed in the trash
|
||||
- A version cannot be restored without its root
|
||||
- The document is restored together with all of its versions
|
||||
"""
|
||||
root, versions = self._make_versioned_document()
|
||||
|
||||
self.client.force_login(user=self.user)
|
||||
self.client.delete(f"/api/documents/{root.pk}/")
|
||||
self.assertEqual(Document.deleted_objects.count(), 3)
|
||||
|
||||
resp = self.client.get("/api/trash/")
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data["count"], 1)
|
||||
self.assertEqual(resp.data["results"][0]["id"], root.pk)
|
||||
|
||||
# A version cannot be restored while its root remains in the trash.
|
||||
resp = self.client.post(
|
||||
"/api/trash/",
|
||||
{"action": "restore", "documents": [versions[0].pk]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("Restore the root document", resp.data["documents"][0])
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/trash/",
|
||||
{"action": "restore", "documents": [root.pk]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(Document.deleted_objects.count(), 0)
|
||||
self.assertCountEqual(
|
||||
Document.objects.filter(root_document=root).values_list("id", flat=True),
|
||||
[version.pk for version in versions],
|
||||
)
|
||||
|
||||
@@ -5,11 +5,8 @@ from unittest import mock
|
||||
|
||||
import pikepdf
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from guardian.shortcuts import assign_perm
|
||||
from guardian.shortcuts import get_groups_with_perms
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
@@ -22,7 +19,6 @@ from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
|
||||
@@ -396,11 +392,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
|
||||
self.assertFalse(Document.objects.filter(id=version.id).exists())
|
||||
|
||||
Document.deleted_objects.get(id=self.doc1.id).restore(strict=False)
|
||||
|
||||
self.assertTrue(Document.objects.filter(id=self.doc1.id).exists())
|
||||
self.assertTrue(Document.objects.filter(id=version.id).exists())
|
||||
|
||||
def test_delete_version_document_keeps_root(self) -> None:
|
||||
version = Document.objects.create(
|
||||
checksum="A-v1",
|
||||
@@ -519,178 +510,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
)
|
||||
self.assertEqual(groups_with_perms.count(), 2)
|
||||
|
||||
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
||||
def test_set_permissions_batched_across_document_count(
|
||||
self,
|
||||
m,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Many documents are being bulk-edited to set permissions at once
|
||||
WHEN:
|
||||
- set_permissions runs over a small batch vs. a much larger one
|
||||
THEN:
|
||||
- Permissions are applied correctly at both scales
|
||||
- Query count does not grow with the number of documents, i.e.
|
||||
each user/group is applied across all documents with one
|
||||
batched call rather than one call per (document, identity)
|
||||
pair
|
||||
"""
|
||||
permissions = {
|
||||
"view": {
|
||||
"users": [self.user1.id, self.user2.id],
|
||||
"groups": [self.group2.id],
|
||||
},
|
||||
"change": {
|
||||
"users": [self.user1.id],
|
||||
"groups": [self.group2.id],
|
||||
},
|
||||
}
|
||||
|
||||
def run_with_n_documents(n: int) -> int:
|
||||
docs = [
|
||||
Document.objects.create(checksum=f"perm-{n}-{i}", title=f"perm-{n}-{i}")
|
||||
for i in range(n)
|
||||
]
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
bulk_edit.set_permissions(
|
||||
[doc.id for doc in docs],
|
||||
set_permissions=permissions,
|
||||
owner=self.owner,
|
||||
merge=False,
|
||||
)
|
||||
for doc in docs:
|
||||
self.assertEqual(get_users_with_perms(doc).count(), 2)
|
||||
self.assertEqual(get_groups_with_perms(doc).count(), 1)
|
||||
return len(ctx.captured_queries)
|
||||
|
||||
small_batch_queries = run_with_n_documents(5)
|
||||
large_batch_queries = run_with_n_documents(50)
|
||||
|
||||
# A tolerance rather than equality, matching the N+1 check in
|
||||
# test_views.py: bulk_create's batch_size caps rows per INSERT, so a
|
||||
# large enough selection does legitimately add statements, and the
|
||||
# per-process ContentType cache makes the first run carry an extra
|
||||
# query. Neither can hide a regression to per-document assignment,
|
||||
# which would be ~10x the small-batch count here.
|
||||
self.assertLessEqual(
|
||||
large_batch_queries,
|
||||
small_batch_queries + 5,
|
||||
"Permission assignment appears to scale with document count: "
|
||||
f"{small_batch_queries} queries for 5 documents vs. "
|
||||
f"{large_batch_queries} for 50",
|
||||
)
|
||||
|
||||
@mock.patch("documents.tasks.bulk_update_documents.apply_async")
|
||||
def test_set_permissions_grants_direct_perm_even_if_already_granted_via_group(
|
||||
self,
|
||||
m,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A user already has view access to a document via group
|
||||
membership, with no direct grant of their own
|
||||
WHEN:
|
||||
- set_permissions explicitly grants that same user direct view
|
||||
access via bulk_edit
|
||||
THEN:
|
||||
- A direct permission grant is created for the user, not skipped
|
||||
because they already have equivalent access via the group
|
||||
|
||||
Regression test: guardian's queryset-aware assign_perm() (routed to
|
||||
when the target is a list/queryset) skips creating a direct row for
|
||||
anyone whose ObjectPermissionChecker.has_perm() already returns True
|
||||
-- which includes group-derived access. The single-object assign_perm
|
||||
this bulk path replaces has no such check; it always ensures a
|
||||
direct row via get_or_create. Losing that guarantee would mean
|
||||
revoking the group's grant later silently strips access that was
|
||||
supposed to be explicit.
|
||||
"""
|
||||
self.doc1.owner = self.user1
|
||||
self.doc1.save()
|
||||
self.user1.groups.add(self.group1)
|
||||
assign_perm("view_document", self.group1, self.doc1)
|
||||
|
||||
bulk_edit.set_permissions(
|
||||
[self.doc1.id],
|
||||
set_permissions={
|
||||
"view": {"users": [self.user1.id], "groups": []},
|
||||
},
|
||||
merge=True,
|
||||
)
|
||||
|
||||
direct_users = get_users_with_perms(
|
||||
self.doc1,
|
||||
only_with_perms_in=["view_document"],
|
||||
with_group_users=False,
|
||||
)
|
||||
self.assertIn(self.user1, direct_users)
|
||||
|
||||
def test_set_permissions_for_objects_raises_for_unknown_action(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An unrecognized permission action name with users to grant it
|
||||
to
|
||||
WHEN:
|
||||
- set_permissions_for_objects is called
|
||||
THEN:
|
||||
- Permission.DoesNotExist is raised, not a silent no-op
|
||||
|
||||
Regression test: the endpoint that calls this
|
||||
(BulkEditObjectPermissionsView) never actually validates action
|
||||
names against the raw client-supplied permissions dict --
|
||||
BulkEditObjectsSerializer._validate_permissions calls
|
||||
validate_set_permissions() only for its side-effecting user/group id
|
||||
checks and discards the filtered dict it returns -- so a bogus
|
||||
action key reaches this function as-is. Resolving the Permission via
|
||||
a bare `.filter()` (which returns empty instead of raising) would
|
||||
silently drop the grant and report success.
|
||||
"""
|
||||
with self.assertRaises(Permission.DoesNotExist):
|
||||
set_permissions_for_objects(
|
||||
{"not_a_real_action": {"users": [self.user1.id], "groups": []}},
|
||||
Document,
|
||||
[self.doc1.pk],
|
||||
)
|
||||
|
||||
def test_set_permissions_for_objects_unknown_action_applies_nothing(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A permissions dict with a valid action ordered ahead of an
|
||||
unrecognized one
|
||||
WHEN:
|
||||
- set_permissions_for_objects is called
|
||||
THEN:
|
||||
- Permission.DoesNotExist is raised
|
||||
- The valid action ahead of it is not applied either
|
||||
|
||||
Every action is resolved before any row is written, so a bad action
|
||||
name cannot leave a half-applied change behind. That matters because
|
||||
BulkEditObjectsView turns this exception into a 400: without the
|
||||
up-front resolution the client would be told the request failed
|
||||
while the leading action had already been committed.
|
||||
"""
|
||||
with self.assertRaises(Permission.DoesNotExist):
|
||||
set_permissions_for_objects(
|
||||
{
|
||||
"view": {"users": [self.user1.id], "groups": []},
|
||||
"not_a_real_action": {"users": [self.user1.id], "groups": []},
|
||||
},
|
||||
Document,
|
||||
[self.doc1.pk],
|
||||
)
|
||||
|
||||
self.assertNotIn(
|
||||
self.user1,
|
||||
get_users_with_perms(
|
||||
self.doc1,
|
||||
only_with_perms_in=["view_document"],
|
||||
with_group_users=False,
|
||||
),
|
||||
)
|
||||
|
||||
@mock.patch("documents.models.Document.delete")
|
||||
def test_delete_documents_old_uuid_field(self, m) -> None:
|
||||
m.side_effect = Exception("Data too long for column 'transaction_id' at row 1")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
from django.core.checks import Error
|
||||
from django.core.checks import Warning
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from documents.checks import filename_format_check
|
||||
@@ -47,7 +47,7 @@ class TestFilenameFormatCheck:
|
||||
)
|
||||
def test_warns_on_old_style_format(
|
||||
self,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
filename_format: str,
|
||||
expected_hint: str,
|
||||
) -> None:
|
||||
|
||||
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
|
||||
checksum="checksum",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
version = Document.objects.create(
|
||||
Document.objects.create(
|
||||
root_document=root,
|
||||
correspondent=root.correspondent,
|
||||
title="Version",
|
||||
@@ -124,10 +124,6 @@ class TestDocument(TestCase):
|
||||
self.assertEqual(Document.objects.count(), 0)
|
||||
self.assertEqual(Document.deleted_objects.count(), 2)
|
||||
|
||||
root.restore(strict=False)
|
||||
|
||||
self.assertTrue(Document.objects.filter(pk=version.pk).exists())
|
||||
|
||||
def test_file_name(self) -> None:
|
||||
doc = Document(
|
||||
mime_type="application/pdf",
|
||||
|
||||
@@ -43,7 +43,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
@@ -136,23 +136,6 @@ def wait_for_mock_call(
|
||||
return False
|
||||
|
||||
|
||||
def sleep_past_stability(
|
||||
owner: FileStabilityTracker | ConsumerThread,
|
||||
*,
|
||||
windows: float = 1.5,
|
||||
) -> None:
|
||||
"""
|
||||
Block until a tracked file's stability window has certainly elapsed.
|
||||
|
||||
Args:
|
||||
owner: The tracker, or the consumer thread running one, whose
|
||||
configured stability delay sets the wait.
|
||||
windows: How many stability windows to wait, giving slop for a slow
|
||||
or loaded test runner.
|
||||
"""
|
||||
sleep(owner.stability_delay * windows)
|
||||
|
||||
|
||||
class TestTrackedFile:
|
||||
"""Tests for the TrackedFile dataclass."""
|
||||
|
||||
@@ -278,56 +261,6 @@ class TestFileStabilityTracker:
|
||||
assert len(stable) == 0
|
||||
assert stability_tracker.pending_count == 1
|
||||
|
||||
def test_get_stable_files_skips_empty_file(
|
||||
self,
|
||||
stability_tracker: FileStabilityTracker,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A zero byte file, tracked and past its stability delay
|
||||
WHEN:
|
||||
- Stable files are collected
|
||||
THEN:
|
||||
- The file is not yielded for consumption
|
||||
- The file is dropped from tracking rather than held, so an
|
||||
abandoned placeholder does not keep the watch loop awake
|
||||
"""
|
||||
empty = tmp_path / "scan.pdf"
|
||||
empty.write_bytes(b"")
|
||||
stability_tracker.track(empty, Change.added)
|
||||
sleep_past_stability(stability_tracker)
|
||||
|
||||
stable = list(stability_tracker.get_stable_files())
|
||||
|
||||
assert stable == []
|
||||
assert stability_tracker.pending_count == 0
|
||||
|
||||
def test_empty_file_is_yielded_once_content_arrives(
|
||||
self,
|
||||
stability_tracker: FileStabilityTracker,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A zero byte file which was dropped from tracking while empty
|
||||
WHEN:
|
||||
- The writer fills the file and a new event re-tracks it
|
||||
THEN:
|
||||
- The file is yielded for consumption once it is stable
|
||||
"""
|
||||
target = tmp_path / "scan.pdf"
|
||||
target.write_bytes(b"")
|
||||
stability_tracker.track(target, Change.added)
|
||||
sleep_past_stability(stability_tracker)
|
||||
assert list(stability_tracker.get_stable_files()) == []
|
||||
|
||||
target.write_bytes(b"%PDF-1.4 content")
|
||||
stability_tracker.track(target, Change.modified)
|
||||
sleep_past_stability(stability_tracker)
|
||||
|
||||
assert list(stability_tracker.get_stable_files()) == [target]
|
||||
|
||||
def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None:
|
||||
"""Test deleted file is not returned during stability check."""
|
||||
tracker = FileStabilityTracker(stability_delay=0.1)
|
||||
@@ -672,7 +605,7 @@ class TestCommandValidation:
|
||||
|
||||
def test_raises_for_missing_consumption_dir(
|
||||
self,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
"""Test command raises error when directory is not provided."""
|
||||
settings.CONSUMPTION_DIR = None
|
||||
@@ -706,7 +639,7 @@ class TestCommandOneshot:
|
||||
scratch_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
"""Test oneshot mode processes existing files."""
|
||||
target = consumption_dir / "document.pdf"
|
||||
@@ -726,7 +659,7 @@ class TestCommandOneshot:
|
||||
scratch_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
"""Test oneshot mode processes files recursively."""
|
||||
subdir = consumption_dir / "subdir"
|
||||
@@ -748,7 +681,7 @@ class TestCommandOneshot:
|
||||
consumption_dir: Path,
|
||||
scratch_dir: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
"""Test oneshot mode ignores unsupported file extensions."""
|
||||
target = consumption_dir / "document.xyz"
|
||||
@@ -946,51 +879,6 @@ class TestCommandWatch:
|
||||
|
||||
mock_consume_file_delay.apply_async.assert_called()
|
||||
|
||||
def test_scanner_placeholder_is_not_consumed_while_empty(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
start_consumer: Callable[..., ConsumerThread],
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A scanner which creates a zero byte placeholder and only writes
|
||||
the page some time later (GH discussion #13969)
|
||||
WHEN:
|
||||
- The placeholder sits untouched well past the stability delay
|
||||
- The scanner then writes the real content
|
||||
THEN:
|
||||
- The empty placeholder is never queued, as it could only fail
|
||||
with "Unsupported mime type inode/x-empty"
|
||||
- The file is queued exactly once, when the content lands
|
||||
"""
|
||||
thread = start_consumer(stability_delay=0.2)
|
||||
|
||||
target = consumption_dir / "scan.pdf"
|
||||
target.write_bytes(b"") # the scanner's placeholder
|
||||
|
||||
# Well past the stability delay: the old behaviour queued it here.
|
||||
sleep_past_stability(thread, windows=5)
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
assert mock_consume_file_delay.apply_async.call_count == 0
|
||||
|
||||
shutil.copy(sample_pdf, target) # the scanner finishes the page
|
||||
|
||||
assert wait_for_mock_call(
|
||||
mock_consume_file_delay.apply_async,
|
||||
timeout_s=5.0,
|
||||
)
|
||||
if thread.exception:
|
||||
raise thread.exception
|
||||
|
||||
assert mock_consume_file_delay.apply_async.call_count == 1
|
||||
queued_doc = mock_consume_file_delay.apply_async.call_args.kwargs["kwargs"][
|
||||
"input_doc"
|
||||
]
|
||||
assert queued_doc.original_file.name == "scan.pdf"
|
||||
|
||||
def test_ignores_macos_files(
|
||||
self,
|
||||
consumption_dir: Path,
|
||||
@@ -1368,7 +1256,7 @@ class TestProcessExistingFilesQueued:
|
||||
consumption_dir: Path,
|
||||
sample_pdf: Path,
|
||||
mock_consume_file_delay: MagicMock,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
) -> None:
|
||||
"""The set returned seeds the rescan's queued set, avoiding re-queue."""
|
||||
target = consumption_dir / "document.pdf"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
from documents.parsers import get_default_file_extension
|
||||
from documents.parsers import get_supported_file_extensions
|
||||
@@ -14,7 +14,7 @@ from paperless.parsers.tika import TikaDocumentParser
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _tika_registry(settings: Settings) -> Generator[None, None, None]:
|
||||
def _tika_registry(settings: SettingsWrapper) -> Generator[None, None, None]:
|
||||
"""
|
||||
Rebuild the parser registry with Tika enabled for the duration of the
|
||||
test, then reset on exit so other tests see the default (Tika-disabled)
|
||||
|
||||
@@ -32,7 +32,6 @@ from documents.signals.handlers import update_llm_suggestions_cache
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from documents.tests.utils import read_streaming_response
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless_ai.exceptions import LLMProviderError
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
|
||||
@@ -738,38 +737,6 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
LLM_BACKEND="openai-like",
|
||||
)
|
||||
def test_ai_suggestions_with_llm_provider_error(
|
||||
self,
|
||||
mock_get_ai_classification,
|
||||
) -> None:
|
||||
mock_get_ai_classification.side_effect = LLMProviderError(
|
||||
"confidential provider response",
|
||||
)
|
||||
|
||||
self.client.force_login(user=self.user)
|
||||
response = self.client.get(
|
||||
f"/api/documents/{self.document.pk}/ai_suggestions/",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"ai": [
|
||||
"AI backend rejected the request. Check logs for details.",
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertNotIn("confidential provider response", response.content.decode())
|
||||
self.assertIsNone(
|
||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||
)
|
||||
|
||||
@patch("documents.views.get_ai_document_classification")
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
|
||||
@@ -23,7 +23,6 @@ from guardian.shortcuts import get_users_with_perms
|
||||
from httpx import ConnectError
|
||||
from httpx import HTTPError
|
||||
from httpx import HTTPStatusError
|
||||
from pytest_django.fixtures import Settings
|
||||
from pytest_httpx import HTTPXMock
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -39,6 +38,7 @@ from paperless_ai.exceptions import LLMTimeoutError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
from documents import tasks
|
||||
from documents.data_models import ConsumableDocument
|
||||
@@ -5356,7 +5356,7 @@ class TestDateWorkflowLocalization(
|
||||
def test_document_consumption_workflow_localization(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
settings: Settings,
|
||||
settings: SettingsWrapper,
|
||||
title_template: str,
|
||||
expected_title: str,
|
||||
) -> None:
|
||||
@@ -5711,39 +5711,6 @@ class TestApplyAISuggestionsWorkflowAction(
|
||||
self.assertEqual(changed, [])
|
||||
self.assertIn("AI is not enabled", "".join(cm.output))
|
||||
|
||||
def test_document_without_content_does_nothing(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose OCR content is empty or whitespace-only
|
||||
WHEN:
|
||||
- AI suggestions are applied by a workflow
|
||||
THEN:
|
||||
- The classifier is not called and the document is left unchanged
|
||||
"""
|
||||
action = self.make_action(ai_overwrite_existing=True)
|
||||
|
||||
for content in ("", " \n\t"):
|
||||
with self.subTest(content=content):
|
||||
self.doc.content = content
|
||||
self.doc.save(update_fields=["content"])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"documents.workflows.ai.get_ai_document_classification",
|
||||
) as get_classification,
|
||||
self.assertLogs(
|
||||
"paperless.workflows.ai",
|
||||
level="WARNING",
|
||||
) as cm,
|
||||
):
|
||||
changed = apply_ai_suggestions_to_document(action, self.doc)
|
||||
|
||||
self.assertEqual(changed, [])
|
||||
get_classification.assert_not_called()
|
||||
self.assertIn("has no content", "".join(cm.output))
|
||||
self.doc.refresh_from_db()
|
||||
self.assertEqual(self.doc.title, "original.pdf")
|
||||
|
||||
def test_invalid_configuration_leaves_document_untouched(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -27,13 +27,10 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||
|
||||
def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||
"""
|
||||
Annotates documents with the content of their newest version unless the
|
||||
queryset already carries the annotation, falling back to their own, so
|
||||
get_effective_content() can answer from the row rather than querying for
|
||||
the versions of each document.
|
||||
Annotates documents with the content of their newest version, falling back
|
||||
to their own, so get_effective_content() can answer from the row rather
|
||||
than querying for the versions of each document
|
||||
"""
|
||||
if "effective_content" in documents.query.annotations:
|
||||
return documents
|
||||
return documents.annotate(
|
||||
effective_content=Coalesce(
|
||||
Subquery(
|
||||
|
||||
@@ -179,7 +179,7 @@ from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import has_system_status_permission
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
from documents.permissions import set_permissions_for_objects
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.permissions import user_is_unrestricted
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
from documents.schema import generate_object_with_permissions_schema
|
||||
@@ -232,7 +232,6 @@ from documents.tasks import train_classifier
|
||||
from documents.tasks import update_document_parent_tags
|
||||
from documents.utils import get_boolean
|
||||
from documents.versioning import VersionResolutionError
|
||||
from documents.versioning import annotate_effective_content
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_request_version_param
|
||||
from documents.versioning import get_root_document
|
||||
@@ -252,7 +251,6 @@ from paperless.views import StandardPagination
|
||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||
from paperless_ai.ai_classifier import get_llm_output_language
|
||||
from paperless_ai.chat import stream_chat_with_documents
|
||||
from paperless_ai.exceptions import LLMProviderError
|
||||
from paperless_ai.exceptions import LLMTimeoutError
|
||||
from paperless_ai.matching import extract_unmatched_names
|
||||
from paperless_ai.matching import match_correspondents_by_name
|
||||
@@ -1604,22 +1602,6 @@ class DocumentViewSet(
|
||||
{"ai": [_("AI backend request timed out.")]},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
except LLMProviderError:
|
||||
logger.exception(
|
||||
"AI backend rejected the request for document %s",
|
||||
doc.pk,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"ai": [
|
||||
_(
|
||||
"AI backend rejected the request. "
|
||||
"Check logs for details.",
|
||||
),
|
||||
],
|
||||
},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
set_llm_suggestions_cache(
|
||||
doc.pk,
|
||||
llm_suggestions,
|
||||
@@ -1906,7 +1888,14 @@ class DocumentViewSet(
|
||||
now = timezone.now()
|
||||
links = (
|
||||
ShareLink.objects.filter(document=doc)
|
||||
.only("pk", "created", "expiration", "slug")
|
||||
.select_related("document")
|
||||
.only(
|
||||
"pk",
|
||||
"created",
|
||||
"expiration",
|
||||
"slug",
|
||||
"document__title",
|
||||
)
|
||||
.exclude(expiration__lt=now)
|
||||
.order_by("-created")
|
||||
)
|
||||
@@ -2109,7 +2098,6 @@ class DocumentViewSet(
|
||||
if version_label:
|
||||
overrides.version_label = version_label.strip()
|
||||
if request.user is not None:
|
||||
overrides.owner_id = request.user.id
|
||||
overrides.actor_id = request.user.id
|
||||
|
||||
async_task = consume_file.apply_async(
|
||||
@@ -2834,7 +2822,6 @@ class DocumentSelectionMixin:
|
||||
filtered_documents = DocumentFilterSet(
|
||||
data=orm_filters,
|
||||
queryset=permitted_documents,
|
||||
user=user,
|
||||
).qs.distinct()
|
||||
# tantivy-filtered docs (if search params provided)
|
||||
search_filtered_ids = self._get_search_document_ids(
|
||||
@@ -3650,13 +3637,8 @@ class GlobalSearchView(PassUserMixin):
|
||||
OBJECT_LIMIT = 3
|
||||
docs = []
|
||||
if request.user.has_perm("documents.view_document"):
|
||||
# Never more than OBJECT_LIMIT rows come back here, so annotating
|
||||
# is cheap -- and without it these results show the root
|
||||
# document's superseded content.
|
||||
all_docs = annotate_effective_content(
|
||||
Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
),
|
||||
all_docs = Document.objects.filter(
|
||||
id__in=permitted_document_ids(request.user),
|
||||
)
|
||||
if db_only:
|
||||
docs = all_docs.filter(title__icontains=query)[:OBJECT_LIMIT]
|
||||
@@ -4578,7 +4560,7 @@ class ShareLinkViewSet(
|
||||
):
|
||||
model = ShareLink
|
||||
|
||||
queryset = ShareLink.objects.all()
|
||||
queryset = ShareLink.objects.select_related("document")
|
||||
|
||||
serializer_class = ShareLinkSerializer
|
||||
pagination_class = StandardPagination
|
||||
@@ -4589,7 +4571,7 @@ class ShareLinkViewSet(
|
||||
PermittedObjectsFilter,
|
||||
)
|
||||
filterset_class = ShareLinkFilterSet
|
||||
ordering_fields = ("created", "expiration", "document")
|
||||
ordering_fields = ("created", "expiration", "document__title")
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -4967,12 +4949,12 @@ class BulkEditObjectsView(PassUserMixin):
|
||||
qs_owner_update.update(owner=owner)
|
||||
|
||||
if "permissions" in serializer.validated_data:
|
||||
set_permissions_for_objects(
|
||||
permissions=permissions,
|
||||
model=object_class,
|
||||
pks=qs.values_list("pk", flat=True),
|
||||
merge=merge,
|
||||
)
|
||||
for obj in qs:
|
||||
set_permissions_for_object(
|
||||
permissions=permissions,
|
||||
object=obj,
|
||||
merge=merge,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -5454,10 +5436,7 @@ class TrashView(ListModelMixin, PassUserMixin):
|
||||
|
||||
model = Document
|
||||
|
||||
# A version is listed separately only when its root is not in the trash.
|
||||
queryset = Document.deleted_objects.exclude(
|
||||
root_document_id__in=Document.deleted_objects.values("id"),
|
||||
)
|
||||
queryset = Document.deleted_objects.all()
|
||||
|
||||
def get(self, request: Request, format: str | None = None) -> Response:
|
||||
self.serializer_class = DocumentSerializer
|
||||
@@ -5488,22 +5467,15 @@ class TrashView(ListModelMixin, PassUserMixin):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
action = serializer.validated_data.get("action")
|
||||
if action == "restore":
|
||||
restored = list(self.get_queryset().filter(id__in=doc_ids))
|
||||
if len(restored) != len(doc_ids):
|
||||
raise ValidationError(
|
||||
{
|
||||
"documents": [
|
||||
"Restore the root document instead of one of its versions.",
|
||||
],
|
||||
},
|
||||
)
|
||||
restored = list(Document.deleted_objects.filter(id__in=doc_ids))
|
||||
for doc in restored:
|
||||
doc.restore(strict=False)
|
||||
if restored:
|
||||
from documents.search import get_backend
|
||||
|
||||
with get_backend().batch_update() as batch:
|
||||
batch.add_or_update_ids([doc.pk for doc in restored])
|
||||
for doc in restored:
|
||||
batch.add_or_update(doc)
|
||||
elif action == "empty":
|
||||
if doc_ids is None:
|
||||
doc_ids = [doc.id for doc in docs]
|
||||
|
||||
@@ -138,16 +138,6 @@ def apply_ai_suggestions_to_document(
|
||||
)
|
||||
return []
|
||||
|
||||
if not document.content.strip():
|
||||
logger.warning(
|
||||
"Document %s has no content, skipping AI suggestions for workflow "
|
||||
"action %s",
|
||||
document.pk,
|
||||
action.pk,
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
return []
|
||||
|
||||
# Workflows run without a user, so we use the document owner
|
||||
owner = document.owner
|
||||
|
||||
|
||||
@@ -133,27 +133,21 @@ class BarcodeConfig(BaseConfig):
|
||||
app_config = self._get_config_instance()
|
||||
|
||||
self.barcodes_enabled = (
|
||||
app_config.barcodes_enabled
|
||||
if app_config.barcodes_enabled is not None
|
||||
else settings.CONSUMER_ENABLE_BARCODES
|
||||
app_config.barcodes_enabled or settings.CONSUMER_ENABLE_BARCODES
|
||||
)
|
||||
self.barcode_enable_tiff_support = (
|
||||
app_config.barcode_enable_tiff_support
|
||||
if app_config.barcode_enable_tiff_support is not None
|
||||
else settings.CONSUMER_BARCODE_TIFF_SUPPORT
|
||||
or settings.CONSUMER_BARCODE_TIFF_SUPPORT
|
||||
)
|
||||
self.barcode_string = (
|
||||
app_config.barcode_string or settings.CONSUMER_BARCODE_STRING
|
||||
)
|
||||
self.barcode_retain_split_pages = (
|
||||
app_config.barcode_retain_split_pages
|
||||
if app_config.barcode_retain_split_pages is not None
|
||||
else settings.CONSUMER_BARCODE_RETAIN_SPLIT_PAGES
|
||||
or settings.CONSUMER_BARCODE_RETAIN_SPLIT_PAGES
|
||||
)
|
||||
self.barcode_enable_asn = (
|
||||
app_config.barcode_enable_asn
|
||||
if app_config.barcode_enable_asn is not None
|
||||
else settings.CONSUMER_ENABLE_ASN_BARCODE
|
||||
app_config.barcode_enable_asn or settings.CONSUMER_ENABLE_ASN_BARCODE
|
||||
)
|
||||
self.barcode_asn_prefix = (
|
||||
app_config.barcode_asn_prefix or settings.CONSUMER_ASN_BARCODE_PREFIX
|
||||
@@ -166,17 +160,13 @@ class BarcodeConfig(BaseConfig):
|
||||
app_config.barcode_max_pages or settings.CONSUMER_BARCODE_MAX_PAGES
|
||||
)
|
||||
self.barcode_enable_tag = (
|
||||
app_config.barcode_enable_tag
|
||||
if app_config.barcode_enable_tag is not None
|
||||
else settings.CONSUMER_ENABLE_TAG_BARCODE
|
||||
app_config.barcode_enable_tag or settings.CONSUMER_ENABLE_TAG_BARCODE
|
||||
)
|
||||
self.barcode_tag_mapping = (
|
||||
app_config.barcode_tag_mapping or settings.CONSUMER_TAG_BARCODE_MAPPING
|
||||
)
|
||||
self.barcode_tag_split = (
|
||||
app_config.barcode_tag_split
|
||||
if app_config.barcode_tag_split is not None
|
||||
else settings.CONSUMER_TAG_BARCODE_SPLIT
|
||||
app_config.barcode_tag_split or settings.CONSUMER_TAG_BARCODE_SPLIT
|
||||
)
|
||||
|
||||
|
||||
@@ -258,11 +248,7 @@ class AIConfig(BaseConfig):
|
||||
def __post_init__(self) -> None:
|
||||
app_config = self._get_config_instance()
|
||||
|
||||
self.ai_enabled = (
|
||||
app_config.ai_enabled
|
||||
if app_config.ai_enabled is not None
|
||||
else settings.AI_ENABLED
|
||||
)
|
||||
self.ai_enabled = app_config.ai_enabled or settings.AI_ENABLED
|
||||
self.llm_embedding_backend = (
|
||||
app_config.llm_embedding_backend or settings.LLM_EMBEDDING_BACKEND
|
||||
)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
def normalize_ai_enabled(apps, schema_editor):
|
||||
application_configuration = apps.get_model(
|
||||
"paperless",
|
||||
"ApplicationConfiguration",
|
||||
)
|
||||
application_configuration.objects.filter(ai_enabled=False).update(ai_enabled=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless", "0015_applicationconfiguration_remote_ocr_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="applicationconfiguration",
|
||||
name="ai_enabled",
|
||||
field=models.BooleanField(
|
||||
null=True,
|
||||
verbose_name="Enables AI features",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(normalize_ai_enabled, migrations.RunPython.noop),
|
||||
]
|
||||