Saving work on trash

[skip ci]
This commit is contained in:
shamoon 2024-04-23 00:07:33 -07:00
parent 06bb218c71
commit fc1423057d
19 changed files with 472 additions and 19 deletions

View File

@ -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',

View File

@ -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,

View 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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<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>&nbsp;<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) {
&nbsp;({{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>
}

View File

@ -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()
})
})

View 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()
}
}

View File

@ -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>&nbsp;<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"

View File

@ -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(() => {

View File

@ -144,6 +144,8 @@ export interface Document extends ObjectWithPermissions {
added?: Date
deleted_at?: Date
original_file_name?: string
archived_file_name?: string

View 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()
})
})

View 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,
})
}
}

View File

@ -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),
),
]

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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."

View File

@ -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})

View File

@ -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

View File

@ -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,
],
),