Consistent naming to Share Link Bundle

This commit is contained in:
shamoon 2025-11-05 17:24:23 -08:00
parent 4db5cf0ee8
commit 1ccdd1f4db
No known key found for this signature in database
24 changed files with 283 additions and 271 deletions

View File

@ -1,7 +0,0 @@
describe('ShareBundleDialogComponent', () => {
it('is pending implementation', () => {
pending(
'ShareBundleDialogComponent tests will be implemented once the dialog logic is finalized.'
)
})
})

View File

@ -1,7 +0,0 @@
describe('ShareBundleManageDialogComponent', () => {
it('is pending implementation', () => {
pending(
'ShareBundleManageDialogComponent tests will be implemented once the dialog logic is finalized.'
)
})
})

View File

@ -50,9 +50,9 @@
} @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 requested</h6>
<h6 class="alert-heading mb-1" i18n>Share link bundle requested</h6>
<p class="mb-0 small" i18n>
You can copy the link below or open the management screen to monitor its progress. The link will start working once it is ready.
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">
@ -105,11 +105,11 @@
<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. This process happens in the background and may take some time, especially for large bundles.</ng-container>
<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" (click)="openManage()" i18n>Open manage links</button>
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="openManage()" i18n>Manage share link bundles</button>
}
@if (!createdBundle) {

View File

@ -0,0 +1,7 @@
describe('ShareLinkBundleDialogComponent', () => {
it('is pending implementation', () => {
pending(
'ShareLinkBundleDialogComponent tests will be implemented once the dialog logic is finalized.'
)
})
})

View File

@ -4,17 +4,17 @@ 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 {
SHARE_BUNDLE_FILE_VERSION_LABELS,
SHARE_BUNDLE_STATUS_LABELS,
ShareBundleCreatePayload,
ShareBundleStatus,
ShareBundleSummary,
} from 'src/app/data/share-bundle'
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'
@ -22,8 +22,8 @@ import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
@Component({
selector: 'pngx-share-bundle-dialog',
templateUrl: './share-bundle-dialog.component.html',
selector: 'pngx-share-link-bundle-dialog',
templateUrl: './share-link-bundle-dialog.component.html',
imports: [
CommonModule,
ReactiveFormsModule,
@ -33,7 +33,7 @@ import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.compone
],
providers: [],
})
export class ShareBundleDialogComponent extends ConfirmDialogComponent {
export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
private formBuilder = inject(FormBuilder)
private clipboard = inject(Clipboard)
private toastService = inject(ToastService)
@ -46,20 +46,20 @@ export class ShareBundleDialogComponent extends ConfirmDialogComponent {
shareArchiveVersion: [true],
expirationDays: [7],
})
payload: ShareBundleCreatePayload | null = null
payload: ShareLinkBundleCreatePayload | null = null
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
createdBundle: ShareBundleSummary | null = null
createdBundle: ShareLinkBundleSummary | null = null
copied = false
onOpenManage?: () => void
readonly statuses = ShareBundleStatus
readonly statuses = ShareLinkBundleStatus
constructor() {
super()
this.loading = false
this.title = $localize`Share Selected Documents`
this.btnCaption = $localize`Create`
this.title = $localize`Create share link bundle`
this.btnCaption = $localize`Create link`
}
@Input()
@ -82,14 +82,14 @@ export class ShareBundleDialogComponent extends ConfirmDialogComponent {
super.confirm()
}
getShareUrl(bundle: ShareBundleSummary): string {
getShareUrl(bundle: ShareLinkBundleSummary): string {
const apiURL = new URL(environment.apiBaseUrl)
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
bundle.slug
}`
}
copy(bundle: ShareBundleSummary): void {
copy(bundle: ShareLinkBundleSummary): void {
const success = this.clipboard.copy(this.getShareUrl(bundle))
if (success) {
this.copied = true
@ -108,11 +108,11 @@ export class ShareBundleDialogComponent extends ConfirmDialogComponent {
}
}
statusLabel(status: ShareBundleSummary['status']): string {
return SHARE_BUNDLE_STATUS_LABELS[status] ?? status
statusLabel(status: ShareLinkBundleSummary['status']): string {
return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
}
fileVersionLabel(version: FileVersion): string {
return SHARE_BUNDLE_FILE_VERSION_LABELS[version] ?? version
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
}
}

View File

@ -7,7 +7,7 @@
@if (loading) {
<div class="d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span i18n>Loading bulk share links…</span>
<span i18n>Loading share link bundles…</span>
</div>
}
@if (!loading && error) {
@ -22,7 +22,7 @@
</p>
</div>
@if (bundles.length === 0) {
<p class="mb-0 text-muted fst-italic" i18n>No bulk share links currently exist.</p>
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
}
@if (bundles.length > 0) {
<div class="table-responsive">
@ -92,7 +92,7 @@
@if (copiedSlug !== bundle.slug) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy link</span>
<span class="visually-hidden" i18n>Copy share link</span>
</button>
@if (bundle.status === statuses.Failed) {
<button
@ -112,7 +112,7 @@
(click)="delete(bundle)"
>
<i-bs name="trash"></i-bs>
<span class="visually-hidden" i18n>Delete link</span>
<span class="visually-hidden" i18n>Delete share link bundle</span>
</button>
</div>
</td>

View File

@ -0,0 +1,7 @@
describe('ShareLinkBundleManageDialogComponent', () => {
it('is pending implementation', () => {
pending(
'ShareLinkBundleManageDialogComponent tests will be implemented once the dialog logic is finalized.'
)
})
})

View File

@ -4,40 +4,40 @@ 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 {
SHARE_BUNDLE_FILE_VERSION_LABELS,
SHARE_BUNDLE_STATUS_LABELS,
ShareBundleStatus,
ShareBundleSummary,
} from 'src/app/data/share-bundle'
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 { ShareBundleService } from 'src/app/services/rest/share-bundle.service'
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-bundle-manage-dialog',
templateUrl: './share-bundle-manage-dialog.component.html',
selector: 'pngx-share-link-bundle-manage-dialog',
templateUrl: './share-link-bundle-manage-dialog.component.html',
imports: [CommonModule, NgxBootstrapIconsModule, FileSizePipe],
})
export class ShareBundleManageDialogComponent
export class ShareLinkBundleManageDialogComponent
extends LoadingComponentWithPermissions
implements OnInit, OnDestroy
{
private activeModal = inject(NgbActiveModal)
private shareBundleService = inject(ShareBundleService)
private shareLinkBundleService = inject(ShareLinkBundleService)
private toastService = inject(ToastService)
private clipboard = inject(Clipboard)
title = $localize`Bulk Share Links`
title = $localize`Share link bundles`
bundles: ShareBundleSummary[] = []
bundles: ShareLinkBundleSummary[] = []
error: string | null = null
copiedSlug: string | null = null
readonly statuses = ShareBundleStatus
readonly statuses = ShareLinkBundleStatus
readonly fileVersions = FileVersion
private readonly refresh$ = new Subject<boolean>()
@ -50,14 +50,14 @@ export class ShareBundleManageDialogComponent
this.loading = true
}
this.error = null
return this.shareBundleService.listAllBundles().pipe(
return this.shareLinkBundleService.listAllBundles().pipe(
catchError((error) => {
if (!silent) {
this.loading = false
}
this.error = $localize`Failed to load bulk share links.`
this.error = $localize`Failed to load share link bundles.`
this.toastService.showError(
$localize`Error retrieving bulk share links.`,
$localize`Error retrieving share link bundles.`,
error
)
return of(null)
@ -84,15 +84,15 @@ export class ShareBundleManageDialogComponent
super.ngOnDestroy()
}
getShareUrl(bundle: ShareBundleSummary): string {
getShareUrl(bundle: ShareLinkBundleSummary): string {
const apiURL = new URL(environment.apiBaseUrl)
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
bundle.slug
}`
}
copy(bundle: ShareBundleSummary): void {
if (bundle.status !== ShareBundleStatus.Ready) {
copy(bundle: ShareLinkBundleSummary): void {
if (bundle.status !== ShareLinkBundleStatus.Ready) {
return
}
const success = this.clipboard.copy(this.getShareUrl(bundle))
@ -105,30 +105,30 @@ export class ShareBundleManageDialogComponent
}
}
delete(bundle: ShareBundleSummary): void {
delete(bundle: ShareLinkBundleSummary): void {
this.error = null
this.loading = true
this.shareBundleService.delete(bundle).subscribe({
this.shareLinkBundleService.delete(bundle).subscribe({
next: () => {
this.toastService.showInfo($localize`Bulk share link deleted.`)
this.toastService.showInfo($localize`Share link bundle deleted.`)
this.triggerRefresh(false)
},
error: (e) => {
this.loading = false
this.toastService.showError(
$localize`Error deleting bulk share link.`,
$localize`Error deleting share link bundle.`,
e
)
},
})
}
retry(bundle: ShareBundleSummary): void {
retry(bundle: ShareLinkBundleSummary): void {
this.error = null
this.shareBundleService.rebuildBundle(bundle.id).subscribe({
this.shareLinkBundleService.rebuildBundle(bundle.id).subscribe({
next: (updated) => {
this.toastService.showInfo(
$localize`Bulk share link rebuild requested.`
$localize`Share link bundle rebuild requested.`
)
this.replaceBundle(updated)
},
@ -138,19 +138,19 @@ export class ShareBundleManageDialogComponent
})
}
statusLabel(status: ShareBundleStatus): string {
return SHARE_BUNDLE_STATUS_LABELS[status] ?? status
statusLabel(status: ShareLinkBundleStatus): string {
return SHARE_LINK_BUNDLE_STATUS_LABELS[status] ?? status
}
fileVersionLabel(version: FileVersion): string {
return SHARE_BUNDLE_FILE_VERSION_LABELS[version] ?? version
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
}
close(): void {
this.activeModal.close()
}
private replaceBundle(updated: ShareBundleSummary): void {
private replaceBundle(updated: ShareLinkBundleSummary): void {
const index = this.bundles.findIndex((bundle) => bundle.id === updated.id)
if (index >= 0) {
this.bundles = [

View File

@ -112,11 +112,11 @@
</div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="shareSelected()">
<i-bs name="link"></i-bs>&nbsp;<ng-container i18n>Create a share link</ng-container>
<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)="manageShareLinks()">
<i-bs name="list-ul"></i-bs>&nbsp;<ng-container i18n>Manage existing links</ng-container>
<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) {

View File

@ -33,7 +33,7 @@ import {
SelectionDataItem,
} from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { ShareBundleService } from 'src/app/services/rest/share-bundle.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'
@ -55,8 +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 { ShareBundleDialogComponent } from '../../common/share-bundle-dialog/share-bundle-dialog.component'
import { ShareBundleManageDialogComponent } from '../../common/share-bundle-manage-dialog/share-bundle-manage-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'
@ -90,7 +90,7 @@ export class BulkEditorComponent
private customFieldService = inject(CustomFieldsService)
private permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService)
private shareBundleService = inject(ShareBundleService)
private shareLinkBundleService = inject(ShareLinkBundleService)
tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel()
@ -912,12 +912,12 @@ export class BulkEditorComponent
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
}
shareSelected() {
const modal = this.modalService.open(ShareBundleDialogComponent, {
createShareLinkBundle() {
const modal = this.modalService.open(ShareLinkBundleDialogComponent, {
backdrop: 'static',
size: 'lg',
})
const dialog = modal.componentInstance as ShareBundleDialogComponent
const dialog = modal.componentInstance as ShareLinkBundleDialogComponent
const selectedDocuments = this.list.documents.filter((d) =>
this.list.selected.has(d.id)
)
@ -931,7 +931,7 @@ export class BulkEditorComponent
}
dialog.loading = true
dialog.buttonsEnabled = false
this.shareBundleService
this.shareLinkBundleService
.createBundle(payload)
.pipe(first())
.subscribe({
@ -943,17 +943,17 @@ export class BulkEditorComponent
dialog.payload = null
dialog.onOpenManage = () => {
modal.close()
this.manageShareLinks()
this.manageShareLinkBundles()
}
this.toastService.showInfo(
$localize`Bulk share link creation requested.`
$localize`Share link bundle creation requested.`
)
},
error: (error) => {
dialog.loading = false
dialog.buttonsEnabled = true
this.toastService.showError(
$localize`Bulk share link creation is not available yet.`,
$localize`Share link bundle creation is not available yet.`,
error
)
},
@ -961,8 +961,8 @@ export class BulkEditorComponent
})
}
manageShareLinks() {
const modal = this.modalService.open(ShareBundleManageDialogComponent, {
manageShareLinkBundles() {
const modal = this.modalService.open(ShareLinkBundleManageDialogComponent, {
backdrop: 'static',
size: 'lg',
})

View File

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

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

@ -1,38 +0,0 @@
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import {
ShareBundleCreatePayload,
ShareBundleSummary,
} from 'src/app/data/share-bundle'
import { AbstractNameFilterService } from './abstract-name-filter-service'
@Injectable({
providedIn: 'root',
})
export class ShareBundleService extends AbstractNameFilterService<ShareBundleSummary> {
constructor() {
super()
this.resourceName = 'share_bundles'
}
createBundle(
payload: ShareBundleCreatePayload
): Observable<ShareBundleSummary> {
this.clearCache()
return this.http.post<ShareBundleSummary>(this.getResourceUrl(), payload)
}
rebuildBundle(bundleId: number): Observable<ShareBundleSummary> {
this.clearCache()
return this.http.post<ShareBundleSummary>(
this.getResourceUrl(bundleId, 'rebuild'),
{}
)
}
listAllBundles(): Observable<ShareBundleSummary[]> {
return this.list(1, 1000, 'created', true).pipe(
map((response) => response.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

@ -12,8 +12,8 @@ from documents.models import Note
from documents.models import PaperlessTask
from documents.models import SavedView
from documents.models import SavedViewFilterRule
from documents.models import ShareBundle
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
@ -186,7 +186,7 @@ class ShareLinksAdmin(GuardedModelAdmin):
return super().get_queryset(request).select_related("document__correspondent")
class ShareBundleAdmin(GuardedModelAdmin):
class ShareLinkBundleAdmin(GuardedModelAdmin):
list_display = ("created", "status", "expiration", "owner", "slug")
list_filter = ("status", "created", "expiration", "owner")
search_fields = ("slug",)
@ -233,7 +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(ShareBundle, ShareBundleAdmin)
admin.site.register(ShareLinkBundle, ShareLinkBundleAdmin)
admin.site.register(CustomField, CustomFieldsAdmin)
admin.site.register(CustomFieldInstance, CustomFieldInstancesAdmin)

View File

@ -38,8 +38,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 ShareBundle
from documents.models import ShareLink
from documents.models import ShareLinkBundle
from documents.models import StoragePath
from documents.models import Tag
@ -794,11 +794,11 @@ class ShareLinkFilterSet(FilterSet):
}
class ShareBundleFilterSet(FilterSet):
class ShareLinkBundleFilterSet(FilterSet):
documents = Filter(method="filter_documents")
class Meta:
model = ShareBundle
model = ShareLinkBundle
fields = {
"created": DATETIME_KWARGS,
"expiration": DATETIME_KWARGS,

View File

@ -11,7 +11,7 @@ from django.db import migrations
from django.db import models
def grant_sharebundle_permissions(apps, schema_editor):
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
@ -22,27 +22,27 @@ def grant_sharebundle_permissions(apps, schema_editor):
if add_document_perm is None:
return
sharebundle_permissions = Permission.objects.filter(
codename__contains="sharebundle",
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(*sharebundle_permissions)
user.user_permissions.add(*share_bundle_permissions)
groups = Group.objects.filter(permissions=add_document_perm).distinct()
for group in groups:
group.permissions.add(*sharebundle_permissions)
group.permissions.add(*share_bundle_permissions)
def revoke_sharebundle_permissions(apps, schema_editor):
sharebundle_permissions = Permission.objects.filter(
codename__contains="sharebundle",
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(*sharebundle_permissions)
user.user_permissions.remove(*share_bundle_permissions)
for group in Group.objects.all():
group.permissions.remove(*sharebundle_permissions)
group.permissions.remove(*share_bundle_permissions)
class Migration(migrations.Migration):
@ -53,7 +53,7 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name="ShareBundle",
name="ShareLinkBundle",
fields=[
(
"id",
@ -150,7 +150,7 @@ class Migration(migrations.Migration):
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="share_bundles",
related_name="share_link_bundles",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
@ -170,21 +170,21 @@ class Migration(migrations.Migration):
],
options={
"ordering": ("-created",),
"verbose_name": "share bundle",
"verbose_name_plural": "share bundles",
"verbose_name": "share link bundle",
"verbose_name_plural": "share link bundles",
},
),
migrations.AddField(
model_name="sharebundle",
model_name="sharelinkbundle",
name="documents",
field=models.ManyToManyField(
related_name="share_bundles",
related_name="share_link_bundles",
to="documents.document",
verbose_name="documents",
),
),
migrations.RunPython(
grant_sharebundle_permissions,
reverse_code=revoke_sharebundle_permissions,
grant_share_link_bundle_permissions,
reverse_code=revoke_share_link_bundle_permissions,
),
]

View File

@ -777,7 +777,7 @@ class ShareLink(SoftDeleteModel):
return f"Share Link for {self.document.title}"
class ShareBundle(SoftDeleteModel):
class ShareLinkBundle(SoftDeleteModel):
class Status(models.TextChoices):
PENDING = ("pending", _("Pending"))
PROCESSING = ("processing", _("Processing"))
@ -786,8 +786,8 @@ class ShareBundle(SoftDeleteModel):
class Meta:
ordering = ("-created",)
verbose_name = _("share bundle")
verbose_name_plural = _("share bundles")
verbose_name = _("share link bundle")
verbose_name_plural = _("share link bundles")
created = models.DateTimeField(
_("created"),
@ -816,7 +816,7 @@ class ShareBundle(SoftDeleteModel):
User,
blank=True,
null=True,
related_name="share_bundles",
related_name="share_link_bundles",
on_delete=models.SET_NULL,
verbose_name=_("owner"),
)
@ -858,12 +858,12 @@ class ShareBundle(SoftDeleteModel):
documents = models.ManyToManyField(
"documents.Document",
related_name="share_bundles",
related_name="share_link_bundles",
verbose_name=_("documents"),
)
def __str__(self):
return _("Share bundle %(slug)s") % {"slug": self.slug}
return _("Share link bundle %(slug)s") % {"slug": self.slug}
@property
def absolute_file_path(self) -> Path | None:

View File

@ -58,8 +58,8 @@ from documents.models import Note
from documents.models import PaperlessTask
from documents.models import SavedView
from documents.models import SavedViewFilterRule
from documents.models import ShareBundle
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
@ -2130,7 +2130,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
return super().create(validated_data)
class ShareBundleSerializer(OwnedObjectSerializer):
class ShareLinkBundleSerializer(OwnedObjectSerializer):
document_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
allow_empty=False,
@ -2149,7 +2149,7 @@ class ShareBundleSerializer(OwnedObjectSerializer):
document_count = SerializerMethodField()
class Meta:
model = ShareBundle
model = ShareLinkBundle
fields = (
"id",
"created",
@ -2198,7 +2198,7 @@ class ShareBundleSerializer(OwnedObjectSerializer):
else:
validated_data["expiration"] = None
share_bundle = super().create(validated_data)
share_link_bundle = super().create(validated_data)
if documents is None:
documents = list(
@ -2224,12 +2224,12 @@ class ShareBundleSerializer(OwnedObjectSerializer):
)
ordered_documents = [documents_by_id[doc_id] for doc_id in document_ids]
share_bundle.documents.set(ordered_documents)
share_bundle.document_total = len(ordered_documents)
share_link_bundle.documents.set(ordered_documents)
share_link_bundle.document_total = len(ordered_documents)
return share_bundle
return share_link_bundle
def get_document_count(self, obj: ShareBundle) -> int:
def get_document_count(self, obj: ShareLinkBundle) -> int:
count = getattr(obj, "document_total", None)
if count is not None:
return count

View File

@ -43,8 +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 ShareBundle
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
@ -572,17 +572,19 @@ def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
@shared_task
def build_share_bundle(bundle_id: int):
def build_share_link_bundle(bundle_id: int):
try:
bundle = (
ShareBundle.objects.filter(pk=bundle_id).prefetch_related("documents").get()
ShareLinkBundle.objects.filter(pk=bundle_id)
.prefetch_related("documents")
.get()
)
except ShareBundle.DoesNotExist:
logger.warning("Share bundle %s no longer exists.", bundle_id)
except ShareLinkBundle.DoesNotExist:
logger.warning("Share link bundle %s no longer exists.", bundle_id)
return
bundle.remove_file()
bundle.status = ShareBundle.Status.PROCESSING
bundle.status = ShareLinkBundle.Status.PROCESSING
bundle.last_error = ""
bundle.size_bytes = None
bundle.built_at = None
@ -617,7 +619,7 @@ def build_share_bundle(bundle_id: int):
for document in documents:
strategy.add_document(document)
output_dir = settings.SHARE_BUNDLE_DIR
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():
@ -629,7 +631,7 @@ def build_share_bundle(bundle_id: int):
except ValueError:
bundle.file_path = str(final_path)
bundle.size_bytes = final_path.stat().st_size
bundle.status = ShareBundle.Status.READY
bundle.status = ShareLinkBundle.Status.READY
bundle.built_at = timezone.now()
bundle.last_error = ""
bundle.save(
@ -641,10 +643,14 @@ def build_share_bundle(bundle_id: int):
"last_error",
],
)
logger.info("Built share bundle %s", bundle.pk)
logger.info("Built share link bundle %s", bundle.pk)
except Exception as exc:
logger.exception("Failed to build share bundle %s: %s", bundle_id, exc)
bundle.status = ShareBundle.Status.FAILED
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:
@ -661,9 +667,9 @@ def build_share_bundle(bundle_id: int):
@shared_task
def cleanup_expired_share_bundles():
def cleanup_expired_share_link_bundles():
now = timezone.now()
expired_qs = ShareBundle.objects.filter(
expired_qs = ShareLinkBundle.objects.filter(
deleted_at__isnull=True,
expiration__isnull=False,
expiration__lt=now,
@ -675,9 +681,9 @@ def cleanup_expired_share_bundles():
bundle.hard_delete()
except Exception as exc:
logger.warning(
"Failed to delete expired share bundle %s: %s",
"Failed to delete expired share link bundle %s: %s",
bundle.pk,
exc,
)
if count:
logger.info("Deleted %s expired share bundle(s)", count)
logger.info("Deleted %s expired share link bundle(s)", count)

View File

@ -119,7 +119,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 ShareBundleFilterSet
from documents.filters import ShareLinkBundleFilterSet
from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet
@ -135,8 +135,8 @@ from documents.models import DocumentType
from documents.models import Note
from documents.models import PaperlessTask
from documents.models import SavedView
from documents.models import ShareBundle
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
@ -170,7 +170,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 ShareBundleSerializer
from documents.serialisers import ShareLinkBundleSerializer
from documents.serialisers import ShareLinkSerializer
from documents.serialisers import StoragePathSerializer
from documents.serialisers import StoragePathTestSerializer
@ -183,7 +183,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_bundle
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
@ -2604,12 +2604,12 @@ class ShareLinkViewSet(ModelViewSet, PassUserMixin):
ordering_fields = ("created", "expiration", "document")
class ShareBundleViewSet(ModelViewSet, PassUserMixin):
model = ShareBundle
class ShareLinkBundleViewSet(ModelViewSet, PassUserMixin):
model = ShareLinkBundle
queryset = ShareBundle.objects.all()
queryset = ShareLinkBundle.objects.all()
serializer_class = ShareBundleSerializer
serializer_class = ShareLinkBundleSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
@ -2617,7 +2617,7 @@ class ShareBundleViewSet(ModelViewSet, PassUserMixin):
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = ShareBundleFilterSet
filterset_class = ShareLinkBundleFilterSet
ordering_fields = ("created", "expiration", "status")
def get_queryset(self):
@ -2667,7 +2667,7 @@ class ShareBundleViewSet(ModelViewSet, PassUserMixin):
documents=ordered_documents,
)
bundle.remove_file()
bundle.status = ShareBundle.Status.PENDING
bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = ""
bundle.size_bytes = None
bundle.built_at = None
@ -2681,7 +2681,7 @@ class ShareBundleViewSet(ModelViewSet, PassUserMixin):
"file_path",
],
)
build_share_bundle.delay(bundle.pk)
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)
@ -2694,13 +2694,13 @@ class ShareBundleViewSet(ModelViewSet, PassUserMixin):
@action(detail=True, methods=["post"])
def rebuild(self, request, pk=None):
bundle = self.get_object()
if bundle.status == ShareBundle.Status.PROCESSING:
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 = ShareBundle.Status.PENDING
bundle.status = ShareLinkBundle.Status.PENDING
bundle.last_error = ""
bundle.size_bytes = None
bundle.built_at = None
@ -2714,7 +2714,7 @@ class ShareBundleViewSet(ModelViewSet, PassUserMixin):
"file_path",
],
)
build_share_bundle.delay(bundle.pk)
build_share_link_bundle.delay(bundle.pk)
bundle.document_total = (
getattr(bundle, "document_total", None) or bundle.documents.count()
)
@ -2740,35 +2740,32 @@ class SharedLinkView(View):
disposition="inline",
)
share_bundle = ShareBundle.objects.filter(slug=slug).first()
if share_bundle is None:
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
if bundle is None:
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
if (
share_bundle.expiration is not None
and share_bundle.expiration < timezone.now()
):
if bundle.expiration is not None and bundle.expiration < timezone.now():
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
if share_bundle.status in {
ShareBundle.Status.PENDING,
ShareBundle.Status.PROCESSING,
if bundle.status in {
ShareLinkBundle.Status.PENDING,
ShareLinkBundle.Status.PROCESSING,
}:
return HttpResponse(
_(
"The shared bundle is still being prepared. Please try again later.",
"The share link bundle is still being prepared. Please try again later.",
),
status=status.HTTP_202_ACCEPTED,
)
if share_bundle.status == ShareBundle.Status.FAILED:
share_bundle.remove_file()
share_bundle.status = ShareBundle.Status.PENDING
share_bundle.last_error = ""
share_bundle.size_bytes = None
share_bundle.built_at = None
share_bundle.file_path = ""
share_bundle.save(
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",
@ -2777,22 +2774,22 @@ class SharedLinkView(View):
"file_path",
],
)
build_share_bundle.delay(share_bundle.pk)
build_share_link_bundle.delay(bundle.pk)
return HttpResponse(
_(
"The shared bundle is temporarily unavailable. A rebuild has been scheduled. Please try again later.",
"The share link bundle is temporarily unavailable. A rebuild has been scheduled. Please try again later.",
),
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
file_path = share_bundle.absolute_file_path
file_path = bundle.absolute_file_path
if file_path is None or not file_path.exists():
share_bundle.status = ShareBundle.Status.PENDING
share_bundle.last_error = ""
share_bundle.size_bytes = None
share_bundle.built_at = None
share_bundle.file_path = ""
share_bundle.save(
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",
@ -2801,16 +2798,16 @@ class SharedLinkView(View):
"file_path",
],
)
build_share_bundle.delay(share_bundle.pk)
build_share_link_bundle.delay(bundle.pk)
return HttpResponse(
_(
"The shared bundle is being prepared. Please try again later.",
"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 = share_bundle.slug[:12]
short_slug = bundle.slug[:12]
download_name = f"paperless-share-{short_slug}.zip"
filename_normalized = (
normalize("NFKD", download_name)

View File

@ -231,11 +231,11 @@ def _parse_beat_schedule() -> dict:
},
},
{
"name": "Cleanup expired share bundles",
"env_key": "PAPERLESS_SHARE_BUNDLE_CLEANUP_CRON",
"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_bundles",
"task": "documents.tasks.cleanup_expired_share_link_bundles",
"options": {
# 1 hour before default schedule sends again
"expires": 23.0 * 60.0 * 60.0,
@ -279,7 +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_BUNDLE_DIR = MEDIA_ROOT / "documents" / "share_bundles"
SHARE_LINK_BUNDLE_DIR = MEDIA_ROOT / "documents" / "share_link_bundles"
DATA_DIR = __get_path("PAPERLESS_DATA_DIR", BASE_DIR.parent / "data")

View File

@ -29,8 +29,8 @@ from documents.views import RemoteVersionView
from documents.views import SavedViewViewSet
from documents.views import SearchAutoCompleteView
from documents.views import SelectionDataView
from documents.views import ShareBundleViewSet
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
@ -73,7 +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_bundles", ShareBundleViewSet)
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)