Skip to content

Commit b4926d6

Browse files
bram2wsilvestrid
andauthored
chore(user files): introduced management command to permanently delete user files (baserow#4178)
* Introduced management command to permanently delete user files uploaded by user * added tests * Fix copilot feedback * allow reuploading file * fixed migration * Fix tests --------- Co-authored-by: Davide Silvestri <silvestri.eng@gmail.com>
1 parent 05fd7bb commit b4926d6

File tree

6 files changed

+277
-1
lines changed

6 files changed

+277
-1
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from django.conf import settings
2+
from django.core.management.base import BaseCommand
3+
from django.utils.timezone import now
4+
5+
from baserow.core.models import UserFile
6+
from baserow.core.storage import get_default_storage
7+
from baserow.core.user_files.handler import UserFileHandler
8+
9+
10+
class Command(BaseCommand):
11+
help = (
12+
"This command permanently deletes all the user files uploaded by the provided "
13+
"user. It deletes the files where the `uploaded_by` matches the given `user_id`. "
14+
"Please note that this could lead to a situation where a file is deleted that "
15+
"another user also uploaded, if it's exactly the same file and the original "
16+
"filename was the same."
17+
)
18+
19+
def add_arguments(self, parser):
20+
parser.add_argument(
21+
"user_id",
22+
type=int,
23+
help="The ID of the user whose uploaded files should be permanently deleted.",
24+
)
25+
parser.add_argument(
26+
"--yes",
27+
action="store_true",
28+
help="Skip confirmation prompt and delete immediately.",
29+
)
30+
31+
def handle(self, *args, **options):
32+
user_id = options["user_id"]
33+
skip_confirmation = options["yes"]
34+
35+
storage = get_default_storage()
36+
handler = UserFileHandler()
37+
38+
user_files = UserFile.objects.filter(
39+
uploaded_by_id=user_id, deleted_at__isnull=True
40+
)
41+
count = user_files.count()
42+
43+
self.stdout.write(f"\nFound {count} file(s) uploaded by user ID {user_id}\n")
44+
45+
if count == 0:
46+
return
47+
48+
for user_file in user_files:
49+
self.stdout.write(
50+
f"- {user_file.name} ({user_file.size} bytes, "
51+
f"uploaded {user_file.uploaded_at})"
52+
)
53+
54+
if not skip_confirmation:
55+
confirm = input(
56+
"\nAre you sure you want to permanently delete these files from storage? [y/N]: "
57+
)
58+
if confirm.lower() not in ["y", "yes"]:
59+
self.stdout.write(self.style.WARNING("Aborted by user."))
60+
return
61+
62+
deleted_count = 0
63+
for user_file in user_files:
64+
try:
65+
file_path = handler.user_file_path(user_file)
66+
if storage.exists(file_path):
67+
storage.delete(file_path)
68+
self.stdout.write(f"Deleted: {file_path}")
69+
else:
70+
self.stdout.write(
71+
self.style.WARNING(f"File not found in storage: {file_path}")
72+
)
73+
74+
if getattr(settings, "USER_THUMBNAILS_DIRECTORY", None):
75+
for thumb_name in getattr(settings, "USER_THUMBNAILS", {}).keys():
76+
thumb_path = handler.user_file_thumbnail_path(
77+
user_file, thumb_name
78+
)
79+
if storage.exists(thumb_path):
80+
storage.delete(thumb_path)
81+
self.stdout.write(f"Deleted thumbnail: {thumb_path}")
82+
83+
user_file.deleted_at = now()
84+
user_file.save(update_fields=["deleted_at"])
85+
deleted_count += 1
86+
87+
except Exception as e:
88+
self.stderr.write(
89+
self.style.ERROR(f"Error deleting file {user_file.name}: {e}")
90+
)
91+
92+
self.stdout.write(
93+
self.style.SUCCESS(
94+
f"\nSuccessfully deleted {deleted_count} file(s) for user ID {user_id}."
95+
)
96+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 5.0.14 on 2025-11-06 21:29
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("core", "0108_alter_userfile_unique"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="userfile",
14+
name="deleted_at",
15+
field=models.DateTimeField(blank=True, default=None, null=True),
16+
),
17+
]

backend/src/baserow/core/user_files/handler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,9 @@ def upload_user_file(self, user, file_name, stream, storage=None):
268268
file_name = truncate_middle(file_name, 64)
269269

270270
existing_user_file = UserFile.objects.filter(
271-
original_name=file_name, sha256_hash=stream_hash
271+
original_name=file_name,
272+
sha256_hash=stream_hash,
273+
deleted_at__isnull=True,
272274
).first()
273275

274276
if existing_user_file:

backend/src/baserow/core/user_files/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class UserFile(models.Model):
2222
uploaded_at = models.DateTimeField(auto_now_add=True)
2323
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
2424
sha256_hash = models.CharField(max_length=64, db_index=True)
25+
deleted_at = models.DateTimeField(null=True, blank=True, default=None)
2526

2627
objects = UserFileQuerySet.as_manager()
2728

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from io import StringIO
2+
from unittest.mock import MagicMock, patch
3+
4+
from django.core.management import call_command
5+
6+
import pytest
7+
8+
9+
@pytest.mark.django_db
10+
def test_delete_files_uploaded_by_user_no_files(data_fixture):
11+
user = data_fixture.create_user()
12+
out = StringIO()
13+
14+
call_command("delete_files_uploaded_by_user", user.id, stdout=out)
15+
16+
output = out.getvalue()
17+
assert f"Found 0 file(s) uploaded by user ID {user.id}" in output
18+
19+
20+
@pytest.mark.django_db
21+
def test_delete_files_uploaded_by_user_aborts_on_no_confirmation(data_fixture):
22+
user = data_fixture.create_user()
23+
user_file = data_fixture.create_user_file(uploaded_by=user)
24+
25+
with patch("builtins.input", return_value="n"):
26+
out = StringIO()
27+
call_command("delete_files_uploaded_by_user", user.id, stdout=out)
28+
29+
output = out.getvalue()
30+
assert "Aborted by user." in output
31+
user_file.refresh_from_db()
32+
assert user_file.deleted_at is None
33+
34+
35+
@pytest.mark.django_db
36+
def test_delete_files_uploaded_by_user_deletes_files_with_yes_flag(data_fixture):
37+
user = data_fixture.create_user()
38+
user_file = data_fixture.create_user_file(uploaded_by=user)
39+
40+
mock_storage = MagicMock()
41+
mock_storage.exists.return_value = True
42+
43+
with patch(
44+
"baserow.core.management.commands.delete_files_uploaded_by_user.get_default_storage",
45+
return_value=mock_storage,
46+
):
47+
out = StringIO()
48+
call_command("delete_files_uploaded_by_user", user.id, "--yes", stdout=out)
49+
50+
output = out.getvalue()
51+
user_file.refresh_from_db()
52+
53+
assert "Successfully deleted 1 file(s)" in output
54+
assert user_file.deleted_at is not None
55+
assert mock_storage.delete.called
56+
57+
58+
@pytest.mark.django_db
59+
def test_delete_files_uploaded_by_user_deletes_thumbnails(data_fixture, settings):
60+
user = data_fixture.create_user()
61+
data_fixture.create_user_file(uploaded_by=user)
62+
63+
settings.USER_THUMBNAILS_DIRECTORY = "thumbnails"
64+
settings.USER_THUMBNAILS = {"tiny": [32, 32], "small": [64, 64]}
65+
66+
mock_storage = MagicMock()
67+
mock_storage.exists.return_value = True
68+
69+
with patch(
70+
"baserow.core.management.commands.delete_files_uploaded_by_user.get_default_storage",
71+
return_value=mock_storage,
72+
):
73+
out = StringIO()
74+
call_command("delete_files_uploaded_by_user", user.id, "--yes", stdout=out)
75+
76+
output = out.getvalue()
77+
78+
# Verify main file was deleted
79+
assert "Successfully deleted 1 file(s)" in output
80+
# Verify thumbnails were also deleted
81+
delete_calls = [call[0][0] for call in mock_storage.delete.call_args_list]
82+
assert any("thumbnails/tiny" in path for path in delete_calls)
83+
assert any("thumbnails/small" in path for path in delete_calls)
84+
85+
86+
@pytest.mark.django_db
87+
def test_delete_files_uploaded_by_user_handles_missing_files(data_fixture):
88+
user = data_fixture.create_user()
89+
user_file = data_fixture.create_user_file(uploaded_by=user)
90+
91+
mock_storage = MagicMock()
92+
mock_storage.exists.return_value = False
93+
94+
with patch(
95+
"baserow.core.management.commands.delete_files_uploaded_by_user.get_default_storage",
96+
return_value=mock_storage,
97+
):
98+
out = StringIO()
99+
call_command("delete_files_uploaded_by_user", user.id, "--yes", stdout=out)
100+
101+
output = out.getvalue()
102+
user_file.refresh_from_db()
103+
104+
assert "File not found in storage" in output
105+
assert user_file.deleted_at is not None
106+
107+
108+
@pytest.mark.django_db
109+
def test_delete_files_uploaded_by_user_logs_exceptions_but_continues(data_fixture):
110+
user = data_fixture.create_user()
111+
file1 = data_fixture.create_user_file(uploaded_by=user)
112+
file2 = data_fixture.create_user_file(uploaded_by=user)
113+
114+
mock_storage = MagicMock()
115+
mock_storage.exists.return_value = True
116+
117+
calls = {"count": 0}
118+
119+
def delete_side_effect(*args, **kwargs):
120+
if calls["count"] == 0:
121+
calls["count"] += 1
122+
raise Exception("delete failed")
123+
return None
124+
125+
mock_storage.delete.side_effect = delete_side_effect
126+
127+
with patch(
128+
"baserow.core.management.commands.delete_files_uploaded_by_user.get_default_storage",
129+
return_value=mock_storage,
130+
):
131+
out = StringIO()
132+
err = StringIO()
133+
call_command(
134+
"delete_files_uploaded_by_user",
135+
user.id,
136+
"--yes",
137+
stdout=out,
138+
stderr=err,
139+
)
140+
141+
error_output = err.getvalue()
142+
file1.refresh_from_db()
143+
file2.refresh_from_db()
144+
145+
# The error should be logged for the first file
146+
assert "Error deleting file" in error_output
147+
148+
# file1 failed before marking deleted
149+
assert file1.deleted_at is None
150+
151+
# file2 should succeed and be marked deleted
152+
assert file2.deleted_at is not None
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"type": "feature",
3+
"message": "Introduced management command to permanently delete user files uploaded by user.",
4+
"domain": "core",
5+
"issue_number": null,
6+
"bullet_points": [],
7+
"created_at": "2025-11-06"
8+
}

0 commit comments

Comments
 (0)