Saving work on trash
[skip ci]
This commit is contained in:
@@ -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,
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user