Skip to content

Commit 074c27f

Browse files
committed
refactor: enhance output messages and improve storage handling in filer_check
1 parent 12c1dce commit 074c27f

File tree

2 files changed

+140
-41
lines changed

2 files changed

+140
-41
lines changed

filer/management/commands/filer_check.py

+25-20
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ def handle(self, *args, **options):
6767
"\nThis will delete missing file references from the database.\n"
6868
"Type 'yes' to continue, or 'no' to cancel: "
6969
) != 'yes':
70-
self.stdout.write("Aborted: Missing file references were not deleted.")
70+
self.stdout.write("Aborted: Missing file references were not deleted.\n")
71+
self.stdout.flush()
7172
return
7273
self.verify_references(options)
7374

@@ -77,7 +78,8 @@ def handle(self, *args, **options):
7778
"\nThis will delete orphaned files from storage.\n"
7879
"Type 'yes' to continue, or 'no' to cancel: "
7980
) != 'yes':
80-
self.stdout.write("Aborted: Orphaned files were not deleted.")
81+
self.stdout.write("Aborted: Orphaned files were not deleted.\n")
82+
self.stdout.flush()
8183
return
8284
self.verify_storages(options)
8385

@@ -86,7 +88,7 @@ def handle(self, *args, **options):
8688

8789
def verify_references(self, options):
8890
"""
89-
Checks that every file reference in the database exists in the storage.
91+
Checks that every file reference in the database exists in storage.
9092
If a file is missing, either report it or delete the reference based on the provided options.
9193
"""
9294
for file in File.objects.all():
@@ -95,21 +97,24 @@ def verify_references(self, options):
9597
file.delete()
9698
verbose_msg = f"Deleted missing file reference '{file.folder}/{file}' from the database."
9799
else:
98-
verbose_msg = f"File reference '{file.folder}/{file}' is missing in the storage."
99-
# For higher verbosity, print the full verbose message.
100+
verbose_msg = f"File reference '{file.folder}/{file}' is missing in storage."
100101
if options.get('verbosity', 1) > 2:
101-
self.stdout.write(verbose_msg)
102-
# Otherwise, just output the relative file path.
102+
self.stdout.write(verbose_msg + "\n")
103+
self.stdout.flush()
103104
elif options.get('verbosity'):
104-
self.stdout.write(os.path.join(str(file.folder), str(file)))
105+
self.stdout.write(os.path.join(str(file.folder), str(file)) + "\n")
106+
self.stdout.flush()
105107

106108
def verify_storages(self, options):
107109
"""
108-
Scans all storages defined in FILER_STORAGES (e.g. public and private)
110+
Scans all storages defined in FILER_STORAGES (e.g., public and private)
109111
for orphaned files, then reports or deletes them based on the options.
110112
"""
111113

112114
def walk(storage, prefix, label_prefix):
115+
# If the directory does not exist, there is nothing to scan
116+
if not storage.exists(prefix):
117+
return
113118
child_dirs, files = storage.listdir(prefix)
114119
for filename in files:
115120
actual_path = os.path.join(prefix, filename)
@@ -121,26 +126,25 @@ def walk(storage, prefix, label_prefix):
121126
else:
122127
message = f"Found orphaned file '{relfilename}'"
123128
if options.get('verbosity', 1) > 2:
124-
self.stdout.write(message)
129+
self.stdout.write(message + "\n")
130+
self.stdout.flush()
125131
elif options.get('verbosity'):
126-
self.stdout.write(relfilename)
132+
self.stdout.write(relfilename + "\n")
133+
self.stdout.flush()
127134
for child in child_dirs:
128135
walk(storage, os.path.join(prefix, child), os.path.join(label_prefix, child))
129136

130-
# Loop through each storage configuration (e.g. public, private, etc.)
137+
# Loop through each storage configuration (e.g., public, private, etc.)
131138
for storage_name, storage_config in filer_settings.FILER_STORAGES.items():
132139
storage_settings = storage_config.get('main')
133140
if not storage_settings:
134141
continue
135142
storage = import_string(storage_settings['ENGINE'])()
136143
if storage_settings.get('OPTIONS', {}).get('location'):
137144
storage.location = storage_settings['OPTIONS']['location']
138-
# Use a label prefix: for public and private storages, use their names.
139-
if storage_name in ['public', 'private']:
140-
label_prefix = storage_name
141-
else:
142-
label_prefix = storage_settings['UPLOAD_TO_PREFIX']
143-
walk(storage, storage_settings['UPLOAD_TO_PREFIX'], label_prefix)
145+
# Set label_prefix: for public and private storages, use their names.
146+
label_prefix = storage_name if storage_name in ['public', 'private'] else storage_settings.get('UPLOAD_TO_PREFIX', '')
147+
walk(storage, storage_settings.get('UPLOAD_TO_PREFIX', ''), label_prefix)
144148

145149
def image_dimensions(self, options):
146150
"""
@@ -154,9 +158,10 @@ def image_dimensions(self, options):
154158

155159
ImageModel = load_model(filer_settings.FILER_IMAGE_MODEL)
156160
images_without_dimensions = ImageModel.objects.filter(Q(_width=0) | Q(_width__isnull=True))
157-
self.stdout.write(f"Setting dimensions for {images_without_dimensions.count()} images")
161+
self.stdout.write(f"Setting dimensions for {images_without_dimensions.count()} images" + "\n")
162+
self.stdout.flush()
158163
for image in images_without_dimensions:
159-
file_holder = image.file_ptr if hasattr(image, 'file_ptr') and image.file_ptr else image
164+
file_holder = image.file_ptr if getattr(image, 'file_ptr', None) else image
160165
try:
161166
imgfile = file_holder.file
162167
imgfile.seek(0)

tests/test_filer_check.py

+115-21
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import os
22
import shutil
3+
import tempfile
34
from io import BytesIO, StringIO
45

56
from django.core.files.uploadedfile import SimpleUploadedFile
67
from django.core.management import call_command
78
from django.test import TestCase
89
from django.utils.module_loading import import_string
10+
from django.core.files.base import ContentFile
911

1012
from filer import settings as filer_settings
1113
from filer.models.filemodels import File
@@ -27,13 +29,18 @@ class FilerCheckTestCase(TestCase):
2729
</svg>"""
2830

2931
def setUp(self):
30-
# Clean up the public folder to avoid interference between tests.
31-
public_settings = filer_settings.FILER_STORAGES['public']['main']
32-
storage = import_string(public_settings['ENGINE'])()
33-
upload_prefix = public_settings['UPLOAD_TO_PREFIX']
34-
if storage.exists(upload_prefix):
35-
shutil.rmtree(storage.path(upload_prefix))
32+
# Clear all configured storages to ensure a clean state for each test.
33+
# This prevents interference from files left in any storage.
34+
for storage_alias, storage_configs in filer_settings.FILER_STORAGES.items():
35+
config = storage_configs.get('main')
36+
if not config:
37+
continue
38+
storage = import_string(config['ENGINE'])()
39+
upload_prefix = config.get('UPLOAD_TO_PREFIX', '')
40+
if storage.exists(upload_prefix):
41+
shutil.rmtree(storage.path(upload_prefix))
3642

43+
# Create a sample file for testing in the public storage.
3744
original_filename = 'testimage.jpg'
3845
file_obj = SimpleUploadedFile(
3946
name=original_filename,
@@ -55,8 +62,10 @@ def test_delete_missing(self):
5562
call_command('filer_check', stdout=out, missing=True)
5663
self.assertEqual('', out.getvalue())
5764

65+
# Remove the file to simulate a missing file.
5866
os.remove(self.filer_file.file.path)
5967
call_command('filer_check', stdout=out, missing=True)
68+
# When verbosity is low, a simple relative file path is output.
6069
self.assertEqual("None/testimage.jpg\n", out.getvalue())
6170
self.assertIsInstance(File.objects.get(id=file_pk), File)
6271

@@ -65,26 +74,36 @@ def test_delete_missing(self):
6574
File.objects.get(id=file_pk)
6675

6776
def test_delete_orphans_public(self):
77+
# First check - should be empty initially
6878
out = StringIO()
69-
self.assertTrue(os.path.exists(self.filer_file.file.path))
70-
call_command('filer_check', stdout=out, orphans=True)
71-
# The public folder should be free of orphaned files.
79+
call_command('filer_check', stdout=out, orphans=True, verbosity=1)
7280
self.assertEqual('', out.getvalue())
7381

74-
# Add an orphan file to the public storage.
82+
# Add an orphan file using the storage API directly
7583
public_settings = filer_settings.FILER_STORAGES['public']['main']
7684
storage = import_string(public_settings['ENGINE'])()
77-
public_path = storage.path(public_settings['UPLOAD_TO_PREFIX'])
78-
orphan_file = os.path.join(public_path, 'hello.txt')
79-
os.makedirs(public_path, exist_ok=True)
80-
with open(orphan_file, 'w') as fh:
81-
fh.write("I don't belong here!")
82-
call_command('filer_check', stdout=out, orphans=True)
85+
86+
# Configure storage location if specified in settings
87+
if public_settings.get('OPTIONS', {}).get('location'):
88+
storage.location = public_settings['OPTIONS']['location']
89+
90+
# Get upload prefix and create file path
91+
prefix = public_settings.get('UPLOAD_TO_PREFIX', '')
92+
file_path = 'hello.txt'
93+
rel_path = os.path.join(prefix, file_path) if prefix else file_path
94+
95+
# Save file through storage API
96+
storage.save(rel_path, ContentFile(b"I don't belong here!"))
97+
self.assertTrue(storage.exists(rel_path))
98+
99+
# Check if orphan is detected
100+
out = StringIO()
101+
call_command('filer_check', stdout=out, orphans=True, verbosity=1)
83102
self.assertEqual("public/hello.txt\n", out.getvalue())
84-
self.assertTrue(os.path.exists(orphan_file))
85103

104+
# Delete orphans
86105
call_command('filer_check', delete_orphans=True, interactive=False, verbosity=0)
87-
self.assertFalse(os.path.exists(orphan_file))
106+
self.assertFalse(storage.exists(rel_path))
88107

89108
def test_delete_orphans_private(self):
90109
# Skip test if private storage is not configured.
@@ -94,15 +113,16 @@ def test_delete_orphans_private(self):
94113
out = StringIO()
95114
private_settings = filer_settings.FILER_STORAGES['private']['main']
96115
storage = import_string(private_settings['ENGINE'])()
116+
# Set storage location if defined in OPTIONS.
97117
if private_settings.get('OPTIONS', {}).get('location'):
98118
storage.location = private_settings['OPTIONS']['location']
99-
private_path = storage.path(private_settings['UPLOAD_TO_PREFIX'])
119+
private_path = storage.path(private_settings.get('UPLOAD_TO_PREFIX', ''))
100120
os.makedirs(private_path, exist_ok=True)
101121

102122
orphan_file = os.path.join(private_path, 'private_orphan.txt')
103123
with open(orphan_file, 'w') as fh:
104124
fh.write("I don't belong here!")
105-
# Verify that the command detects the orphan file.
125+
# Run the command and check that it detects the private orphan file.
106126
call_command('filer_check', stdout=out, orphans=True)
107127
self.assertIn("private_orphan.txt", out.getvalue())
108128
self.assertTrue(os.path.exists(orphan_file))
@@ -111,11 +131,84 @@ def test_delete_orphans_private(self):
111131
call_command('filer_check', delete_orphans=True, interactive=False, verbosity=0)
112132
self.assertFalse(os.path.exists(orphan_file))
113133

134+
def test_delete_orphans_multiple_storages(self):
135+
"""
136+
Test that the filer_check command correctly handles orphaned files in multiple storages
137+
without permanently modifying the settings. We use monkey-patching to assign temporary
138+
directories to the storage configurations.
139+
"""
140+
out = StringIO()
141+
142+
# --- Monkey-patch public storage location ---
143+
public_config = filer_settings.FILER_STORAGES['public']['main']
144+
temp_public_dir = tempfile.mkdtemp()
145+
if 'OPTIONS' in public_config:
146+
public_config['OPTIONS']['location'] = temp_public_dir
147+
else:
148+
public_config['OPTIONS'] = {'location': temp_public_dir}
149+
# Determine the upload prefix (if any) and ensure the corresponding directory exists.
150+
public_upload_prefix = public_config.get('UPLOAD_TO_PREFIX', '')
151+
if public_upload_prefix:
152+
public_full_dir = os.path.join(temp_public_dir, public_upload_prefix)
153+
else:
154+
public_full_dir = temp_public_dir
155+
os.makedirs(public_full_dir, exist_ok=True)
156+
157+
# --- Monkey-patch private storage location ---
158+
private_config = filer_settings.FILER_STORAGES.get('private', {}).get('main')
159+
if private_config:
160+
temp_private_dir = tempfile.mkdtemp()
161+
if 'OPTIONS' in private_config:
162+
private_config['OPTIONS']['location'] = temp_private_dir
163+
else:
164+
private_config['OPTIONS'] = {'location': temp_private_dir}
165+
private_upload_prefix = private_config.get('UPLOAD_TO_PREFIX', '')
166+
if private_upload_prefix:
167+
private_full_dir = os.path.join(temp_private_dir, private_upload_prefix)
168+
else:
169+
private_full_dir = temp_private_dir
170+
os.makedirs(private_full_dir, exist_ok=True)
171+
else:
172+
self.skipTest("Private storage not configured in FILER_STORAGES.")
173+
174+
# --- Initialize storages using the patched locations ---
175+
from django.core.files.storage import FileSystemStorage
176+
storage_public = FileSystemStorage(location=temp_public_dir)
177+
storage_private = FileSystemStorage(location=private_config['OPTIONS']['location'])
178+
179+
# --- Save dummy orphan files in both storages ---
180+
# For public storage, include the upload prefix in the filename if needed.
181+
if public_upload_prefix:
182+
filename_public = os.path.join(public_upload_prefix, 'orphan_public.txt')
183+
else:
184+
filename_public = 'orphan_public.txt'
185+
if private_config.get('UPLOAD_TO_PREFIX', ''):
186+
filename_private = os.path.join(private_config['UPLOAD_TO_PREFIX'], 'orphan_private.txt')
187+
else:
188+
filename_private = 'orphan_private.txt'
189+
190+
storage_public.save(filename_public, ContentFile(b"dummy content"))
191+
storage_private.save(filename_private, ContentFile(b"dummy content"))
192+
193+
# --- Run the filer_check command ---
194+
call_command('filer_check', stdout=out, orphans=True)
195+
output = out.getvalue()
196+
197+
# Verify that the output contains indicators for both storages.
198+
self.assertIn('public', output)
199+
self.assertIn('private', output)
200+
201+
# --- Clean up ---
202+
storage_public.delete(filename_public)
203+
storage_private.delete(filename_private)
204+
shutil.rmtree(temp_public_dir)
205+
shutil.rmtree(private_config['OPTIONS']['location'])
206+
114207
def test_image_dimensions_corrupted_file(self):
115208
original_filename = 'testimage.jpg'
116209
file_obj = SimpleUploadedFile(
117210
name=original_filename,
118-
content=create_image().tobytes(), # corrupted file
211+
content=create_image().tobytes(), # Simulate a corrupted file.
119212
content_type='image/jpeg'
120213
)
121214
self.filer_image = Image.objects.create(
@@ -189,3 +282,4 @@ def test_image_dimensions_svg(self):
189282
call_command('filer_check', image_dimensions=True)
190283
self.filer_image.refresh_from_db()
191284
self.assertGreater(self.filer_image._width, 0)
285+

0 commit comments

Comments
 (0)