mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-04 14:45:40 +00:00
✨(backend) add command to clean pending and deleted files
Since we have added custom backgrounds, they could be soft deleted but were never really deleted. This commits introduces a command to actually delete this files. In introduces also a command to purge pending upload .
This commit is contained in:
@@ -8,6 +8,11 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) add command to clean pending and deleted files
|
||||||
|
- 🧱(helm) run clean files command as cronjob
|
||||||
|
|
||||||
## [1.21.0] - 2026-06-15
|
## [1.21.0] - 2026-06-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1187,7 +1187,10 @@ class FileViewSet(
|
|||||||
serializer.save(creator=self.request.user)
|
serializer.save(creator=self.request.user)
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
"""Override to implement a soft delete instead of dumping the record in database."""
|
"""Override to implement a soft delete instead of dumping the record in database.
|
||||||
|
|
||||||
|
Files are actually purged by commands that should run periodically.
|
||||||
|
"""
|
||||||
instance.soft_delete()
|
instance.soft_delete()
|
||||||
|
|
||||||
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Clean stale pending files that were never fully uploaded."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.models import File, FileUploadStateChoices
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Remove pending files older than a given threshold."""
|
||||||
|
|
||||||
|
help = "Delete pending files that have been stuck for too long"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--hours",
|
||||||
|
type=int,
|
||||||
|
default=24,
|
||||||
|
help="Age threshold in hours (default: 24)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
hours = options["hours"]
|
||||||
|
if hours < 0:
|
||||||
|
raise CommandError("Hours must be greater than 0")
|
||||||
|
|
||||||
|
threshold = timezone.now() - timedelta(hours=hours)
|
||||||
|
|
||||||
|
files = File.objects.filter(
|
||||||
|
upload_state=FileUploadStateChoices.PENDING,
|
||||||
|
created_at__lt=threshold,
|
||||||
|
hard_deleted_at__isnull=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for file in files.iterator():
|
||||||
|
# This check shouldn't happen, but just in case we do it to avoid an error
|
||||||
|
if not file.deleted_at:
|
||||||
|
file.soft_delete()
|
||||||
|
file.hard_delete()
|
||||||
|
process_file_deletion(file.id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.stdout.write(f"Cleaned {count} stale pending file(s).")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Purge deleted files."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.models import File
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""
|
||||||
|
Purge deleted files (object storage and database object):
|
||||||
|
- files marked as hard deleted in database
|
||||||
|
- files marked as soft deleted and for which the trashbin retention period has expired
|
||||||
|
"""
|
||||||
|
|
||||||
|
help = "Purge deleted files"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""Browse purgeable files and queue them through the file deletion task."""
|
||||||
|
|
||||||
|
is_hard_deleted = Q(hard_deleted_at__isnull=False)
|
||||||
|
is_purgeable = Q(
|
||||||
|
deleted_at__lte=timezone.now()
|
||||||
|
- timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for file in File.objects.filter(is_hard_deleted | is_purgeable).iterator():
|
||||||
|
if file.hard_deleted_at is None:
|
||||||
|
file.hard_delete()
|
||||||
|
|
||||||
|
process_file_deletion.delay(file.id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.stdout.write(f"Purged {count} deleted file(s).")
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Tests for the clean_pending_files management command."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_no_stale_files():
|
||||||
|
"""Nothing happens when there are no stale pending files."""
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_recent_pending_not_deleted():
|
||||||
|
"""Recent pending files (within threshold) should not be deleted."""
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is None
|
||||||
|
assert default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_old_pending_deleted():
|
||||||
|
"""Pending files older than the threshold should be deleted."""
|
||||||
|
old_date = timezone.now() - timedelta(hours=49)
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
assert default_storage.exists(file.file_key)
|
||||||
|
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||||
|
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||||
|
assert not default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_old_non_pending_not_deleted():
|
||||||
|
"""Old files that are not pending should not be deleted."""
|
||||||
|
old_date = timezone.now() - timedelta(hours=49)
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.READY,
|
||||||
|
)
|
||||||
|
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||||
|
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is None
|
||||||
|
assert file.hard_deleted_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_custom_hours():
|
||||||
|
"""The --hours argument controls the age threshold."""
|
||||||
|
old_date = timezone.now() - timedelta(hours=10)
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||||
|
|
||||||
|
# Default 24h threshold -> file not deleted
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is None
|
||||||
|
assert default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
# 8h threshold -> file deleted
|
||||||
|
call_command("clean_pending_files", "--hours=8")
|
||||||
|
|
||||||
|
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||||
|
assert not default_storage.exists(file.file_key)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for the purge_deleted_files management command."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from io import StringIO
|
||||||
|
from random import randint
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_deleted_files_no_deleted_files(django_assert_num_queries):
|
||||||
|
"""Nothing happens when there are no purgeable files."""
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
call_command("purge_deleted_files")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
def test_purge_deleted_files_success(settings):
|
||||||
|
"""
|
||||||
|
Queue deletion for:
|
||||||
|
- hard-deleted files
|
||||||
|
- soft-deleted files past retention period + grace period.
|
||||||
|
"""
|
||||||
|
out = StringIO()
|
||||||
|
|
||||||
|
settings.FILE_PURGE_GRACE_DAYS = grace = randint(1, 20)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
purge_now = now - timedelta(days=grace)
|
||||||
|
|
||||||
|
not_deleted_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("django.utils.timezone.now", return_value=now):
|
||||||
|
not_purgeable_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
not_purgeable_file.soft_delete()
|
||||||
|
|
||||||
|
with patch("django.utils.timezone.now", return_value=purge_now):
|
||||||
|
purgeable_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
purgeable_file.soft_delete()
|
||||||
|
|
||||||
|
hard_deleted_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
hard_deleted_file.soft_delete()
|
||||||
|
hard_deleted_file.hard_delete()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"core.management.commands.purge_deleted_files.process_file_deletion.delay",
|
||||||
|
side_effect=process_file_deletion,
|
||||||
|
) as mock_delay:
|
||||||
|
call_command("purge_deleted_files", stdout=out)
|
||||||
|
|
||||||
|
assert "Purged 2 deleted file(s)." in out.getvalue()
|
||||||
|
assert mock_delay.call_count == 2
|
||||||
|
called_ids = {call.args[0] for call in mock_delay.call_args_list}
|
||||||
|
assert called_ids == {purgeable_file.id, hard_deleted_file.id}
|
||||||
|
|
||||||
|
assert models.File.objects.filter(id=not_deleted_file.id).exists()
|
||||||
|
assert models.File.objects.filter(id=not_purgeable_file.id).exists()
|
||||||
|
assert not models.File.objects.filter(id=purgeable_file.id).exists()
|
||||||
|
assert not models.File.objects.filter(id=hard_deleted_file.id).exists()
|
||||||
|
|
||||||
|
assert default_storage.exists(not_deleted_file.file_key)
|
||||||
|
assert default_storage.exists(not_purgeable_file.file_key)
|
||||||
|
assert not default_storage.exists(purgeable_file.file_key)
|
||||||
|
assert not default_storage.exists(hard_deleted_file.file_key)
|
||||||
@@ -182,7 +182,9 @@ class Base(Configuration):
|
|||||||
environ_name="FILE_UPLOAD_ENABLED",
|
environ_name="FILE_UPLOAD_ENABLED",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
FILE_PURGE_GRACE_DAYS = values.PositiveIntegerValue(
|
||||||
|
default=7, environ_name="FILE_PURGE_GRACE_DAYS", environ_prefix=None
|
||||||
|
)
|
||||||
FILE_UPLOAD_PATH = values.Value(
|
FILE_UPLOAD_PATH = values.Value(
|
||||||
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
|
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user