Saving work on trash
[skip ci]
This commit is contained in:
parent
06bb218c71
commit
fc1423057d
@ -26,6 +26,7 @@ import { MailComponent } from './components/manage/mail/mail.component'
|
|||||||
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
|
||||||
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||||
import { ConfigComponent } from './components/admin/config/config.component'
|
import { ConfigComponent } from './components/admin/config/config.component'
|
||||||
|
import { TrashComponent } from './components/admin/trash/trash.component'
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
@ -144,6 +145,14 @@ export const routes: Routes = [
|
|||||||
requireAdmin: true,
|
requireAdmin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'trash',
|
||||||
|
component: TrashComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requireAdmin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
// redirect old paths
|
// redirect old paths
|
||||||
{
|
{
|
||||||
path: 'settings/mail',
|
path: 'settings/mail',
|
||||||
|
@ -357,6 +357,7 @@ import localeSv from '@angular/common/locales/sv'
|
|||||||
import localeTr from '@angular/common/locales/tr'
|
import localeTr from '@angular/common/locales/tr'
|
||||||
import localeUk from '@angular/common/locales/uk'
|
import localeUk from '@angular/common/locales/uk'
|
||||||
import localeZh from '@angular/common/locales/zh'
|
import localeZh from '@angular/common/locales/zh'
|
||||||
|
import { TrashComponent } from './components/admin/trash/trash.component'
|
||||||
|
|
||||||
registerLocaleData(localeAf)
|
registerLocaleData(localeAf)
|
||||||
registerLocaleData(localeAr)
|
registerLocaleData(localeAr)
|
||||||
@ -497,6 +498,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
HotkeyDialogComponent,
|
HotkeyDialogComponent,
|
||||||
DeletePagesConfirmDialogComponent,
|
DeletePagesConfirmDialogComponent,
|
||||||
|
TrashComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
|
94
src-ui/src/app/components/admin/trash/trash.component.html
Normal file
94
src-ui/src/app/components/admin/trash/trash.component.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
title="Trash"
|
||||||
|
i18n-title
|
||||||
|
info="Manage trashed items."
|
||||||
|
>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
|
||||||
|
<i-bs name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedObjects)" [disabled]="selectedObjects.size === 0">
|
||||||
|
<i-bs name="trash"></i-bs> <ng-container i18n>Delete objects</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="trashedObjects.length === 0">
|
||||||
|
<i-bs name="trash"></i-bs> <ng-container i18n>Empty trash</ng-container>
|
||||||
|
</button>
|
||||||
|
</pngx-page-header>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="trashedObjects.length" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border table-responsive mb-3">
|
||||||
|
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">
|
||||||
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
|
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="trashedObjects.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||||
|
<label class="form-check-label" for="all-objects"></label>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="fw-normal" i18n>Name</th>
|
||||||
|
<th scope="col" class="fw-normal d-none d-sm-table-cell" i18n>Deleted</th>
|
||||||
|
<th scope="col" class="fw-normal" i18n>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (isLoading) {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
|
<ng-container i18n>Loading...</ng-container>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
@for (object of trashedObjects; track object.id) {
|
||||||
|
<tr (click)="toggleSelected(object); $event.stopPropagation();">
|
||||||
|
<td>
|
||||||
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
|
<input type="checkbox" class="form-check-input" id="{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
|
||||||
|
<label class="form-check-label" for="{{object.id}}"></label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td scope="row">{{ object['name'] ?? object['title'] }}</td>
|
||||||
|
<td scope="row">{{ object['deleted_at'] | customDate }}</td>
|
||||||
|
<td scope="row">
|
||||||
|
<div class="btn-group d-block d-sm-none">
|
||||||
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
|
<i-bs name="three-dots-vertical"></i-bs>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||||
|
<button (click)="restoreObject(object)" ngbDropdownItem i18n>Restore</button>
|
||||||
|
<button (click)="deleteObject(object)" ngbDropdownItem i18n>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group d-none d-sm-block">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="restoreObject(object); $event.stopPropagation();">
|
||||||
|
<i-bs width="1em" height="1em" name="restore"></i-bs> <ng-container i18n>Restore</ng-container>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" (click)="deleteObject(object); $event.stopPropagation();">
|
||||||
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!isLoading) {
|
||||||
|
<div class="d-flex mb-2">
|
||||||
|
<div>
|
||||||
|
<ng-container i18n>{trashedObjects.length, plural, =1 {One object in trash} other {{{trashedObjects.length || 0}} total objects in trash}}</ng-container>
|
||||||
|
@if (selectedObjects.size > 0) {
|
||||||
|
({{selectedObjects.size}} selected)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (trashedObjects.length > 20) {
|
||||||
|
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="trashedObjects.length" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { TrashComponent } from './trash.component'
|
||||||
|
|
||||||
|
describe('TrashComponent', () => {
|
||||||
|
let component: TrashComponent
|
||||||
|
let fixture: ComponentFixture<TrashComponent>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [TrashComponent],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(TrashComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
87
src-ui/src/app/components/admin/trash/trash.component.ts
Normal file
87
src-ui/src/app/components/admin/trash/trash.component.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http'
|
||||||
|
import { Component } from '@angular/core'
|
||||||
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-trash',
|
||||||
|
templateUrl: './trash.component.html',
|
||||||
|
styleUrl: './trash.component.scss',
|
||||||
|
})
|
||||||
|
export class TrashComponent {
|
||||||
|
public trashedObjects: ObjectWithId[] = []
|
||||||
|
public selectedObjects: Set<number> = new Set()
|
||||||
|
public togggleAll: boolean = false
|
||||||
|
public page: number = 1
|
||||||
|
public isLoading: boolean = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private trashService: TrashService,
|
||||||
|
private toastService: ToastService
|
||||||
|
) {
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
this.isLoading = true
|
||||||
|
this.trashService.getTrash().subscribe((trash) => {
|
||||||
|
this.trashedObjects = trash
|
||||||
|
this.isLoading = false
|
||||||
|
console.log('Trash:', trash)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteObject(object: ObjectWithId) {
|
||||||
|
this.trashService.emptyTrash([object.id]).subscribe(() => {
|
||||||
|
this.toastService.showInfo($localize`Object deleted`)
|
||||||
|
this.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyTrash(objects: Set<number> = null) {
|
||||||
|
console.log('Emptying trash')
|
||||||
|
this.trashService
|
||||||
|
.emptyTrash(objects ? Array.from(objects) : [])
|
||||||
|
.subscribe(() => {
|
||||||
|
this.toastService.showInfo($localize`Object(s) deleted`)
|
||||||
|
this.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreObject(object: ObjectWithId) {
|
||||||
|
this.trashService.restoreObjects([object.id]).subscribe(() => {
|
||||||
|
this.toastService.showInfo($localize`Object restored`)
|
||||||
|
this.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreAll(objects: Set<number> = null) {
|
||||||
|
this.trashService
|
||||||
|
.restoreObjects(objects ? Array.from(this.selectedObjects) : [])
|
||||||
|
.subscribe(() => {
|
||||||
|
this.toastService.showInfo($localize`Object(s) restored`)
|
||||||
|
this.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAll(event: PointerEvent) {
|
||||||
|
if ((event.target as HTMLInputElement).checked) {
|
||||||
|
this.selectedObjects = new Set(this.trashedObjects.map((t) => t.id))
|
||||||
|
} else {
|
||||||
|
this.clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelected(object: ObjectWithId) {
|
||||||
|
this.selectedObjects.has(object.id)
|
||||||
|
? this.selectedObjects.delete(object.id)
|
||||||
|
: this.selectedObjects.add(object.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection() {
|
||||||
|
this.togggleAll = false
|
||||||
|
this.selectedObjects.clear()
|
||||||
|
}
|
||||||
|
}
|
@ -267,6 +267,15 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
<!-- @if (permissionsService.isAdmin()) { -->
|
||||||
|
<li class="nav-item app-link">
|
||||||
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="trash"></i-bs><span> <ng-container i18n>Trash</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<!-- } -->
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
|
@ -708,12 +708,10 @@ export class BulkEditorComponent
|
|||||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.delayConfirm(5)
|
|
||||||
modal.componentInstance.title = $localize`Delete confirm`
|
modal.componentInstance.title = $localize`Delete confirm`
|
||||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete ${this.list.selected.size} selected document(s).`
|
modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?`
|
||||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
|
||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Delete document(s)`
|
modal.componentInstance.btnCaption = $localize`Move to trash`
|
||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
|
@ -144,6 +144,8 @@ export interface Document extends ObjectWithPermissions {
|
|||||||
|
|
||||||
added?: Date
|
added?: Date
|
||||||
|
|
||||||
|
deleted_at?: Date
|
||||||
|
|
||||||
original_file_name?: string
|
original_file_name?: string
|
||||||
|
|
||||||
archived_file_name?: string
|
archived_file_name?: string
|
||||||
|
16
src-ui/src/app/services/trash.service.spec.ts
Normal file
16
src-ui/src/app/services/trash.service.spec.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { TrashService } from './trash.service'
|
||||||
|
|
||||||
|
describe('TrashServiceService', () => {
|
||||||
|
let service: TrashService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({})
|
||||||
|
service = TestBed.inject(TrashService)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
30
src-ui/src/app/services/trash.service.ts
Normal file
30
src-ui/src/app/services/trash.service.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http'
|
||||||
|
import { Injectable } from '@angular/core'
|
||||||
|
import { Observable } from 'rxjs'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
import { ObjectWithId } from '../data/object-with-id'
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class TrashService {
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
public getTrash(): Observable<ObjectWithId[]> {
|
||||||
|
return this.http.get<ObjectWithId[]>(`${environment.apiBaseUrl}trash/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
public emptyTrash(documents: number[] = []) {
|
||||||
|
return this.http.post(`${environment.apiBaseUrl}trash/`, {
|
||||||
|
action: 'empty',
|
||||||
|
documents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public restoreObjects(documents: number[]): Observable<any> {
|
||||||
|
return this.http.post(`${environment.apiBaseUrl}trash/`, {
|
||||||
|
action: 'restore',
|
||||||
|
documents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -81,4 +81,74 @@ class Migration(migrations.Migration):
|
|||||||
name="restored_at",
|
name="restored_at",
|
||||||
field=models.DateTimeField(blank=True, null=True),
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="note",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="note",
|
||||||
|
name="restored_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customfield",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customfield",
|
||||||
|
name="restored_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customfieldinstance",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customfieldinstance",
|
||||||
|
name="restored_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="savedviewfilterrule",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="savedviewfilterrule",
|
||||||
|
name="restored_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sharelink",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="sharelink",
|
||||||
|
name="restored_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="restored_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="restored_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -462,7 +462,7 @@ class SavedView(ModelWithOwner):
|
|||||||
return f"SavedView {self.name}"
|
return f"SavedView {self.name}"
|
||||||
|
|
||||||
|
|
||||||
class SavedViewFilterRule(models.Model):
|
class SavedViewFilterRule(SoftDeleteModel):
|
||||||
RULE_TYPES = [
|
RULE_TYPES = [
|
||||||
(0, _("title contains")),
|
(0, _("title contains")),
|
||||||
(1, _("content contains")),
|
(1, _("content contains")),
|
||||||
@ -694,7 +694,7 @@ class PaperlessTask(models.Model):
|
|||||||
return f"Task {self.task_id}"
|
return f"Task {self.task_id}"
|
||||||
|
|
||||||
|
|
||||||
class Note(models.Model):
|
class Note(SoftDeleteModel):
|
||||||
note = models.TextField(
|
note = models.TextField(
|
||||||
_("content"),
|
_("content"),
|
||||||
blank=True,
|
blank=True,
|
||||||
@ -734,7 +734,7 @@ class Note(models.Model):
|
|||||||
return self.note
|
return self.note
|
||||||
|
|
||||||
|
|
||||||
class ShareLink(models.Model):
|
class ShareLink(SoftDeleteModel):
|
||||||
class FileVersion(models.TextChoices):
|
class FileVersion(models.TextChoices):
|
||||||
ARCHIVE = ("archive", _("Archive"))
|
ARCHIVE = ("archive", _("Archive"))
|
||||||
ORIGINAL = ("original", _("Original"))
|
ORIGINAL = ("original", _("Original"))
|
||||||
@ -794,7 +794,7 @@ class ShareLink(models.Model):
|
|||||||
return f"Share Link for {self.document.title}"
|
return f"Share Link for {self.document.title}"
|
||||||
|
|
||||||
|
|
||||||
class CustomField(models.Model):
|
class CustomField(SoftDeleteModel):
|
||||||
"""
|
"""
|
||||||
Defines the name and type of a custom field
|
Defines the name and type of a custom field
|
||||||
"""
|
"""
|
||||||
@ -840,7 +840,7 @@ class CustomField(models.Model):
|
|||||||
return f"{self.name} : {self.data_type}"
|
return f"{self.name} : {self.data_type}"
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldInstance(models.Model):
|
class CustomFieldInstance(SoftDeleteModel):
|
||||||
"""
|
"""
|
||||||
A single instance of a field, attached to a CustomField for the name and type
|
A single instance of a field, attached to a CustomField for the name and type
|
||||||
and attached to a single Document to be metadata for it
|
and attached to a single Document to be metadata for it
|
||||||
@ -941,7 +941,7 @@ if settings.AUDIT_LOG_ENABLED:
|
|||||||
auditlog.register(CustomFieldInstance)
|
auditlog.register(CustomFieldInstance)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowTrigger(models.Model):
|
class WorkflowTrigger(SoftDeleteModel):
|
||||||
class WorkflowTriggerMatching(models.IntegerChoices):
|
class WorkflowTriggerMatching(models.IntegerChoices):
|
||||||
# No auto matching
|
# No auto matching
|
||||||
NONE = MatchingModel.MATCH_NONE, _("None")
|
NONE = MatchingModel.MATCH_NONE, _("None")
|
||||||
@ -1045,7 +1045,7 @@ class WorkflowTrigger(models.Model):
|
|||||||
return f"WorkflowTrigger {self.pk}"
|
return f"WorkflowTrigger {self.pk}"
|
||||||
|
|
||||||
|
|
||||||
class WorkflowAction(models.Model):
|
class WorkflowAction(SoftDeleteModel):
|
||||||
class WorkflowActionType(models.IntegerChoices):
|
class WorkflowActionType(models.IntegerChoices):
|
||||||
ASSIGNMENT = (
|
ASSIGNMENT = (
|
||||||
1,
|
1,
|
||||||
|
@ -786,6 +786,7 @@ class DocumentSerializer(
|
|||||||
"created_date",
|
"created_date",
|
||||||
"modified",
|
"modified",
|
||||||
"added",
|
"added",
|
||||||
|
"deleted_at",
|
||||||
"archive_serial_number",
|
"archive_serial_number",
|
||||||
"original_file_name",
|
"original_file_name",
|
||||||
"archived_file_name",
|
"archived_file_name",
|
||||||
@ -1863,3 +1864,33 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
|||||||
self.prune_triggers_and_actions()
|
self.prune_triggers_and_actions()
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class TrashSerializer(SerializerWithPerms):
|
||||||
|
documents = serializers.ListField(
|
||||||
|
required=True,
|
||||||
|
label="Documents",
|
||||||
|
write_only=True,
|
||||||
|
child=serializers.IntegerField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
action = serializers.ChoiceField(
|
||||||
|
choices=["restore", "empty"],
|
||||||
|
label="Action",
|
||||||
|
write_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_document_id_list(self, documents, name="documents"):
|
||||||
|
if not isinstance(documents, list):
|
||||||
|
raise serializers.ValidationError(f"{name} must be a list")
|
||||||
|
if not all(isinstance(i, int) for i in documents):
|
||||||
|
raise serializers.ValidationError(f"{name} must be a list of integers")
|
||||||
|
count = Document.deleted_objects.filter(id__in=documents).count()
|
||||||
|
if not count == len(documents):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Some documents in {name} have not yet been deleted.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_documents(self, documents):
|
||||||
|
self._validate_document_id_list(documents)
|
||||||
|
return documents
|
||||||
|
@ -303,12 +303,12 @@ def set_storage_path(
|
|||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.post_delete, sender=Document)
|
@receiver(models.signals.post_delete, sender=Document)
|
||||||
def cleanup_document_deletion(sender, instance, **kwargs):
|
def cleanup_document_deletion(sender, instance, force=False, **kwargs):
|
||||||
|
if not force:
|
||||||
now = timezone.localtime(timezone.now())
|
now = timezone.localtime(timezone.now())
|
||||||
if now - instance.deleted_at < timedelta(days=settings.EMPTY_TRASH_DELAY):
|
if now - instance.deleted_at < timedelta(days=settings.EMPTY_TRASH_DELAY):
|
||||||
logger.info(
|
return
|
||||||
f"Detected soft delete of {instance!s}. Deferring cleanup.",
|
# print(instance.pk, force, kwargs)
|
||||||
)
|
|
||||||
return
|
return
|
||||||
with FileLock(settings.MEDIA_LOCK):
|
with FileLock(settings.MEDIA_LOCK):
|
||||||
if settings.TRASH_DIR:
|
if settings.TRASH_DIR:
|
||||||
|
@ -2,6 +2,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -11,7 +12,9 @@ from celery import Task
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.db.models.signals import post_delete
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
|
from django.utils import timezone
|
||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
from whoosh.writing import AsyncWriter
|
from whoosh.writing import AsyncWriter
|
||||||
|
|
||||||
@ -292,3 +295,39 @@ def update_document_archive_file(document_id):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
parser.cleanup()
|
parser.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def empty_trash(doc_ids=None):
|
||||||
|
cutoff = timezone.localtime(timezone.now()) - timedelta(
|
||||||
|
days=settings.EMPTY_TRASH_DELAY,
|
||||||
|
)
|
||||||
|
documents = (
|
||||||
|
Document.deleted_objects.filter(id__in=doc_ids)
|
||||||
|
if doc_ids is not None
|
||||||
|
else Document.deleted_objects.filter(deleted_at__gt=cutoff)
|
||||||
|
)
|
||||||
|
# print(documents, doc_ids)
|
||||||
|
for doc in documents:
|
||||||
|
# with disable_signal(
|
||||||
|
# post_delete,
|
||||||
|
# receiver=cleanup_document_deletion,
|
||||||
|
# sender=Document,
|
||||||
|
# ):
|
||||||
|
doc.delete()
|
||||||
|
post_delete.send(
|
||||||
|
sender=Document,
|
||||||
|
instance=doc,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# messages.log_messages()
|
||||||
|
|
||||||
|
# if messages.has_error:
|
||||||
|
# raise SanityCheckFailedException("Sanity check failed with errors. See log.")
|
||||||
|
# elif messages.has_warning:
|
||||||
|
# return "Sanity check exited with warnings. See log."
|
||||||
|
# elif len(messages) > 0:
|
||||||
|
# return "Sanity check exited with infos. See log."
|
||||||
|
# else:
|
||||||
|
# return "No issues detected."
|
||||||
|
@ -142,12 +142,14 @@ from documents.serialisers import StoragePathSerializer
|
|||||||
from documents.serialisers import TagSerializer
|
from documents.serialisers import TagSerializer
|
||||||
from documents.serialisers import TagSerializerVersion1
|
from documents.serialisers import TagSerializerVersion1
|
||||||
from documents.serialisers import TasksViewSerializer
|
from documents.serialisers import TasksViewSerializer
|
||||||
|
from documents.serialisers import TrashSerializer
|
||||||
from documents.serialisers import UiSettingsViewSerializer
|
from documents.serialisers import UiSettingsViewSerializer
|
||||||
from documents.serialisers import WorkflowActionSerializer
|
from documents.serialisers import WorkflowActionSerializer
|
||||||
from documents.serialisers import WorkflowSerializer
|
from documents.serialisers import WorkflowSerializer
|
||||||
from documents.serialisers import WorkflowTriggerSerializer
|
from documents.serialisers import WorkflowTriggerSerializer
|
||||||
from documents.signals import document_updated
|
from documents.signals import document_updated
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
|
from documents.tasks import empty_trash
|
||||||
from paperless import version
|
from paperless import version
|
||||||
from paperless.celery import app as celery_app
|
from paperless.celery import app as celery_app
|
||||||
from paperless.config import GeneralConfig
|
from paperless.config import GeneralConfig
|
||||||
@ -2050,3 +2052,39 @@ class SystemStatusView(PassUserMixin):
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrashView(PassUserMixin):
|
||||||
|
permission_classes = (IsAuthenticated,)
|
||||||
|
serializer_class = TrashSerializer
|
||||||
|
|
||||||
|
def get(self, request, format=None):
|
||||||
|
user = self.request.user
|
||||||
|
documents = Document.deleted_objects.filter(
|
||||||
|
owner=user,
|
||||||
|
) | Document.deleted_objects.filter(
|
||||||
|
owner=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"request": request,
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = DocumentSerializer(documents, many=True, context=context)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
# user = self.request.user
|
||||||
|
# method = serializer.validated_data.get("method")
|
||||||
|
# parameters = serializer.validated_data.get("parameters")
|
||||||
|
doc_ids = serializer.validated_data.get("documents")
|
||||||
|
action = serializer.validated_data.get("action")
|
||||||
|
if action == "restore":
|
||||||
|
for doc in Document.deleted_objects.filter(id__in=doc_ids).all():
|
||||||
|
doc.restore(strict=False)
|
||||||
|
elif action == "empty":
|
||||||
|
empty_trash(doc_ids=doc_ids)
|
||||||
|
return Response({"result": "OK", "doc_ids": doc_ids})
|
||||||
|
@ -212,7 +212,7 @@ def _parse_beat_schedule() -> dict:
|
|||||||
"env_key": "PAPERLESS_EMPTY_TRASH_TASK_CRON",
|
"env_key": "PAPERLESS_EMPTY_TRASH_TASK_CRON",
|
||||||
# Default daily at 01:00
|
# Default daily at 01:00
|
||||||
"env_default": "0 1 * * *",
|
"env_default": "0 1 * * *",
|
||||||
"task": "documents.tasks.sanity_check",
|
"task": "documents.tasks.empty_trash",
|
||||||
"options": {
|
"options": {
|
||||||
# 1 hour before default schedule sends again
|
# 1 hour before default schedule sends again
|
||||||
"expires": 23.0
|
"expires": 23.0
|
||||||
|
@ -36,6 +36,7 @@ from documents.views import StoragePathViewSet
|
|||||||
from documents.views import SystemStatusView
|
from documents.views import SystemStatusView
|
||||||
from documents.views import TagViewSet
|
from documents.views import TagViewSet
|
||||||
from documents.views import TasksViewSet
|
from documents.views import TasksViewSet
|
||||||
|
from documents.views import TrashView
|
||||||
from documents.views import UiSettingsView
|
from documents.views import UiSettingsView
|
||||||
from documents.views import UnifiedSearchViewSet
|
from documents.views import UnifiedSearchViewSet
|
||||||
from documents.views import WorkflowActionViewSet
|
from documents.views import WorkflowActionViewSet
|
||||||
@ -159,6 +160,11 @@ urlpatterns = [
|
|||||||
SystemStatusView.as_view(),
|
SystemStatusView.as_view(),
|
||||||
name="system_status",
|
name="system_status",
|
||||||
),
|
),
|
||||||
|
re_path(
|
||||||
|
"^trash/",
|
||||||
|
TrashView.as_view(),
|
||||||
|
name="trash",
|
||||||
|
),
|
||||||
*api_router.urls,
|
*api_router.urls,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user