Compare commits

..

24 Commits

Author SHA1 Message Date
shamoon
636a6636dd Fix scheduled tasks tests 2025-11-05 20:52:45 -08:00
shamoon
85188c505a Frontend coverage for bulk editor changes, sharelink bundle service 2025-11-05 20:29:09 -08:00
shamoon
c7e57361f8 Frontend tests 2025-11-05 20:07:50 -08:00
shamoon
cc966a8c6e Backend tests 2025-11-05 20:02:19 -08:00
shamoon
45c41b7852 Docs 2025-11-05 17:55:06 -08:00
shamoon
20c9f94bd2 Dialog tweaks 2025-11-05 17:29:08 -08:00
shamoon
45546a7616 Consistent naming to Share Link Bundle 2025-11-05 17:24:23 -08:00
shamoon
2cb05020ed Nice badge 2025-11-05 14:00:06 -08:00
shamoon
18687d674a Use titles not IDs 2025-11-05 13:58:28 -08:00
shamoon
940de4cc0b Remove standalone 2025-11-05 13:48:46 -08:00
shamoon
dde0259b5f Trim slug in zip name 2025-11-05 13:35:59 -08:00
shamoon
3e2e43f259 Better explanation 2025-11-05 13:28:08 -08:00
shamoon
ca097cb224 Cleanup expired task 2025-11-05 13:21:53 -08:00
shamoon
9dc82ca0a6 Unify labels 2025-11-05 08:47:52 -08:00
shamoon
aceefc7601 Manage dialog polling 2025-11-04 21:25:15 -08:00
shamoon
6358b05dc8 Initial result display in create dialog 2025-11-04 21:12:57 -08:00
shamoon
dcaa039ece Initial task for building 2025-11-04 20:55:12 -08:00
shamoon
d7f183792d Basic wiring of existing bundles 2025-11-04 18:57:33 -08:00
shamoon
88f7dc30cf Backend initial stuff 2025-11-04 10:59:02 -08:00
shamoon
4ff69d4cf1 Random cleanup stuff 2025-11-04 10:29:19 -08:00
shamoon
a6571075b3 Skeleton bundle component some more 2025-11-04 10:25:57 -08:00
shamoon
c53e3abed8 Generic warning 2025-11-04 09:43:18 -08:00
shamoon
5d3ea28547 Skeleton share bundle component 2025-11-04 09:33:19 -08:00
shamoon
587f324b1b Add send menu to bulk editor 2025-11-04 09:22:57 -08:00
31 changed files with 2369 additions and 28 deletions

View File

@@ -1593,6 +1593,16 @@ processing. This only has an effect if
Defaults to `0 1 * * *`, once per day.
## Share links
#### [`PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON=<cron expression>`](#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON) {#PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON}
: Controls how often Paperless-ngx removes expired share link bundles (and their generated ZIP archives).
: If set to the string "disable", expired bundles are not cleaned up automatically.
Defaults to `0 2 * * *`, once per day at 02:00.
## Binaries
There are a few external software packages that Paperless expects to

View File

@@ -286,12 +286,14 @@ or using [email](#workflow-action-email) or [webhook](#workflow-action-webhook)
### Share Links
"Share links" are shareable public links to files and can be created and managed under the 'Send' button on the document detail screen.
"Share links" are public links to files (or an archive of files) and can be created and managed under the 'Send' button on the document detail screen or from the bulk editor.
- Share links do not require a user to login and thus link directly to a file.
- Share links do not require a user to login and thus link directly to a file or bundled download.
- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`.
- Links can optionally have an expiration time set.
- After a link expires or is deleted users will be redirected to the regular paperless-ngx login.
- From the document detail screen you can create a share link for that single document.
- From the bulk editor you can create a **share link bundle** for any selection. Paperless-ngx prepares a ZIP archive in the background and exposes a single share link. You can revisit the "Manage share link bundles" dialog to monitor progress, retry failed bundles, or delete links.
!!! tip

View File

@@ -210,7 +210,6 @@
<pngx-input-tags
[allowCreate]="false"
[title]="null"
[autoHeirarchy]="false"
formControlName="values"
></pngx-input-tags>
} @else if (

View File

@@ -103,9 +103,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input()
multiple: boolean = true
@Input()
autoHeirarchy: boolean = true
@Output()
filterDocuments = new EventEmitter<Tag[]>()
@@ -137,7 +134,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
oldValue.splice(index, 1)
// remove children
if (this.autoHeirarchy) oldValue = this.removeChildren(oldValue, tag)
oldValue = this.removeChildren(oldValue, tag)
this.value = [...oldValue]
this.onChange(this.value)
@@ -156,7 +153,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
}
public onAdd(tag: Tag) {
if (this.autoHeirarchy && tag.parent) {
if (tag.parent) {
// add all parents recursively
const parent = this.getTag(tag.parent)
this.value = [...this.value, parent.id]

View File

@@ -0,0 +1,128 @@
<div class="modal-header">
<h4 class="modal-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
</div>
<div class="modal-body">
@if (!createdBundle) {
<form [formGroup]="form" class="d-flex flex-column gap-3">
<div>
<p class="mb-1">
<ng-container i18n>Selected documents:</ng-container>
{{ selectionCount }}
</p>
@if (documentPreview.length > 0) {
<ul class="list-unstyled small mb-0">
@for (doc of documentPreview; track doc.id) {
<li>
<strong>{{ doc.title | documentTitle }}</strong>
</li>
}
@if (selectionCount > documentPreview.length) {
<li>
<ng-container i18n>+ {{ selectionCount - documentPreview.length }} more…</ng-container>
</li>
}
</ul>
}
</div>
<div class="d-flex align-items-center justify-content-between">
<div class="input-group">
<label class="input-group-text" for="expirationDays"><ng-container i18n>Expires</ng-container>:</label>
<select class="form-select" id="expirationDays" formControlName="expirationDays">
@for (option of expirationOptions; track option.value) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>
</div>
<div class="form-check form-switch w-100 ms-3">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="shareArchiveSwitch"
formControlName="shareArchiveVersion"
/>
<label class="form-check-label" for="shareArchiveSwitch" i18n>Share archive version (if available)</label>
</div>
</div>
</form>
} @else {
<div class="d-flex flex-column gap-3">
<div class="alert alert-success mb-0" role="status">
<h6 class="alert-heading mb-1" i18n>Share link bundle requested</h6>
<p class="mb-0 small" i18n>
You can copy the share link below or open the manager to monitor progress. The link will start working once the bundle is ready.
</p>
</div>
<dl class="row mb-0 small">
<dt class="col-sm-4" i18n>Status</dt>
<dd class="col-sm-8">
<span class="badge text-bg-secondary text-uppercase">{{ statusLabel(createdBundle.status) }}</span>
</dd>
<dt class="col-sm-4" i18n>Slug</dt>
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
<dt class="col-sm-4" i18n>Link</dt>
<dd class="col-sm-8">
<div class="input-group input-group-sm">
<input class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
<button
class="btn btn-outline-primary"
type="button"
(click)="copy(createdBundle)"
>
@if (copied) {
<i-bs name="clipboard-check"></i-bs>
}
@if (!copied) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy link</span>
</button>
</div>
</dd>
<dt class="col-sm-4" i18n>Documents</dt>
<dd class="col-sm-8">{{ createdBundle.document_count }}</dd>
<dt class="col-sm-4" i18n>Expires</dt>
<dd class="col-sm-8">
@if (createdBundle.expiration) {
{{ createdBundle.expiration | date: 'short' }}
}
@if (!createdBundle.expiration) {
<span i18n>Never</span>
}
</dd>
<dt class="col-sm-4" i18n>File version</dt>
<dd class="col-sm-8">{{ fileVersionLabel(createdBundle.file_version) }}</dd>
@if (createdBundle.size_bytes !== undefined && createdBundle.size_bytes !== null) {
<dt class="col-sm-4" i18n>Size</dt>
<dd class="col-sm-8">{{ createdBundle.size_bytes | fileSize }}</dd>
}
</dl>
</div>
}
</div>
<div class="modal-footer">
<div class="d-flex align-items-center gap-2 w-100">
<div class="text-light fst-italic small">
<ng-container i18n>A zip file containing the selected documents will be created for this share link bundle. This process happens in the background and may take some time, especially for large bundles.</ng-container>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm ms-auto" (click)="cancel()">{{ cancelBtnCaption }}</button>
@if (createdBundle) {
<button type="button" class="btn btn-outline-secondary btn-sm text-nowrap" (click)="openManage()" i18n>Manage share link bundles</button>
}
@if (!createdBundle) {
<button
type="button"
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
(click)="submit()"
[disabled]="loading || !buttonsEnabled">
@if (loading) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
}
{{ btnCaption }}
</button>
}
</div>
</div>

View File

@@ -0,0 +1,149 @@
import { Clipboard } from '@angular/cdk/clipboard'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { FileVersion } from 'src/app/data/share-link'
import {
ShareLinkBundleStatus,
ShareLinkBundleSummary,
} from 'src/app/data/share-link-bundle'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ShareLinkBundleDialogComponent } from './share-link-bundle-dialog.component'
class MockToastService {
showInfo = jest.fn()
showError = jest.fn()
}
describe('ShareLinkBundleDialogComponent', () => {
let component: ShareLinkBundleDialogComponent
let fixture: ComponentFixture<ShareLinkBundleDialogComponent>
let clipboard: Clipboard
let toastService: MockToastService
let activeModal: NgbActiveModal
let originalApiBaseUrl: string
beforeEach(() => {
originalApiBaseUrl = environment.apiBaseUrl
toastService = new MockToastService()
TestBed.configureTestingModule({
imports: [
ShareLinkBundleDialogComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
NgbActiveModal,
{ provide: ToastService, useValue: toastService },
],
})
fixture = TestBed.createComponent(ShareLinkBundleDialogComponent)
component = fixture.componentInstance
clipboard = TestBed.inject(Clipboard)
activeModal = TestBed.inject(NgbActiveModal)
fixture.detectChanges()
})
afterEach(() => {
jest.clearAllTimers()
environment.apiBaseUrl = originalApiBaseUrl
})
it('builds payload and emits confirm on submit', () => {
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
component.documents = [
{ id: 1, title: 'Doc 1' } as any,
{ id: 2, title: 'Doc 2' } as any,
]
component.form.setValue({
shareArchiveVersion: false,
expirationDays: 3,
})
component.submit()
expect(component.payload).toEqual({
document_ids: [1, 2],
file_version: FileVersion.Original,
expiration_days: 3,
})
expect(component.buttonsEnabled).toBe(false)
expect(confirmSpy).toHaveBeenCalled()
})
it('ignores submit when bundle already created', () => {
component.createdBundle = { id: 1 } as ShareLinkBundleSummary
const confirmSpy = jest.spyOn(component, 'confirm')
component.submit()
expect(confirmSpy).not.toHaveBeenCalled()
})
it('limits preview to ten documents', () => {
const docs = Array.from({ length: 12 }).map((_, index) => ({
id: index + 1,
}))
component.documents = docs as any
expect(component.selectionCount).toBe(12)
expect(component.documentPreview).toHaveLength(10)
expect(component.documentPreview[0].id).toBe(1)
})
it('copies share link and resets state after timeout', fakeAsync(() => {
const copySpy = jest.spyOn(clipboard, 'copy').mockReturnValue(true)
const bundle = {
slug: 'bundle-slug',
status: ShareLinkBundleStatus.Ready,
} as ShareLinkBundleSummary
component.copy(bundle)
expect(copySpy).toHaveBeenCalledWith(component.getShareUrl(bundle))
expect(component.copied).toBe(true)
expect(toastService.showInfo).toHaveBeenCalled()
tick(3000)
expect(component.copied).toBe(false)
}))
it('generates share URLs based on API base URL', () => {
environment.apiBaseUrl = 'https://example.com/api/'
expect(
component.getShareUrl({ slug: 'abc' } as ShareLinkBundleSummary)
).toBe('https://example.com/share/abc')
})
it('opens manage dialog when callback provided', () => {
const manageSpy = jest.fn()
component.onOpenManage = manageSpy
component.openManage()
expect(manageSpy).toHaveBeenCalled()
})
it('falls back to cancel when manage callback missing', () => {
const cancelSpy = jest.spyOn(component, 'cancel')
component.onOpenManage = undefined
component.openManage()
expect(cancelSpy).toHaveBeenCalled()
})
it('maps status and file version labels', () => {
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
'Processing'
)
expect(component.fileVersionLabel(FileVersion.Archive)).toContain('Archive')
})
it('closes dialog when cancel invoked', () => {
const closeSpy = jest.spyOn(activeModal, 'close')
component.cancel()
expect(closeSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,118 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common'
import { Component, Input, inject } from '@angular/core'
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Document } from 'src/app/data/document'
import {
FileVersion,
SHARE_LINK_EXPIRATION_OPTIONS,
} from 'src/app/data/share-link'
import {
SHARE_LINK_BUNDLE_FILE_VERSION_LABELS,
SHARE_LINK_BUNDLE_STATUS_LABELS,
ShareLinkBundleCreatePayload,
ShareLinkBundleStatus,
ShareLinkBundleSummary,
} from 'src/app/data/share-link-bundle'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
@Component({
selector: 'pngx-share-link-bundle-dialog',
templateUrl: './share-link-bundle-dialog.component.html',
imports: [
CommonModule,
ReactiveFormsModule,
NgxBootstrapIconsModule,
FileSizePipe,
DocumentTitlePipe,
],
providers: [],
})
export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
private formBuilder = inject(FormBuilder)
private clipboard = inject(Clipboard)
private toastService = inject(ToastService)
private _documents: Document[] = []
selectionCount = 0
documentPreview: Document[] = []
form: FormGroup = this.formBuilder.group({
shareArchiveVersion: [true],
expirationDays: [7],
})
payload: ShareLinkBundleCreatePayload | null = null
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
createdBundle: ShareLinkBundleSummary | null = null
copied = false
onOpenManage?: () => void
readonly statuses = ShareLinkBundleStatus
constructor() {
super()
this.loading = false
this.title = $localize`Create share link bundle`
this.btnCaption = $localize`Create link`
}
@Input()
set documents(docs: Document[]) {
this._documents = docs.concat()
this.selectionCount = this._documents.length
this.documentPreview = this._documents.slice(0, 10)
}
submit() {
if (this.createdBundle) return
this.payload = {
document_ids: this._documents.map((doc) => doc.id),
file_version: this.form.value.shareArchiveVersion
? FileVersion.Archive
: FileVersion.Original,
expiration_days: this.form.value.expirationDays,
}
this.buttonsEnabled = false
super.confirm()
}
getShareUrl(bundle: ShareLinkBundleSummary): string {
const apiURL = new URL(environment.apiBaseUrl)
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
bundle.slug
}`
}
copy(bundle: ShareLinkBundleSummary): void {
const success = this.clipboard.copy(this.getShareUrl(bundle))
if (success) {
this.copied = true
this.toastService.showInfo($localize`Share link copied to clipboard.`)
setTimeout(() => {
this.copied = false
}, 3000)
}
}
openManage(): void {
if (this.onOpenManage) {
this.onOpenManage()
} else {
this.cancel()
}
}
statusLabel(status: ShareLinkBundleSummary['status']): string {
return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
}
fileVersionLabel(version: FileVersion): string {
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
}
}

View File

@@ -0,0 +1,130 @@
<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>
}
@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">
<thead>
<tr>
<th scope="col" i18n>Created</th>
<th scope="col" i18n>Status</th>
<th scope="col" i18n>Size</th>
<th scope="col" 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>
</tr>
</thead>
<tbody>
@for (bundle of bundles; track bundle.id) {
<tr>
<td>
<div>{{ bundle.created | date: 'short' }}</div>
@if (bundle.built_at) {
<div class="small text-muted">
<ng-container i18n>Built:</ng-container> {{ bundle.built_at | date: 'short' }}
</div>
}
</td>
<td>
<div class="d-flex align-items-center gap-2">
@if (bundle.status === statuses.Processing || bundle.status === statuses.Pending) {
<span class="spinner-border spinner-border-sm" role="status"></span>
}
<span class="badge text-bg-secondary text-uppercase">{{ statusLabel(bundle.status) }}</span>
</div>
@if (bundle.last_error && bundle.status === statuses.Failed) {
<div class="small text-danger mt-1">{{ bundle.last_error }}</div>
}
</td>
<td>
@if (bundle.size_bytes !== undefined && bundle.size_bytes !== null) {
{{ bundle.size_bytes | fileSize }}
}
@if (bundle.size_bytes === undefined || bundle.size_bytes === null) {
<span class="text-muted">&mdash;</span>
}
</td>
<td>
@if (bundle.expiration) {
{{ bundle.expiration | date: 'short' }}
}
@if (!bundle.expiration) {
<span i18n>Never</span>
}
</td>
<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)"
>
@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) {
<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>
}
<button
type="button"
class="btn btn-outline-danger"
[disabled]="loading"
(click)="delete(bundle)"
>
<i-bs name="trash"></i-bs>
<span class="visually-hidden" i18n>Delete share link bundle</span>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
</div>

View File

@@ -0,0 +1,250 @@
import { Clipboard } from '@angular/cdk/clipboard'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} 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'
import {
ShareLinkBundleStatus,
ShareLinkBundleSummary,
} from 'src/app/data/share-link-bundle'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
class MockShareLinkBundleService {
listAllBundles = jest.fn()
delete = jest.fn()
rebuildBundle = jest.fn()
}
class MockToastService {
showInfo = jest.fn()
showError = jest.fn()
}
describe('ShareLinkBundleManageDialogComponent', () => {
let component: ShareLinkBundleManageDialogComponent
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
let service: MockShareLinkBundleService
let toastService: MockToastService
let clipboard: Clipboard
let activeModal: NgbActiveModal
let originalApiBaseUrl: string
beforeEach(() => {
service = new MockShareLinkBundleService()
toastService = new MockToastService()
originalApiBaseUrl = environment.apiBaseUrl
service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(of(true))
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
TestBed.configureTestingModule({
imports: [
ShareLinkBundleManageDialogComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
NgbActiveModal,
{ provide: ShareLinkBundleService, useValue: service },
{ provide: ToastService, useValue: toastService },
],
})
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
component = fixture.componentInstance
clipboard = TestBed.inject(Clipboard)
activeModal = TestBed.inject(NgbActiveModal)
})
afterEach(() => {
component.ngOnDestroy()
fixture.destroy()
environment.apiBaseUrl = originalApiBaseUrl
jest.clearAllMocks()
})
const sampleBundle = (overrides: Partial<ShareLinkBundleSummary> = {}) =>
({
id: 1,
slug: 'bundle-slug',
created: new Date().toISOString(),
document_count: 1,
documents: [1],
status: ShareLinkBundleStatus.Pending,
file_version: FileVersion.Archive,
...overrides,
}) as ShareLinkBundleSummary
it('loads bundles on init and polls periodically', fakeAsync(() => {
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
service.listAllBundles.mockReset()
service.listAllBundles
.mockReturnValueOnce(of(bundles))
.mockReturnValue(of(bundles))
fixture.detectChanges()
tick()
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
expect(component.bundles).toEqual(bundles)
expect(component.loading).toBe(false)
expect(component.error).toBeNull()
tick(5000)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
}))
it('handles errors when loading bundles', fakeAsync(() => {
service.listAllBundles.mockReset()
service.listAllBundles
.mockReturnValueOnce(throwError(() => new Error('load fail')))
.mockReturnValue(of([]))
fixture.detectChanges()
tick()
expect(component.error).toContain('Failed to load share link bundles.')
expect(toastService.showError).toHaveBeenCalled()
expect(component.loading).toBe(false)
tick(5000)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
}))
it('copies bundle links when ready', fakeAsync(() => {
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
fixture.detectChanges()
tick()
const readyBundle = sampleBundle({
slug: 'ready-slug',
status: ShareLinkBundleStatus.Ready,
})
component.copy(readyBundle)
expect(clipboard.copy).toHaveBeenCalledWith(
component.getShareUrl(readyBundle)
)
expect(component.copiedSlug).toBe('ready-slug')
expect(toastService.showInfo).toHaveBeenCalled()
tick(3000)
expect(component.copiedSlug).toBeNull()
}))
it('ignores copy requests for non-ready bundles', fakeAsync(() => {
const copySpy = jest.spyOn(clipboard, 'copy')
fixture.detectChanges()
tick()
component.copy(sampleBundle({ status: ShareLinkBundleStatus.Pending }))
expect(copySpy).not.toHaveBeenCalled()
}))
it('deletes bundles and refreshes list', fakeAsync(() => {
service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(of(true))
fixture.detectChanges()
tick()
component.delete(sampleBundle())
tick()
expect(service.delete).toHaveBeenCalled()
expect(toastService.showInfo).toHaveBeenCalledWith(
expect.stringContaining('deleted.')
)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
expect(component.loading).toBe(false)
}))
it('handles delete errors gracefully', fakeAsync(() => {
service.listAllBundles.mockReturnValue(of([]))
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
fixture.detectChanges()
tick()
component.delete(sampleBundle())
tick()
expect(toastService.showError).toHaveBeenCalled()
expect(component.loading).toBe(false)
}))
it('retries bundle build and replaces existing entry', fakeAsync(() => {
service.listAllBundles.mockReturnValue(of([]))
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
service.rebuildBundle.mockReturnValue(of(updated))
fixture.detectChanges()
tick()
component.bundles = [sampleBundle()]
component.retry(component.bundles[0])
tick()
expect(service.rebuildBundle).toHaveBeenCalledWith(updated.id)
expect(component.bundles[0].status).toBe(ShareLinkBundleStatus.Ready)
expect(toastService.showInfo).toHaveBeenCalled()
}))
it('adds new bundle when retry returns unknown entry', fakeAsync(() => {
service.listAllBundles.mockReturnValue(of([]))
service.rebuildBundle.mockReturnValue(
of(sampleBundle({ id: 99, slug: 'new-slug' }))
)
fixture.detectChanges()
tick()
component.bundles = [sampleBundle()]
component.retry({ id: 99 } as ShareLinkBundleSummary)
tick()
expect(component.bundles.find((bundle) => bundle.id === 99)).toBeTruthy()
}))
it('handles retry errors', fakeAsync(() => {
service.listAllBundles.mockReturnValue(of([]))
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
fixture.detectChanges()
tick()
component.retry(sampleBundle())
tick()
expect(toastService.showError).toHaveBeenCalled()
}))
it('maps helpers and closes dialog', fakeAsync(() => {
service.listAllBundles.mockReturnValue(of([]))
fixture.detectChanges()
tick()
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
'Processing'
)
expect(component.fileVersionLabel(FileVersion.Original)).toContain(
'Original'
)
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()
}))
})

View File

@@ -0,0 +1,169 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { NgbActiveModal } 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'
import {
SHARE_LINK_BUNDLE_FILE_VERSION_LABELS,
SHARE_LINK_BUNDLE_STATUS_LABELS,
ShareLinkBundleStatus,
ShareLinkBundleSummary,
} from 'src/app/data/share-link-bundle'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({
selector: 'pngx-share-link-bundle-manage-dialog',
templateUrl: './share-link-bundle-manage-dialog.component.html',
imports: [CommonModule, NgxBootstrapIconsModule, FileSizePipe],
})
export class ShareLinkBundleManageDialogComponent
extends LoadingComponentWithPermissions
implements OnInit, OnDestroy
{
private activeModal = inject(NgbActiveModal)
private shareLinkBundleService = inject(ShareLinkBundleService)
private toastService = inject(ToastService)
private clipboard = inject(Clipboard)
title = $localize`Share link bundles`
bundles: ShareLinkBundleSummary[] = []
error: string | null = null
copiedSlug: string | null = null
readonly statuses = ShareLinkBundleStatus
readonly fileVersions = FileVersion
private readonly refresh$ = new Subject<boolean>()
ngOnInit(): void {
this.refresh$
.pipe(
switchMap((silent) => {
if (!silent) {
this.loading = true
}
this.error = null
return this.shareLinkBundleService.listAllBundles().pipe(
catchError((error) => {
if (!silent) {
this.loading = false
}
this.error = $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 = results
this.copiedSlug = null
}
this.loading = false
})
this.triggerRefresh(false)
timer(5000, 5000)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => this.triggerRefresh(true))
}
ngOnDestroy(): void {
super.ngOnDestroy()
}
getShareUrl(bundle: ShareLinkBundleSummary): string {
const apiURL = new URL(environment.apiBaseUrl)
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
bundle.slug
}`
}
copy(bundle: ShareLinkBundleSummary): void {
if (bundle.status !== ShareLinkBundleStatus.Ready) {
return
}
const success = this.clipboard.copy(this.getShareUrl(bundle))
if (success) {
this.copiedSlug = bundle.slug
setTimeout(() => {
this.copiedSlug = null
}, 3000)
this.toastService.showInfo($localize`Share link copied to clipboard.`)
}
}
delete(bundle: ShareLinkBundleSummary): void {
this.error = null
this.loading = true
this.shareLinkBundleService.delete(bundle).subscribe({
next: () => {
this.toastService.showInfo($localize`Share link bundle deleted.`)
this.triggerRefresh(false)
},
error: (e) => {
this.loading = false
this.toastService.showError(
$localize`Error deleting share link bundle.`,
e
)
},
})
}
retry(bundle: ShareLinkBundleSummary): void {
this.error = null
this.shareLinkBundleService.rebuildBundle(bundle.id).subscribe({
next: (updated) => {
this.toastService.showInfo(
$localize`Share link bundle rebuild requested.`
)
this.replaceBundle(updated)
},
error: (e) => {
this.toastService.showError($localize`Error requesting rebuild.`, e)
},
})
}
statusLabel(status: ShareLinkBundleStatus): string {
return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
}
fileVersionLabel(version: FileVersion): string {
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
}
close(): void {
this.activeModal.close()
}
private replaceBundle(updated: ShareLinkBundleSummary): void {
const index = this.bundles.findIndex((bundle) => bundle.id === updated.id)
if (index >= 0) {
this.bundles = [
...this.bundles.slice(0, index),
updated,
...this.bundles.slice(index + 1),
]
} else {
this.bundles = [updated, ...this.bundles]
}
}
private triggerRefresh(silent: boolean): void {
this.refresh$.next(silent)
}
}

View File

@@ -51,7 +51,7 @@
<div class="input-group w-100 mt-2">
<label class="input-group-text" for="addLink"><ng-container i18n>Expires</ng-container>:</label>
<select class="form-select fs-6" [(ngModel)]="expirationDays">
@for (option of EXPIRATION_OPTIONS; track option) {
@for (option of expirationOptions; track option) {
<option [ngValue]="option.value">{{ option.label }}</option>
}
</select>

View File

@@ -4,7 +4,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first } from 'rxjs'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
import {
FileVersion,
SHARE_LINK_EXPIRATION_OPTIONS,
ShareLink,
} from 'src/app/data/share-link'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
@@ -21,12 +25,7 @@ export class ShareLinksDialogComponent implements OnInit {
private toastService = inject(ToastService)
private clipboard = inject(Clipboard)
EXPIRATION_OPTIONS = [
{ label: $localize`1 day`, value: 1 },
{ label: $localize`7 days`, value: 7 },
{ label: $localize`30 days`, value: 30 },
{ label: $localize`Never`, value: null },
]
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
@Input()
title = $localize`Share Links`

View File

@@ -96,14 +96,36 @@
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
<i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
</button>
@if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()">
<i-bs name="envelope"></i-bs>&nbsp;<ng-container i18n>Email</ng-container>
</button>
}
</div>
</div>
</div>
<div class="btn-toolbar" ngbDropdown>
<button
class="btn btn-sm btn-outline-primary"
id="dropdownSend"
ngbDropdownToggle
[disabled]="disabled || list.selected.size === 0"
>
<i-bs name="send"></i-bs>
<div class="d-none d-sm-inline">
&nbsp;<ng-container i18n>Send</ng-container>
</div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()">
<i-bs name="link"></i-bs>&nbsp;<ng-container i18n>Create a share link bundle</ng-container>
</button>
<button ngbDropdownItem (click)="manageShareLinkBundles()">
<i-bs name="list-ul"></i-bs>&nbsp;<ng-container i18n>Manage share link bundles</ng-container>
</button>
<div class="dropdown-divider"></div>
@if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()">
<i-bs name="envelope"></i-bs>&nbsp;<ng-container i18n>Email</ng-container>
</button>
}
</div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
@if (!awaitingDownload) {

View File

@@ -3,6 +3,7 @@ import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { EventEmitter } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
@@ -25,6 +26,7 @@ import {
SelectionData,
} from 'src/app/services/rest/document.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
@@ -38,6 +40,8 @@ import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
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 = {
@@ -72,6 +76,7 @@ describe('BulkEditorComponent', () => {
let storagePathService: StoragePathService
let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController
let shareLinkBundleService: ShareLinkBundleService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -152,6 +157,15 @@ describe('BulkEditorComponent', () => {
}),
},
},
{
provide: ShareLinkBundleService,
useValue: {
createBundle: jest.fn(),
listAllBundles: jest.fn(),
rebuildBundle: jest.fn(),
delete: jest.fn(),
},
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
@@ -168,6 +182,7 @@ describe('BulkEditorComponent', () => {
storagePathService = TestBed.inject(StoragePathService)
customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController)
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
fixture = TestBed.createComponent(BulkEditorComponent)
component = fixture.componentInstance
@@ -1454,4 +1469,130 @@ describe('BulkEditorComponent', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should create share link bundle and enable manage callback', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 5 }, { id: 7 }] as any)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([5, 7]))
const confirmClicked = new EventEmitter<void>()
const modalRef: Partial<NgbModalRef> = {
close: jest.fn(),
componentInstance: {
documents: [],
confirmClicked,
payload: {
document_ids: [5, 7],
file_version: 'archive',
expiration_days: 7,
},
loading: false,
buttonsEnabled: true,
copied: false,
},
}
const openSpy = jest.spyOn(modalService, 'open')
openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
openSpy.mockReturnValueOnce({} as NgbModalRef)
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
of({ id: 42 })
)
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
component.createShareLinkBundle()
expect(openSpy).toHaveBeenNthCalledWith(
1,
ShareLinkBundleDialogComponent,
expect.objectContaining({ backdrop: 'static', size: 'lg' })
)
const dialogInstance = modalRef.componentInstance as any
expect(dialogInstance.documents).toEqual([{ id: 5 }, { id: 7 }])
confirmClicked.emit()
expect(shareLinkBundleService.createBundle).toHaveBeenCalledWith({
document_ids: [5, 7],
file_version: 'archive',
expiration_days: 7,
})
expect(dialogInstance.loading).toBe(false)
expect(dialogInstance.buttonsEnabled).toBe(false)
expect(dialogInstance.createdBundle).toEqual({ id: 42 })
expect(typeof dialogInstance.onOpenManage).toBe('function')
expect(toastInfoSpy).toHaveBeenCalledWith(
$localize`Share link bundle creation requested.`
)
dialogInstance.onOpenManage()
expect(modalRef.close).toHaveBeenCalled()
expect(openSpy).toHaveBeenNthCalledWith(
2,
ShareLinkBundleManageDialogComponent,
expect.objectContaining({ backdrop: 'static', size: 'lg' })
)
openSpy.mockRestore()
})
it('should handle share link bundle creation errors', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 9 }] as any)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([9]))
const confirmClicked = new EventEmitter<void>()
const modalRef: Partial<NgbModalRef> = {
componentInstance: {
documents: [],
confirmClicked,
payload: {
document_ids: [9],
file_version: 'original',
expiration_days: null,
},
loading: false,
buttonsEnabled: true,
},
}
const openSpy = jest
.spyOn(modalService, 'open')
.mockReturnValue(modalRef as NgbModalRef)
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
throwError(() => new Error('bundle failure'))
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
component.createShareLinkBundle()
const dialogInstance = modalRef.componentInstance as any
confirmClicked.emit()
expect(toastErrorSpy).toHaveBeenCalledWith(
$localize`Share link bundle creation is not available yet.`,
expect.any(Error)
)
expect(dialogInstance.loading).toBe(false)
expect(dialogInstance.buttonsEnabled).toBe(true)
openSpy.mockRestore()
})
it('should open share link bundle management dialog', () => {
const openSpy = jest.spyOn(modalService, 'open')
component.manageShareLinkBundles()
expect(openSpy).toHaveBeenCalledWith(
ShareLinkBundleManageDialogComponent,
expect.objectContaining({ backdrop: 'static', size: 'lg' })
)
openSpy.mockRestore()
})
})

View File

@@ -33,6 +33,7 @@ import {
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -54,6 +55,8 @@ import {
} from '../../common/filterable-dropdown/filterable-dropdown.component'
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'
@@ -87,6 +90,7 @@ export class BulkEditorComponent
private customFieldService = inject(CustomFieldsService)
private permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService)
private shareLinkBundleService = inject(ShareLinkBundleService)
tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel()
@@ -908,6 +912,62 @@ export class BulkEditorComponent
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
}
createShareLinkBundle() {
const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
backdrop: 'static',
size: 'lg',
})
const dialog = modal.componentInstance as ShareLinkBundleDialogComponent
const selectedDocuments = this.list.documents.filter((d) =>
this.list.selected.has(d.id)
)
dialog.documents = selectedDocuments
dialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
const payload = dialog.payload
if (!payload) {
return
}
dialog.loading = true
dialog.buttonsEnabled = false
this.shareLinkBundleService
.createBundle(payload)
.pipe(first())
.subscribe({
next: (result) => {
dialog.loading = false
dialog.buttonsEnabled = false
dialog.createdBundle = result
dialog.copied = false
dialog.payload = null
dialog.onOpenManage = () => {
modal.close()
this.manageShareLinkBundles()
}
this.toastService.showInfo(
$localize`Share link bundle creation requested.`
)
},
error: (error) => {
dialog.loading = false
dialog.buttonsEnabled = true
this.toastService.showError(
$localize`Share link bundle creation is not available yet.`,
error
)
},
})
})
}
manageShareLinkBundles() {
const modal = this.modalService.open(ShareLinkBundleManageDialogComponent, {
backdrop: 'static',
size: 'lg',
})
}
emailSelected() {
const allHaveArchiveVersion = this.list.documents
.filter((d) => this.list.selected.has(d.id))

View File

@@ -0,0 +1,46 @@
import { FileVersion } from './share-link'
export enum ShareLinkBundleStatus {
Pending = 'pending',
Processing = 'processing',
Ready = 'ready',
Failed = 'failed',
}
export interface ShareLinkBundleSummary {
id: number
slug: string
created: string // Date
expiration?: string // Date
documents: number[]
document_count: number
file_version: FileVersion
status: ShareLinkBundleStatus
built_at?: string
size_bytes?: number
last_error?: string
}
export interface ShareLinkBundleCreatePayload {
document_ids: number[]
file_version: FileVersion
expiration_days: number | null
}
export const SHARE_LINK_BUNDLE_STATUS_LABELS: Record<
ShareLinkBundleStatus,
string
> = {
[ShareLinkBundleStatus.Pending]: $localize`Pending`,
[ShareLinkBundleStatus.Processing]: $localize`Processing`,
[ShareLinkBundleStatus.Ready]: $localize`Ready`,
[ShareLinkBundleStatus.Failed]: $localize`Failed`,
}
export const SHARE_LINK_BUNDLE_FILE_VERSION_LABELS: Record<
FileVersion,
string
> = {
[FileVersion.Archive]: $localize`Archive`,
[FileVersion.Original]: $localize`Original`,
}

View File

@@ -5,6 +5,18 @@ export enum FileVersion {
Original = 'original',
}
export interface ShareLinkExpirationOption {
label: string
value: number | null
}
export const SHARE_LINK_EXPIRATION_OPTIONS: ShareLinkExpirationOption[] = [
{ label: $localize`1 day`, value: 1 },
{ label: $localize`7 days`, value: 7 },
{ label: $localize`30 days`, value: 30 },
{ label: $localize`Never`, value: null },
]
export interface ShareLink extends ObjectWithPermissions {
created: string // Date

View File

@@ -0,0 +1,60 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { Subscription } from 'rxjs'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { ShareLinkBundleService } from './share-link-bundle.service'
const endpoint = 'share_link_bundles'
commonAbstractPaperlessServiceTests(endpoint, ShareLinkBundleService)
describe('ShareLinkBundleService', () => {
let httpTestingController: HttpTestingController
let service: ShareLinkBundleService
let subscription: Subscription | undefined
beforeEach(() => {
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(ShareLinkBundleService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
})
it('creates bundled share links', () => {
const payload = {
document_ids: [1, 2],
file_version: 'archive',
expiration_days: 7,
}
subscription = service.createBundle(payload as any).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/`
)
expect(req.request.method).toBe('POST')
expect(req.request.body).toEqual(payload)
req.flush({})
})
it('rebuilds bundles', () => {
subscription = service.rebuildBundle(12).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/12/rebuild/`
)
expect(req.request.method).toBe('POST')
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: [] })
})
})

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import {
ShareLinkBundleCreatePayload,
ShareLinkBundleSummary,
} from 'src/app/data/share-link-bundle'
import { AbstractNameFilterService } from './abstract-name-filter-service'
@Injectable({
providedIn: 'root',
})
export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkBundleSummary> {
constructor() {
super()
this.resourceName = 'share_link_bundles'
}
createBundle(
payload: ShareLinkBundleCreatePayload
): Observable<ShareLinkBundleSummary> {
this.clearCache()
return this.http.post<ShareLinkBundleSummary>(
this.getResourceUrl(),
payload
)
}
rebuildBundle(bundleId: number): Observable<ShareLinkBundleSummary> {
this.clearCache()
return this.http.post<ShareLinkBundleSummary>(
this.getResourceUrl(bundleId, 'rebuild'),
{}
)
}
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
return this.list(1, 1000, 'created', true).pipe(
map((response) => response.results)
)
}
}

View File

@@ -13,6 +13,7 @@ from documents.models import PaperlessTask
from documents.models import SavedView
from documents.models import SavedViewFilterRule
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
from documents.tasks import update_document_parent_tags
@@ -185,6 +186,22 @@ class ShareLinksAdmin(GuardedModelAdmin):
return super().get_queryset(request).select_related("document__correspondent")
class ShareLinkBundleAdmin(GuardedModelAdmin):
list_display = ("created", "status", "expiration", "owner", "slug")
list_filter = ("status", "created", "expiration", "owner")
search_fields = ("slug",)
def get_queryset(self, request): # pragma: no cover
return (
super()
.get_queryset(request)
.select_related("owner")
.prefetch_related(
"documents",
)
)
class CustomFieldsAdmin(GuardedModelAdmin):
fields = ("name", "created", "data_type")
readonly_fields = ("created", "data_type")
@@ -216,6 +233,7 @@ admin.site.register(StoragePath, StoragePathAdmin)
admin.site.register(PaperlessTask, TaskAdmin)
admin.site.register(Note, NotesAdmin)
admin.site.register(ShareLink, ShareLinksAdmin)
admin.site.register(ShareLinkBundle, ShareLinkBundleAdmin)
admin.site.register(CustomField, CustomFieldsAdmin)
admin.site.register(CustomFieldInstance, CustomFieldInstancesAdmin)

View File

@@ -39,6 +39,7 @@ from documents.models import Document
from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
@@ -793,6 +794,29 @@ class ShareLinkFilterSet(FilterSet):
}
class ShareLinkBundleFilterSet(FilterSet):
documents = Filter(method="filter_documents")
class Meta:
model = ShareLinkBundle
fields = {
"created": DATETIME_KWARGS,
"expiration": DATETIME_KWARGS,
"status": ["exact"],
}
def filter_documents(self, queryset, name, value):
if not value:
return queryset
try:
ids = [int(item) for item in value.split(",") if item]
except ValueError:
return queryset.none()
if not ids:
return queryset
return queryset.filter(documents__in=ids).distinct()
class PaperlessTaskFilterSet(FilterSet):
acknowledged = BooleanFilter(
label="Acknowledged",

View File

@@ -0,0 +1,190 @@
# Generated by Django 5.2.7 on 2025-11-04 18:34
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.contrib.auth.management import create_permissions
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 migrations
from django.db import models
def grant_share_link_bundle_permissions(apps, schema_editor):
# Ensure newly introduced permissions are created for all apps
for app_config in apps.get_app_configs():
app_config.models_module = True
create_permissions(app_config, apps=apps, verbosity=0)
app_config.models_module = None
add_document_perm = Permission.objects.filter(codename="add_document").first()
if add_document_perm is None:
return
share_bundle_permissions = Permission.objects.filter(
codename__contains="sharelinkbundle",
)
users = User.objects.filter(user_permissions=add_document_perm).distinct()
for user in users:
user.user_permissions.add(*share_bundle_permissions)
groups = Group.objects.filter(permissions=add_document_perm).distinct()
for group in groups:
group.permissions.add(*share_bundle_permissions)
def revoke_share_link_bundle_permissions(apps, schema_editor):
share_bundle_permissions = Permission.objects.filter(
codename__contains="sharelinkbundle",
)
for user in User.objects.all():
user.user_permissions.remove(*share_bundle_permissions)
for group in Group.objects.all():
group.permissions.remove(*share_bundle_permissions)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("documents", "1074_workflowrun_deleted_at_workflowrun_restored_at_and_more"),
]
operations = [
migrations.CreateModel(
name="ShareLinkBundle",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
models.DateTimeField(
blank=True,
db_index=True,
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"expiration",
models.DateTimeField(
blank=True,
db_index=True,
null=True,
verbose_name="expiration",
),
),
(
"slug",
models.SlugField(
blank=True,
editable=False,
unique=True,
verbose_name="slug",
),
),
(
"file_version",
models.CharField(
choices=[("archive", "Archive"), ("original", "Original")],
default="archive",
max_length=50,
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("processing", "Processing"),
("ready", "Ready"),
("failed", "Failed"),
],
default="pending",
max_length=50,
),
),
(
"size_bytes",
models.BigIntegerField(
blank=True,
null=True,
verbose_name="size (bytes)",
),
),
(
"last_error",
models.TextField(
blank=True,
verbose_name="last error",
),
),
(
"file_path",
models.CharField(
blank=True,
max_length=512,
verbose_name="file path",
),
),
(
"built_at",
models.DateTimeField(
blank=True,
null=True,
verbose_name="built at",
),
),
(
"owner",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="share_link_bundles",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
(
"deleted_at",
models.DateTimeField(blank=True, null=True),
),
(
"restored_at",
models.DateTimeField(blank=True, null=True),
),
(
"transaction_id",
models.UUIDField(blank=True, null=True),
),
],
options={
"ordering": ("-created",),
"verbose_name": "share link bundle",
"verbose_name_plural": "share link bundles",
},
),
migrations.AddField(
model_name="sharelinkbundle",
name="documents",
field=models.ManyToManyField(
related_name="share_link_bundles",
to="documents.document",
verbose_name="documents",
),
),
migrations.RunPython(
grant_share_link_bundle_permissions,
reverse_code=revoke_share_link_bundle_permissions,
),
]

View File

@@ -777,6 +777,120 @@ class ShareLink(SoftDeleteModel):
return f"Share Link for {self.document.title}"
class ShareLinkBundle(SoftDeleteModel):
class Status(models.TextChoices):
PENDING = ("pending", _("Pending"))
PROCESSING = ("processing", _("Processing"))
READY = ("ready", _("Ready"))
FAILED = ("failed", _("Failed"))
class Meta:
ordering = ("-created",)
verbose_name = _("share link bundle")
verbose_name_plural = _("share link bundles")
created = models.DateTimeField(
_("created"),
default=timezone.now,
db_index=True,
blank=True,
editable=False,
)
expiration = models.DateTimeField(
_("expiration"),
blank=True,
null=True,
db_index=True,
)
slug = models.SlugField(
_("slug"),
db_index=True,
unique=True,
blank=True,
editable=False,
)
owner = models.ForeignKey(
User,
blank=True,
null=True,
related_name="share_link_bundles",
on_delete=models.SET_NULL,
verbose_name=_("owner"),
)
file_version = models.CharField(
max_length=50,
choices=ShareLink.FileVersion.choices,
default=ShareLink.FileVersion.ARCHIVE,
)
status = models.CharField(
max_length=50,
choices=Status.choices,
default=Status.PENDING,
)
size_bytes = models.BigIntegerField(
_("size (bytes)"),
blank=True,
null=True,
)
last_error = models.TextField(
_("last error"),
blank=True,
)
file_path = models.CharField(
_("file path"),
max_length=512,
blank=True,
)
built_at = models.DateTimeField(
_("built at"),
null=True,
blank=True,
)
documents = models.ManyToManyField(
"documents.Document",
related_name="share_link_bundles",
verbose_name=_("documents"),
)
def __str__(self):
return _("Share link bundle %(slug)s") % {"slug": self.slug}
@property
def absolute_file_path(self) -> Path | None:
if not self.file_path:
return None
file_path = Path(self.file_path)
if not file_path.is_absolute():
file_path = (settings.MEDIA_ROOT / file_path).resolve()
return file_path
def remove_file(self):
path = self.absolute_file_path
if path and path.exists():
try:
path.unlink()
except OSError:
pass
def delete(self, using=None, *, keep_parents=False):
self.remove_file()
return super().delete(using=using, keep_parents=keep_parents)
def hard_delete(self, using=None, *, keep_parents=False):
self.remove_file()
return super().hard_delete(using=using, keep_parents=keep_parents)
class CustomField(models.Model):
"""
Defines the name and type of a custom field

View File

@@ -4,6 +4,7 @@ import logging
import math
import re
from datetime import datetime
from datetime import timedelta
from decimal import Decimal
from typing import TYPE_CHECKING
from typing import Literal
@@ -21,6 +22,7 @@ from django.core.validators import MaxLengthValidator
from django.core.validators import RegexValidator
from django.core.validators import integer_validator
from django.db.models import Count
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.dateparse import parse_datetime
from django.utils.text import slugify
@@ -57,6 +59,7 @@ from documents.models import PaperlessTask
from documents.models import SavedView
from documents.models import SavedViewFilterRule
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
from documents.models import UiSettings
@@ -2127,6 +2130,112 @@ class ShareLinkSerializer(OwnedObjectSerializer):
return super().create(validated_data)
class ShareLinkBundleSerializer(OwnedObjectSerializer):
document_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=False,
write_only=True,
)
expiration_days = serializers.IntegerField(
required=False,
allow_null=True,
min_value=1,
write_only=True,
)
documents = serializers.PrimaryKeyRelatedField(
many=True,
read_only=True,
)
document_count = SerializerMethodField()
class Meta:
model = ShareLinkBundle
fields = (
"id",
"created",
"expiration",
"expiration_days",
"slug",
"file_version",
"status",
"size_bytes",
"last_error",
"built_at",
"documents",
"document_ids",
"document_count",
)
read_only_fields = (
"id",
"created",
"expiration",
"slug",
"status",
"size_bytes",
"last_error",
"built_at",
"documents",
"document_count",
)
def validate_document_ids(self, value):
unique_ids = set(value)
if len(unique_ids) != len(value):
raise serializers.ValidationError(
_("Duplicate document identifiers are not allowed."),
)
return value
def create(self, validated_data):
document_ids = validated_data.pop("document_ids")
expiration_days = validated_data.pop("expiration_days", None)
documents = validated_data.pop("documents", None)
validated_data["slug"] = get_random_string(50)
if expiration_days:
validated_data["expiration"] = timezone.now() + timedelta(
days=expiration_days,
)
else:
validated_data["expiration"] = None
share_link_bundle = super().create(validated_data)
if documents is None:
documents = list(
Document.objects.filter(pk__in=document_ids).only(
"pk",
),
)
else:
documents = list(documents)
documents_by_id = {doc.pk: doc for doc in documents}
missing = [
str(doc_id) for doc_id in document_ids if doc_id not in documents_by_id
]
if missing:
raise serializers.ValidationError(
{
"document_ids": _(
"Documents not found: %(ids)s",
)
% {"ids": ", ".join(missing)},
},
)
ordered_documents = [documents_by_id[doc_id] for doc_id in document_ids]
share_link_bundle.documents.set(ordered_documents)
share_link_bundle.document_total = len(ordered_documents)
return share_link_bundle
def get_document_count(self, obj: ShareLinkBundle) -> int:
count = getattr(obj, "document_total", None)
if count is not None:
return count
return obj.documents.count()
class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
objects = serializers.ListField(
required=True,

View File

@@ -3,7 +3,9 @@ import hashlib
import logging
import shutil
import uuid
import zipfile
from pathlib import Path
from tempfile import NamedTemporaryFile
from tempfile import TemporaryDirectory
import tqdm
@@ -22,6 +24,8 @@ from whoosh.writing import AsyncWriter
from documents import index
from documents import sanity_checker
from documents.barcodes import BarcodePlugin
from documents.bulk_download import ArchiveOnlyStrategy
from documents.bulk_download import OriginalsOnlyStrategy
from documents.caching import clear_document_caches
from documents.classifier import DocumentClassifier
from documents.classifier import load_classifier
@@ -39,6 +43,8 @@ from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
@@ -563,3 +569,121 @@ def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
if affected:
bulk_update_documents.delay(document_ids=list(affected))
@shared_task
def build_share_link_bundle(bundle_id: int):
try:
bundle = (
ShareLinkBundle.objects.filter(pk=bundle_id)
.prefetch_related("documents")
.get()
)
except ShareLinkBundle.DoesNotExist:
logger.warning("Share link bundle %s no longer exists.", bundle_id)
return
bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PROCESSING
bundle.last_error = ""
bundle.size_bytes = None
bundle.built_at = None
bundle.file_path = ""
bundle.save(
update_fields=[
"status",
"last_error",
"size_bytes",
"built_at",
"file_path",
],
)
documents = list(bundle.documents.all().order_by("pk"))
with NamedTemporaryFile(
dir=settings.SCRATCH_DIR,
suffix=".zip",
delete=False,
) as temp_zip:
temp_zip_path = Path(temp_zip.name)
try:
strategy_class = (
ArchiveOnlyStrategy
if bundle.file_version == ShareLink.FileVersion.ARCHIVE
else OriginalsOnlyStrategy
)
with zipfile.ZipFile(temp_zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
strategy = strategy_class(zipf)
for document in documents:
strategy.add_document(document)
output_dir = settings.SHARE_LINK_BUNDLE_DIR
output_dir.mkdir(parents=True, exist_ok=True)
final_path = (output_dir / f"{bundle.slug}.zip").resolve()
if final_path.exists():
final_path.unlink()
shutil.move(str(temp_zip_path), final_path)
try:
bundle.file_path = str(final_path.relative_to(settings.MEDIA_ROOT))
except ValueError:
bundle.file_path = str(final_path)
bundle.size_bytes = final_path.stat().st_size
bundle.status = ShareLinkBundle.Status.READY
bundle.built_at = timezone.now()
bundle.last_error = ""
bundle.save(
update_fields=[
"file_path",
"size_bytes",
"status",
"built_at",
"last_error",
],
)
logger.info("Built share link bundle %s", bundle.pk)
except Exception as exc:
logger.exception(
"Failed to build share link bundle %s: %s",
bundle_id,
exc,
)
bundle.status = ShareLinkBundle.Status.FAILED
bundle.last_error = str(exc)
bundle.save(update_fields=["status", "last_error"])
try:
temp_zip_path.unlink()
except OSError:
pass
raise
finally:
if temp_zip_path.exists():
try:
temp_zip_path.unlink()
except OSError:
pass
@shared_task
def cleanup_expired_share_link_bundles():
now = timezone.now()
expired_qs = ShareLinkBundle.objects.filter(
deleted_at__isnull=True,
expiration__isnull=False,
expiration__lt=now,
)
count = 0
for bundle in expired_qs.iterator():
count += 1
try:
bundle.hard_delete()
except Exception as exc:
logger.warning(
"Failed to delete expired share link bundle %s: %s",
bundle.pk,
exc,
)
if count:
logger.info("Deleted %s expired share link bundle(s)", count)

View File

@@ -0,0 +1,182 @@
from __future__ import annotations
from datetime import timedelta
from pathlib import Path
from unittest import mock
from django.contrib.auth.models import User
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.tasks import cleanup_expired_share_link_bundles
from documents.tests.factories import DocumentFactory
from documents.tests.utils import DirectoriesMixin
class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/share_link_bundles/"
def setUp(self):
super().setUp()
self.user = User.objects.create_superuser(username="bundle_admin")
self.client.force_authenticate(self.user)
self.document = DocumentFactory.create()
@mock.patch("documents.views.build_share_link_bundle.delay")
def test_create_bundle_triggers_build_job(self, delay_mock):
payload = {
"document_ids": [self.document.pk],
"file_version": ShareLink.FileVersion.ARCHIVE,
"expiration_days": 7,
}
response = self.client.post(self.ENDPOINT, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
bundle = ShareLinkBundle.objects.get(pk=response.data["id"])
self.assertEqual(bundle.documents.count(), 1)
self.assertEqual(bundle.status, ShareLinkBundle.Status.PENDING)
delay_mock.assert_called_once_with(bundle.pk)
def test_create_bundle_rejects_missing_documents(self):
payload = {
"document_ids": [9999],
"file_version": ShareLink.FileVersion.ARCHIVE,
"expiration_days": 7,
}
response = self.client.post(self.ENDPOINT, payload, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("document_ids", response.data)
@mock.patch("documents.views.build_share_link_bundle.delay")
def test_rebuild_bundle_resets_state(self, delay_mock):
bundle = ShareLinkBundle.objects.create(
slug="rebuild-slug",
file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.FAILED,
)
bundle.documents.set([self.document])
bundle.last_error = "Something went wrong"
bundle.size_bytes = 100
bundle.file_path = "path/to/file.zip"
bundle.save()
response = self.client.post(f"{self.ENDPOINT}{bundle.pk}/rebuild/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
bundle.refresh_from_db()
self.assertEqual(bundle.status, ShareLinkBundle.Status.PENDING)
self.assertEqual(bundle.last_error, "")
self.assertIsNone(bundle.size_bytes)
self.assertEqual(bundle.file_path, "")
delay_mock.assert_called_once_with(bundle.pk)
def test_rebuild_bundle_rejects_processing_status(self):
bundle = ShareLinkBundle.objects.create(
slug="processing-slug",
file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.PROCESSING,
)
bundle.documents.set([self.document])
response = self.client.post(f"{self.ENDPOINT}{bundle.pk}/rebuild/")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("detail", response.data)
def test_download_ready_bundle_streams_file(self):
bundle_file = Path(self.dirs.media_dir) / "bundles" / "ready.zip"
bundle_file.parent.mkdir(parents=True, exist_ok=True)
bundle_file.write_bytes(b"binary-zip-content")
bundle = ShareLinkBundle.objects.create(
slug="readyslug",
file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.READY,
file_path=str(bundle_file),
)
bundle.documents.set([self.document])
self.client.logout()
response = self.client.get(f"/share/{bundle.slug}/")
content = b"".join(response.streaming_content)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response["Content-Type"], "application/zip")
self.assertEqual(content, b"binary-zip-content")
self.assertIn("attachment;", response["Content-Disposition"])
def test_download_pending_bundle_returns_202(self):
bundle = ShareLinkBundle.objects.create(
slug="pendingslug",
file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.PENDING,
)
bundle.documents.set([self.document])
self.client.logout()
response = self.client.get(f"/share/{bundle.slug}/")
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
@mock.patch("documents.views.build_share_link_bundle.delay")
def test_download_missing_file_triggers_rebuild(self, delay_mock):
bundle = ShareLinkBundle.objects.create(
slug="missingfileslug",
file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.READY,
file_path=str(Path(self.dirs.media_dir) / "does-not-exist.zip"),
)
bundle.documents.set([self.document])
self.client.logout()
response = self.client.get(f"/share/{bundle.slug}/")
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
bundle.refresh_from_db()
self.assertEqual(bundle.status, ShareLinkBundle.Status.PENDING)
delay_mock.assert_called_once_with(bundle.pk)
class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
def setUp(self):
super().setUp()
self.document = DocumentFactory.create()
def test_cleanup_expired_share_link_bundles(self):
expired_path = Path(self.dirs.media_dir) / "expired.zip"
expired_path.parent.mkdir(parents=True, exist_ok=True)
expired_path.write_bytes(b"expired")
active_path = Path(self.dirs.media_dir) / "active.zip"
active_path.write_bytes(b"active")
expired_bundle = ShareLinkBundle.objects.create(
slug="expired-bundle",
file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.READY,
expiration=timezone.now() - timedelta(days=1),
file_path=str(expired_path),
)
expired_bundle.documents.set([self.document])
active_bundle = ShareLinkBundle.objects.create(
slug="active-bundle",
file_version=ShareLink.FileVersion.ARCHIVE,
status=ShareLinkBundle.Status.READY,
expiration=timezone.now() + timedelta(days=1),
file_path=str(active_path),
)
active_bundle.documents.set([self.document])
cleanup_expired_share_link_bundles()
self.assertFalse(ShareLinkBundle.objects.filter(pk=expired_bundle.pk).exists())
self.assertTrue(ShareLinkBundle.objects.filter(pk=active_bundle.pk).exists())
self.assertFalse(expired_path.exists())
self.assertTrue(active_path.exists())

View File

@@ -49,6 +49,7 @@ from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.timezone import make_aware
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.cache import cache_control
from django.views.decorators.cache import cache_page
@@ -69,6 +70,7 @@ from packaging import version as packaging_version
from redis import Redis
from rest_framework import parsers
from rest_framework import serializers
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.exceptions import ValidationError
@@ -116,6 +118,7 @@ from documents.filters import DocumentTypeFilterSet
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.filters import ObjectOwnedPermissionsFilter
from documents.filters import PaperlessTaskFilterSet
from documents.filters import ShareLinkBundleFilterSet
from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet
@@ -132,6 +135,7 @@ from documents.models import Note
from documents.models import PaperlessTask
from documents.models import SavedView
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
from documents.models import UiSettings
@@ -165,6 +169,7 @@ from documents.serialisers import PostDocumentSerializer
from documents.serialisers import RunTaskViewSerializer
from documents.serialisers import SavedViewSerializer
from documents.serialisers import SearchResultSerializer
from documents.serialisers import ShareLinkBundleSerializer
from documents.serialisers import ShareLinkSerializer
from documents.serialisers import StoragePathSerializer
from documents.serialisers import StoragePathTestSerializer
@@ -177,6 +182,7 @@ from documents.serialisers import WorkflowActionSerializer
from documents.serialisers import WorkflowSerializer
from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_updated
from documents.tasks import build_share_link_bundle
from documents.tasks import consume_file
from documents.tasks import empty_trash
from documents.tasks import index_optimize
@@ -2251,7 +2257,7 @@ class BulkDownloadView(GenericAPIView):
follow_filename_format = serializer.validated_data.get("follow_formatting")
for document in documents:
if not has_perms_owner_aware(request.user, "view_document", document):
if not has_perms_owner_aware(request.user, "change_document", document):
return HttpResponseForbidden("Insufficient permissions")
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
@@ -2596,21 +2602,225 @@ class ShareLinkViewSet(ModelViewSet, PassUserMixin):
ordering_fields = ("created", "expiration", "document")
class ShareLinkBundleViewSet(ModelViewSet, PassUserMixin):
model = ShareLinkBundle
queryset = ShareLinkBundle.objects.all()
serializer_class = ShareLinkBundleSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = ShareLinkBundleFilterSet
ordering_fields = ("created", "expiration", "status")
def get_queryset(self):
return (
super()
.get_queryset()
.prefetch_related("documents")
.annotate(document_total=Count("documents", distinct=True))
)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
document_ids = serializer.validated_data["document_ids"]
documents_qs = Document.objects.filter(pk__in=document_ids).select_related(
"owner",
)
found_ids = set(documents_qs.values_list("pk", flat=True))
missing = sorted(set(document_ids) - found_ids)
if missing:
raise ValidationError(
{
"document_ids": _(
"Documents not found: %(ids)s",
)
% {"ids": ", ".join(str(item) for item in missing)},
},
)
documents = list(documents_qs)
for document in documents:
if not has_perms_owner_aware(request.user, "view_document", document):
raise ValidationError(
{
"document_ids": _(
"Insufficient permissions to share document %(id)s.",
)
% {"id": document.pk},
},
)
document_map = {document.pk: document for document in documents}
ordered_documents = [document_map[doc_id] for doc_id in document_ids]
bundle = serializer.save(
owner=request.user,
documents=ordered_documents,
)
bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = ""
bundle.size_bytes = None
bundle.built_at = None
bundle.file_path = ""
bundle.save(
update_fields=[
"status",
"last_error",
"size_bytes",
"built_at",
"file_path",
],
)
build_share_link_bundle.delay(bundle.pk)
bundle.document_total = len(ordered_documents)
response_serializer = self.get_serializer(bundle)
headers = self.get_success_headers(response_serializer.data)
return Response(
response_serializer.data,
status=status.HTTP_201_CREATED,
headers=headers,
)
@action(detail=True, methods=["post"])
def rebuild(self, request, pk=None):
bundle = self.get_object()
if bundle.status == ShareLinkBundle.Status.PROCESSING:
return Response(
{"detail": _("Bundle is already being processed.")},
status=status.HTTP_400_BAD_REQUEST,
)
bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = ""
bundle.size_bytes = None
bundle.built_at = None
bundle.file_path = ""
bundle.save(
update_fields=[
"status",
"last_error",
"size_bytes",
"built_at",
"file_path",
],
)
build_share_link_bundle.delay(bundle.pk)
bundle.document_total = (
getattr(bundle, "document_total", None) or bundle.documents.count()
)
serializer = self.get_serializer(bundle)
return Response(serializer.data)
class SharedLinkView(View):
authentication_classes = []
permission_classes = []
def get(self, request, slug):
share_link = ShareLink.objects.filter(slug=slug).first()
if share_link is None:
if share_link is not None:
if (
share_link.expiration is not None
and share_link.expiration < timezone.now()
):
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
return serve_file(
doc=share_link.document,
use_archive=share_link.file_version == "archive",
disposition="inline",
)
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
if bundle is None:
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
if share_link.expiration is not None and share_link.expiration < timezone.now():
if bundle.expiration is not None and bundle.expiration < timezone.now():
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
return serve_file(
doc=share_link.document,
use_archive=share_link.file_version == "archive",
disposition="inline",
if bundle.status in {
ShareLinkBundle.Status.PENDING,
ShareLinkBundle.Status.PROCESSING,
}:
return HttpResponse(
_(
"The share link bundle is still being prepared. Please try again later.",
),
status=status.HTTP_202_ACCEPTED,
)
if bundle.status == ShareLinkBundle.Status.FAILED:
bundle.remove_file()
bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = ""
bundle.size_bytes = None
bundle.built_at = None
bundle.file_path = ""
bundle.save(
update_fields=[
"status",
"last_error",
"size_bytes",
"built_at",
"file_path",
],
)
build_share_link_bundle.delay(bundle.pk)
return HttpResponse(
_(
"The share link bundle is temporarily unavailable. A rebuild has been scheduled. Please try again later.",
),
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
file_path = bundle.absolute_file_path
if file_path is None or not file_path.exists():
bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = ""
bundle.size_bytes = None
bundle.built_at = None
bundle.file_path = ""
bundle.save(
update_fields=[
"status",
"last_error",
"size_bytes",
"built_at",
"file_path",
],
)
build_share_link_bundle.delay(bundle.pk)
return HttpResponse(
_(
"The share link bundle is being prepared. Please try again later.",
),
status=status.HTTP_202_ACCEPTED,
)
response = FileResponse(file_path.open("rb"), content_type="application/zip")
short_slug = bundle.slug[:12]
download_name = f"paperless-share-{short_slug}.zip"
filename_normalized = (
normalize("NFKD", download_name)
.encode(
"ascii",
"ignore",
)
.decode("ascii")
)
filename_encoded = quote(download_name)
response["Content-Disposition"] = (
f"attachment; filename='{filename_normalized}'; "
f"filename*=utf-8''{filename_encoded}"
)
return response
def serve_file(*, doc: Document, use_archive: bool, disposition: str):

View File

@@ -230,6 +230,17 @@ def _parse_beat_schedule() -> dict:
"expires": 59.0 * 60.0,
},
},
{
"name": "Cleanup expired share link bundles",
"env_key": "PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON",
# Default daily at 02:00
"env_default": "0 2 * * *",
"task": "documents.tasks.cleanup_expired_share_link_bundles",
"options": {
# 1 hour before default schedule sends again
"expires": 23.0 * 60.0 * 60.0,
},
},
]
for task in tasks:
# Either get the environment setting or use the default
@@ -268,6 +279,7 @@ MEDIA_ROOT = __get_path("PAPERLESS_MEDIA_ROOT", BASE_DIR.parent / "media")
ORIGINALS_DIR = MEDIA_ROOT / "documents" / "originals"
ARCHIVE_DIR = MEDIA_ROOT / "documents" / "archive"
THUMBNAIL_DIR = MEDIA_ROOT / "documents" / "thumbnails"
SHARE_LINK_BUNDLE_DIR = MEDIA_ROOT / "documents" / "share_link_bundles"
DATA_DIR = __get_path("PAPERLESS_DATA_DIR", BASE_DIR.parent / "data")

View File

@@ -160,6 +160,7 @@ class TestCeleryScheduleParsing(TestCase):
SANITY_EXPIRE_TIME = ((7.0 * 24.0) - 1.0) * 60.0 * 60.0
EMPTY_TRASH_EXPIRE_TIME = 23.0 * 60.0 * 60.0
RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME = 59.0 * 60.0
CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME = 23.0 * 60.0 * 60.0
def test_schedule_configuration_default(self):
"""
@@ -204,6 +205,13 @@ class TestCeleryScheduleParsing(TestCase):
"schedule": crontab(minute="5", hour="*/1"),
"options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME},
},
"Cleanup expired share link bundles": {
"task": "documents.tasks.cleanup_expired_share_link_bundles",
"schedule": crontab(minute=0, hour=2),
"options": {
"expires": self.CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME,
},
},
},
schedule,
)
@@ -256,6 +264,13 @@ class TestCeleryScheduleParsing(TestCase):
"schedule": crontab(minute="5", hour="*/1"),
"options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME},
},
"Cleanup expired share link bundles": {
"task": "documents.tasks.cleanup_expired_share_link_bundles",
"schedule": crontab(minute=0, hour=2),
"options": {
"expires": self.CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME,
},
},
},
schedule,
)
@@ -300,6 +315,13 @@ class TestCeleryScheduleParsing(TestCase):
"schedule": crontab(minute="5", hour="*/1"),
"options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME},
},
"Cleanup expired share link bundles": {
"task": "documents.tasks.cleanup_expired_share_link_bundles",
"schedule": crontab(minute=0, hour=2),
"options": {
"expires": self.CLEANUP_EXPIRED_SHARE_BUNDLES_EXPIRE_TIME,
},
},
},
schedule,
)
@@ -322,6 +344,7 @@ class TestCeleryScheduleParsing(TestCase):
"PAPERLESS_INDEX_TASK_CRON": "disable",
"PAPERLESS_EMPTY_TRASH_TASK_CRON": "disable",
"PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON": "disable",
"PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON": "disable",
},
):
schedule = _parse_beat_schedule()

View File

@@ -30,6 +30,7 @@ from documents.views import SavedViewViewSet
from documents.views import SearchAutoCompleteView
from documents.views import SelectionDataView
from documents.views import SharedLinkView
from documents.views import ShareLinkBundleViewSet
from documents.views import ShareLinkViewSet
from documents.views import StatisticsView
from documents.views import StoragePathViewSet
@@ -72,6 +73,7 @@ api_router.register(r"users", UserViewSet, basename="users")
api_router.register(r"groups", GroupViewSet, basename="groups")
api_router.register(r"mail_accounts", MailAccountViewSet)
api_router.register(r"mail_rules", MailRuleViewSet)
api_router.register(r"share_link_bundles", ShareLinkBundleViewSet)
api_router.register(r"share_links", ShareLinkViewSet)
api_router.register(r"workflow_triggers", WorkflowTriggerViewSet)
api_router.register(r"workflow_actions", WorkflowActionViewSet)