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 { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||
import { ConfigComponent } from './components/admin/config/config.component'
|
||||
import { TrashComponent } from './components/admin/trash/trash.component'
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
@ -144,6 +145,14 @@ export const routes: Routes = [
|
||||
requireAdmin: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'trash',
|
||||
component: TrashComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requireAdmin: true,
|
||||
},
|
||||
},
|
||||
// redirect old paths
|
||||
{
|
||||
path: 'settings/mail',
|
||||
|
@ -357,6 +357,7 @@ import localeSv from '@angular/common/locales/sv'
|
||||
import localeTr from '@angular/common/locales/tr'
|
||||
import localeUk from '@angular/common/locales/uk'
|
||||
import localeZh from '@angular/common/locales/zh'
|
||||
import { TrashComponent } from './components/admin/trash/trash.component'
|
||||
|
||||
registerLocaleData(localeAf)
|
||||
registerLocaleData(localeAr)
|
||||
@ -497,6 +498,7 @@ function initializeApp(settings: SettingsService) {
|
||||
GlobalSearchComponent,
|
||||
HotkeyDialogComponent,
|
||||
DeletePagesConfirmDialogComponent,
|
||||
TrashComponent,
|
||||
],
|
||||
imports: [
|
||||
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>
|
||||
</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">
|
||||
<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"
|
||||
|
@ -708,12 +708,10 @@ export class BulkEditorComponent
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.delayConfirm(5)
|
||||
modal.componentInstance.title = $localize`Delete confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete ${this.list.selected.size} selected document(s).`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.messageBold = $localize`Move ${this.list.selected.size} selected document(s) to the trash?`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Delete document(s)`
|
||||
modal.componentInstance.btnCaption = $localize`Move to trash`
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
|
@ -144,6 +144,8 @@ export interface Document extends ObjectWithPermissions {
|
||||
|
||||
added?: Date
|
||||
|
||||
deleted_at?: Date
|
||||
|
||||
original_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",
|
||||
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}"
|
||||
|
||||
|
||||
class SavedViewFilterRule(models.Model):
|
||||
class SavedViewFilterRule(SoftDeleteModel):
|
||||
RULE_TYPES = [
|
||||
(0, _("title contains")),
|
||||
(1, _("content contains")),
|
||||
@ -694,7 +694,7 @@ class PaperlessTask(models.Model):
|
||||
return f"Task {self.task_id}"
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
class Note(SoftDeleteModel):
|
||||
note = models.TextField(
|
||||
_("content"),
|
||||
blank=True,
|
||||
@ -734,7 +734,7 @@ class Note(models.Model):
|
||||
return self.note
|
||||
|
||||
|
||||
class ShareLink(models.Model):
|
||||
class ShareLink(SoftDeleteModel):
|
||||
class FileVersion(models.TextChoices):
|
||||
ARCHIVE = ("archive", _("Archive"))
|
||||
ORIGINAL = ("original", _("Original"))
|
||||
@ -794,7 +794,7 @@ class ShareLink(models.Model):
|
||||
return f"Share Link for {self.document.title}"
|
||||
|
||||
|
||||
class CustomField(models.Model):
|
||||
class CustomField(SoftDeleteModel):
|
||||
"""
|
||||
Defines the name and type of a custom field
|
||||
"""
|
||||
@ -840,7 +840,7 @@ class CustomField(models.Model):
|
||||
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
|
||||
and attached to a single Document to be metadata for it
|
||||
@ -941,7 +941,7 @@ if settings.AUDIT_LOG_ENABLED:
|
||||
auditlog.register(CustomFieldInstance)
|
||||
|
||||
|
||||
class WorkflowTrigger(models.Model):
|
||||
class WorkflowTrigger(SoftDeleteModel):
|
||||
class WorkflowTriggerMatching(models.IntegerChoices):
|
||||
# No auto matching
|
||||
NONE = MatchingModel.MATCH_NONE, _("None")
|
||||
@ -1045,7 +1045,7 @@ class WorkflowTrigger(models.Model):
|
||||
return f"WorkflowTrigger {self.pk}"
|
||||
|
||||
|
||||
class WorkflowAction(models.Model):
|
||||
class WorkflowAction(SoftDeleteModel):
|
||||
class WorkflowActionType(models.IntegerChoices):
|
||||
ASSIGNMENT = (
|
||||
1,
|
||||
|
@ -786,6 +786,7 @@ class DocumentSerializer(
|
||||
"created_date",
|
||||
"modified",
|
||||
"added",
|
||||
"deleted_at",
|
||||
"archive_serial_number",
|
||||
"original_file_name",
|
||||
"archived_file_name",
|
||||
@ -1863,3 +1864,33 @@ class WorkflowSerializer(serializers.ModelSerializer):
|
||||
self.prune_triggers_and_actions()
|
||||
|
||||
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,13 +303,13 @@ def set_storage_path(
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Document)
|
||||
def cleanup_document_deletion(sender, instance, **kwargs):
|
||||
now = timezone.localtime(timezone.now())
|
||||
if now - instance.deleted_at < timedelta(days=settings.EMPTY_TRASH_DELAY):
|
||||
logger.info(
|
||||
f"Detected soft delete of {instance!s}. Deferring cleanup.",
|
||||
)
|
||||
return
|
||||
def cleanup_document_deletion(sender, instance, force=False, **kwargs):
|
||||
if not force:
|
||||
now = timezone.localtime(timezone.now())
|
||||
if now - instance.deleted_at < timedelta(days=settings.EMPTY_TRASH_DELAY):
|
||||
return
|
||||
# print(instance.pk, force, kwargs)
|
||||
return
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
if settings.TRASH_DIR:
|
||||
# Find a non-conflicting filename in case a document with the same
|
||||
|
@ -2,6 +2,7 @@ import hashlib
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Optional
|
||||
@ -11,7 +12,9 @@ from celery import Task
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_delete
|
||||
from django.db.models.signals import post_save
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from whoosh.writing import AsyncWriter
|
||||
|
||||
@ -292,3 +295,39 @@ def update_document_archive_file(document_id):
|
||||
)
|
||||
finally:
|
||||
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 TagSerializerVersion1
|
||||
from documents.serialisers import TasksViewSerializer
|
||||
from documents.serialisers import TrashSerializer
|
||||
from documents.serialisers import UiSettingsViewSerializer
|
||||
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 consume_file
|
||||
from documents.tasks import empty_trash
|
||||
from paperless import version
|
||||
from paperless.celery import app as celery_app
|
||||
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",
|
||||
# Default daily at 01:00
|
||||
"env_default": "0 1 * * *",
|
||||
"task": "documents.tasks.sanity_check",
|
||||
"task": "documents.tasks.empty_trash",
|
||||
"options": {
|
||||
# 1 hour before default schedule sends again
|
||||
"expires": 23.0
|
||||
|
@ -36,6 +36,7 @@ from documents.views import StoragePathViewSet
|
||||
from documents.views import SystemStatusView
|
||||
from documents.views import TagViewSet
|
||||
from documents.views import TasksViewSet
|
||||
from documents.views import TrashView
|
||||
from documents.views import UiSettingsView
|
||||
from documents.views import UnifiedSearchViewSet
|
||||
from documents.views import WorkflowActionViewSet
|
||||
@ -159,6 +160,11 @@ urlpatterns = [
|
||||
SystemStatusView.as_view(),
|
||||
name="system_status",
|
||||
),
|
||||
re_path(
|
||||
"^trash/",
|
||||
TrashView.as_view(),
|
||||
name="trash",
|
||||
),
|
||||
*api_router.urls,
|
||||
],
|
||||
),
|
||||
|
Loading…
x
Reference in New Issue
Block a user