diff --git a/src-ui/src/app/app-routing.module.ts b/src-ui/src/app/app-routing.module.ts
index 12b412f67..bbeba9e8a 100644
--- a/src-ui/src/app/app-routing.module.ts
+++ b/src-ui/src/app/app-routing.module.ts
@@ -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',
diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts
index f9e04b069..c4bbc177d 100644
--- a/src-ui/src/app/app.module.ts
+++ b/src-ui/src/app/app.module.ts
@@ -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,
diff --git a/src-ui/src/app/components/admin/trash/trash.component.html b/src-ui/src/app/components/admin/trash/trash.component.html
new file mode 100644
index 000000000..2a2040126
--- /dev/null
+++ b/src-ui/src/app/components/admin/trash/trash.component.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ Name |
+ Deleted |
+ Actions |
+
+
+
+ @if (isLoading) {
+
+
+
+ Loading...
+ |
+
+ }
+ @for (object of trashedObjects; track object.id) {
+
+
+
+
+
+
+ |
+ {{ object['name'] ?? object['title'] }} |
+ {{ object['deleted_at'] | customDate }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ }
+
+
+
+
+@if (!isLoading) {
+
+
+ {trashedObjects.length, plural, =1 {One object in trash} other {{{trashedObjects.length || 0}} total objects in trash}}
+ @if (selectedObjects.size > 0) {
+ ({{selectedObjects.size}} selected)
+ }
+
+ @if (trashedObjects.length > 20) {
+
+ }
+
+}
diff --git a/src-ui/src/app/components/admin/trash/trash.component.scss b/src-ui/src/app/components/admin/trash/trash.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/src-ui/src/app/components/admin/trash/trash.component.spec.ts b/src-ui/src/app/components/admin/trash/trash.component.spec.ts
new file mode 100644
index 000000000..10e60e3e2
--- /dev/null
+++ b/src-ui/src/app/components/admin/trash/trash.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { TrashComponent } from './trash.component'
+
+describe('TrashComponent', () => {
+ let component: TrashComponent
+ let fixture: ComponentFixture
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [TrashComponent],
+ }).compileComponents()
+
+ fixture = TestBed.createComponent(TrashComponent)
+ component = fixture.componentInstance
+ fixture.detectChanges()
+ })
+
+ it('should create', () => {
+ expect(component).toBeTruthy()
+ })
+})
diff --git a/src-ui/src/app/components/admin/trash/trash.component.ts b/src-ui/src/app/components/admin/trash/trash.component.ts
new file mode 100644
index 000000000..89eb92615
--- /dev/null
+++ b/src-ui/src/app/components/admin/trash/trash.component.ts
@@ -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 = 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 = 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 = 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()
+ }
+}
diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html
index ab5759ec0..3695a731c 100644
--- a/src-ui/src/app/components/app-frame/app-frame.component.html
+++ b/src-ui/src/app/components/app-frame/app-frame.component.html
@@ -267,6 +267,15 @@
}
+
+
+
+ Trash
+
+
+
{
diff --git a/src-ui/src/app/data/document.ts b/src-ui/src/app/data/document.ts
index 7b7c6f786..1571d2a53 100644
--- a/src-ui/src/app/data/document.ts
+++ b/src-ui/src/app/data/document.ts
@@ -144,6 +144,8 @@ export interface Document extends ObjectWithPermissions {
added?: Date
+ deleted_at?: Date
+
original_file_name?: string
archived_file_name?: string
diff --git a/src-ui/src/app/services/trash.service.spec.ts b/src-ui/src/app/services/trash.service.spec.ts
new file mode 100644
index 000000000..21813c785
--- /dev/null
+++ b/src-ui/src/app/services/trash.service.spec.ts
@@ -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()
+ })
+})
diff --git a/src-ui/src/app/services/trash.service.ts b/src-ui/src/app/services/trash.service.ts
new file mode 100644
index 000000000..1df3abd5c
--- /dev/null
+++ b/src-ui/src/app/services/trash.service.ts
@@ -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 {
+ return this.http.get(`${environment.apiBaseUrl}trash/`)
+ }
+
+ public emptyTrash(documents: number[] = []) {
+ return this.http.post(`${environment.apiBaseUrl}trash/`, {
+ action: 'empty',
+ documents,
+ })
+ }
+
+ public restoreObjects(documents: number[]): Observable {
+ return this.http.post(`${environment.apiBaseUrl}trash/`, {
+ action: 'restore',
+ documents,
+ })
+ }
+}
diff --git a/src/documents/migrations/1047_correspondent_deleted_at_correspondent_restored_at_and_more.py b/src/documents/migrations/1047_correspondent_deleted_at_correspondent_restored_at_and_more.py
index 203cfe8e8..77f641e4b 100644
--- a/src/documents/migrations/1047_correspondent_deleted_at_correspondent_restored_at_and_more.py
+++ b/src/documents/migrations/1047_correspondent_deleted_at_correspondent_restored_at_and_more.py
@@ -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),
+ ),
]
diff --git a/src/documents/models.py b/src/documents/models.py
index c2231a404..21db78517 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -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,
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 426527183..42361cc20 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -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
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 3836c6c90..8113077ea 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -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
diff --git a/src/documents/tasks.py b/src/documents/tasks.py
index e3b2df990..475021328 100644
--- a/src/documents/tasks.py
+++ b/src/documents/tasks.py
@@ -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."
diff --git a/src/documents/views.py b/src/documents/views.py
index 72414d4f0..50edc45bf 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -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})
diff --git a/src/paperless/settings.py b/src/paperless/settings.py
index 450daf840..3f92f0751 100644
--- a/src/paperless/settings.py
+++ b/src/paperless/settings.py
@@ -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
diff --git a/src/paperless/urls.py b/src/paperless/urls.py
index 8626cc8b1..4de9f3662 100644
--- a/src/paperless/urls.py
+++ b/src/paperless/urls.py
@@ -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,
],
),