diff --git a/Pipfile b/Pipfile index da26987cf..948e22273 100644 --- a/Pipfile +++ b/Pipfile @@ -17,6 +17,7 @@ django-extensions = "*" django-filter = "~=24.2" django-guardian = "*" django-multiselectfield = "*" +django-soft-delete = "*" djangorestframework = "==3.14.0" djangorestframework-guardian = "*" drf-writable-nested = "*" diff --git a/Pipfile.lock b/Pipfile.lock index f3753a0cd..5746eaafc 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -540,6 +540,15 @@ "index": "pypi", "version": "==0.1.12" }, + "django-soft-delete": { + "hashes": [ + "sha256:2b28ab95d18847ea301c46f6f172ea878490fdf12261c697ec7e44b9d652f866", + "sha256:5afe70c0f5ce066c0e287c39806fb949ab66e73305a11b88d4c533247f3b96f0" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==1.0.12" + }, "djangorestframework": { "hashes": [ "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8", diff --git a/docs/configuration.md b/docs/configuration.md index f4c271ce1..b2b2b925a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1362,6 +1362,20 @@ processing. This only has an effect if Defaults to false. +## Trash + +#### [`EMPTY_TRASH_DELAY=`](#EMPTY_TRASH_DELAY) {#EMPTY_TRASH_DELAY} + +: Sets how long in days objects remain in the 'trash' before they are permanently deleted. + + Defaults to 30 days, minimum of 1 day. + +#### [`PAPERLESS_EMPTY_TRASH_TASK_CRON=`](#PAPERLESS_EMPTY_TRASH_TASK_CRON) {#PAPERLESS_EMPTY_TRASH_TASK_CRON} + +: Configures the schedule to empty the trash of expired deleted objects. + + Defaults to `0 1 * * *`, once per day. + ## Binaries There are a few external software packages that Paperless expects to 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 new file mode 100644 index 000000000..203cfe8e8 --- /dev/null +++ b/src/documents/migrations/1047_correspondent_deleted_at_correspondent_restored_at_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.11 on 2024-04-22 20:44 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("documents", "1046_workflowaction_remove_all_correspondents_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="correspondent", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="correspondent", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="document", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="document", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="documenttype", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="documenttype", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="savedview", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="savedview", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="storagepath", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="storagepath", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="tag", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="tag", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="workflow", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="workflow", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 12fd6f63d..c2231a404 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -23,11 +23,13 @@ from multiselectfield import MultiSelectField if settings.AUDIT_LOG_ENABLED: from auditlog.registry import auditlog +from django_softdelete.models import SoftDeleteModel + from documents.data_models import DocumentSource from documents.parsers import get_default_file_extension -class ModelWithOwner(models.Model): +class ModelWithOwner(SoftDeleteModel): owner = models.ForeignKey( User, blank=True, @@ -1262,7 +1264,7 @@ class WorkflowAction(models.Model): return f"WorkflowAction {self.pk}" -class Workflow(models.Model): +class Workflow(SoftDeleteModel): name = models.CharField(_("name"), max_length=256, unique=True) order = models.IntegerField(_("order"), default=0) diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 68f366f44..3836c6c90 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -1,6 +1,7 @@ import logging import os import shutil +from datetime import timedelta from typing import Optional from celery import states @@ -302,7 +303,13 @@ def set_storage_path( @receiver(models.signals.post_delete, sender=Document) -def cleanup_document_deletion(sender, instance, using, **kwargs): +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 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/paperless/settings.py b/src/paperless/settings.py index c4e70f68a..450daf840 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -207,6 +207,19 @@ def _parse_beat_schedule() -> dict: "expires": ((7.0 * 24.0) - 1.0) * 60.0 * 60.0, }, }, + { + "name": "Empty trash", + "env_key": "PAPERLESS_EMPTY_TRASH_TASK_CRON", + # Default daily at 01:00 + "env_default": "0 1 * * *", + "task": "documents.tasks.sanity_check", + "options": { + # 1 hour before default schedule sends again + "expires": 23.0 + * 60.0 + * 60.0, + }, + }, ] for task in tasks: # Either get the environment setting or use the default @@ -1148,3 +1161,9 @@ EMAIL_SUBJECT_PREFIX: Final[str] = "[Paperless-ngx] " if DEBUG: # pragma: no cover EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend" EMAIL_FILE_PATH = BASE_DIR / "sent_emails" + + +############################################################################### +# Soft Delete +############################################################################### +EMPTY_TRASH_DELAY = max(__get_int("PAPERLESS_SOFT_DELETE_DELAY", 30), 1) diff --git a/src/paperless_mail/migrations/0024_mailaccount_deleted_at_mailaccount_restored_at_and_more.py b/src/paperless_mail/migrations/0024_mailaccount_deleted_at_mailaccount_restored_at_and_more.py new file mode 100644 index 000000000..671971695 --- /dev/null +++ b/src/paperless_mail/migrations/0024_mailaccount_deleted_at_mailaccount_restored_at_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.11 on 2024-04-22 20:44 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("paperless_mail", "0023_remove_mailrule_filter_attachment_filename_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="mailaccount", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="mailaccount", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="mailrule", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="mailrule", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="processedmail", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="processedmail", + name="restored_at", + field=models.DateTimeField(blank=True, null=True), + ), + ]