Skip to content

Commit c7f9094

Browse files
authored
Merge pull request #2077 from saadpy0/gallery-image-validation-command
feat: add management command to check gallery images for animation
2 parents 9da01e5 + 6487941 commit c7f9094

File tree

3 files changed

+308
-0
lines changed

3 files changed

+308
-0
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# This file is part of wger Workout Manager.
2+
#
3+
# wger Workout Manager is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU Affero General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# wger Workout Manager is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU Affero General Public License
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# This file is part of wger Workout Manager.
2+
#
3+
# wger Workout Manager is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU Affero General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# wger Workout Manager is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU Affero General Public License
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
# This file is part of wger Workout Manager.
2+
#
3+
# wger Workout Manager is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU Affero General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# wger Workout Manager is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU Affero General Public License
14+
15+
# Standard Library
16+
import os
17+
from argparse import RawTextHelpFormatter
18+
19+
# Django
20+
from django.core.management.base import BaseCommand
21+
22+
# Third Party
23+
from PIL import (
24+
Image,
25+
UnidentifiedImageError,
26+
)
27+
28+
# wger
29+
from wger.gallery.models import Image as GalleryImage
30+
31+
32+
class Command(BaseCommand):
33+
"""
34+
Check existing gallery images in the database for validation issues.
35+
36+
This command validates all existing gallery images against the same rules
37+
that would be applied to new uploads, including:
38+
- File size limits (20MB max)
39+
- Supported formats (JPEG, PNG, WEBP)
40+
- Static images only (no animated images)
41+
- File accessibility
42+
"""
43+
44+
help = (
45+
'Check existing gallery images in the database for validation issues.\n\n'
46+
'This command will:\n'
47+
'- Check if image files exist on disk\n'
48+
'- Validate file size (max 20MB)\n'
49+
'- Validate supported formats (JPEG, PNG, WEBP)\n'
50+
'- Check for animated images (reject animated WEBP)\n'
51+
'- Report any issues found\n\n'
52+
'Use --delete-invalid to remove invalid images from the database.\n'
53+
'Use --dry-run to see what would be done without making changes.'
54+
)
55+
56+
def create_parser(self, *args, **kwargs):
57+
parser = super(Command, self).create_parser(*args, **kwargs)
58+
parser.formatter_class = RawTextHelpFormatter
59+
return parser
60+
61+
def add_arguments(self, parser):
62+
parser.add_argument(
63+
'--dry-run',
64+
action='store_true',
65+
dest='dry_run',
66+
default=False,
67+
help='Show what would be done without making any changes',
68+
)
69+
70+
parser.add_argument(
71+
'--delete-invalid',
72+
action='store_true',
73+
dest='delete_invalid',
74+
default=False,
75+
help='Delete invalid images from the database and filesystem',
76+
)
77+
78+
parser.add_argument(
79+
'--user-id',
80+
type=int,
81+
dest='user_id',
82+
help='Check only images for a specific user ID',
83+
)
84+
85+
parser.add_argument(
86+
'--verbose',
87+
action='store_true',
88+
dest='verbose',
89+
default=False,
90+
help='Show detailed output for each image processed',
91+
)
92+
93+
def handle(self, **options):
94+
"""
95+
Process the gallery images validation
96+
"""
97+
dry_run = options['dry_run']
98+
delete_invalid = options['delete_invalid']
99+
user_id = options['user_id']
100+
verbose = options['verbose']
101+
102+
if delete_invalid and dry_run:
103+
self.stdout.write(self.style.ERROR('Cannot use --delete-invalid with --dry-run'))
104+
return
105+
106+
# Get the queryset
107+
queryset = GalleryImage.objects.all()
108+
if user_id:
109+
queryset = queryset.filter(user_id=user_id)
110+
111+
total_images = queryset.count()
112+
if total_images == 0:
113+
self.stdout.write('No gallery images found to check.')
114+
return
115+
116+
self.stdout.write(f'Checking {total_images} gallery images...')
117+
if dry_run:
118+
self.stdout.write(self.style.WARNING('DRY RUN MODE - No changes will be made'))
119+
120+
# Statistics
121+
valid_count = 0
122+
invalid_count = 0
123+
missing_files = 0
124+
animated_images = 0
125+
invalid_formats = 0
126+
oversized_files = 0
127+
corrupted_files = 0
128+
129+
for gallery_image in queryset:
130+
try:
131+
result = self._validate_image(gallery_image, verbose)
132+
133+
if result['valid']:
134+
valid_count += 1
135+
136+
else:
137+
invalid_count += 1
138+
issue_type = result['issue_type']
139+
140+
if issue_type == 'missing_file':
141+
missing_files += 1
142+
elif issue_type == 'animated':
143+
animated_images += 1
144+
elif issue_type == 'invalid_format':
145+
invalid_formats += 1
146+
elif issue_type == 'oversized':
147+
oversized_files += 1
148+
elif issue_type == 'corrupted':
149+
corrupted_files += 1
150+
151+
self.stdout.write(
152+
self.style.ERROR(f'✗ Image {gallery_image.id}: {result["message"]}')
153+
)
154+
155+
# Delete invalid images if requested
156+
if delete_invalid:
157+
self._delete_invalid_image(gallery_image, result['issue_type'])
158+
159+
except Exception as e:
160+
self.stdout.write(
161+
self.style.ERROR(f'✗ Image {gallery_image.id}: Unexpected error - {str(e)}')
162+
)
163+
invalid_count += 1
164+
165+
# Summary
166+
self.stdout.write('\n' + '=' * 50)
167+
self.stdout.write('VALIDATION SUMMARY')
168+
self.stdout.write('=' * 50)
169+
self.stdout.write(f'Total images checked: {total_images}')
170+
self.stdout.write(f'Valid images: {valid_count}')
171+
self.stdout.write(f'Invalid images: {invalid_count}')
172+
173+
if invalid_count > 0:
174+
self.stdout.write('\nInvalid image breakdown:')
175+
if missing_files > 0:
176+
self.stdout.write(f' - Missing files: {missing_files}')
177+
if animated_images > 0:
178+
self.stdout.write(f' - Animated images: {animated_images}')
179+
if invalid_formats > 0:
180+
self.stdout.write(f' - Invalid formats: {invalid_formats}')
181+
if oversized_files > 0:
182+
self.stdout.write(f' - Oversized files: {oversized_files}')
183+
if corrupted_files > 0:
184+
self.stdout.write(f' - Corrupted files: {corrupted_files}')
185+
186+
if delete_invalid and invalid_count > 0:
187+
self.stdout.write(f'\n{invalid_count} invalid images have been deleted.')
188+
189+
def _validate_image(self, gallery_image, verbose=False):
190+
"""
191+
Validate a single gallery image against the validation rules
192+
"""
193+
# Check if file exists
194+
if not gallery_image.image or not gallery_image.image.name:
195+
return {
196+
'valid': False,
197+
'issue_type': 'missing_file',
198+
'message': 'No image file associated with this record',
199+
}
200+
201+
# Check if file exists on disk
202+
try:
203+
file_path = gallery_image.image.path
204+
if not os.path.exists(file_path):
205+
return {
206+
'valid': False,
207+
'issue_type': 'missing_file',
208+
'message': f'File not found on disk: {file_path}',
209+
}
210+
except Exception as e:
211+
return {
212+
'valid': False,
213+
'issue_type': 'missing_file',
214+
'message': f'Cannot access file: {str(e)}',
215+
}
216+
217+
# File size check (20MB max)
218+
MAX_FILE_SIZE_MB = 20
219+
try:
220+
file_size = os.path.getsize(file_path)
221+
if file_size > 1024 * 1024 * MAX_FILE_SIZE_MB:
222+
return {
223+
'valid': False,
224+
'issue_type': 'oversized',
225+
'message': f'File too large: {file_size / (1024 * 1024):.1f}MB (max {MAX_FILE_SIZE_MB}MB)',
226+
}
227+
except Exception as e:
228+
return {
229+
'valid': False,
230+
'issue_type': 'corrupted',
231+
'message': f'Cannot determine file size: {str(e)}',
232+
}
233+
234+
# Try opening the file with PIL
235+
try:
236+
with open(file_path, 'rb') as f:
237+
img = Image.open(f)
238+
img_format = img.format.lower() if img.format else 'unknown'
239+
except UnidentifiedImageError:
240+
return {
241+
'valid': False,
242+
'issue_type': 'corrupted',
243+
'message': 'File is not a valid image',
244+
}
245+
except Exception as e:
246+
return {
247+
'valid': False,
248+
'issue_type': 'corrupted',
249+
'message': f'Cannot open image: {str(e)}',
250+
}
251+
252+
# Supported types
253+
allowed_formats = {'jpeg', 'jpg', 'png', 'webp'}
254+
if img_format not in allowed_formats:
255+
return {
256+
'valid': False,
257+
'issue_type': 'invalid_format',
258+
'message': f'Unsupported format: {img_format} (allowed: {", ".join(allowed_formats)})',
259+
}
260+
261+
# Check for animation
262+
if img_format == 'webp' and getattr(img, 'is_animated', False):
263+
return {
264+
'valid': False,
265+
'issue_type': 'animated',
266+
'message': 'Animated images are not supported',
267+
}
268+
269+
return {'valid': True, 'issue_type': None, 'message': 'Valid image'}
270+
271+
def _delete_invalid_image(self, gallery_image, issue_type):
272+
"""
273+
Delete an invalid image from the database and filesystem
274+
"""
275+
try:
276+
# The model has a post_delete signal that will handle file cleanup
277+
gallery_image.delete()
278+
self.stdout.write(f' -> Deleted image {gallery_image.id}')
279+
except Exception as e:
280+
self.stdout.write(
281+
self.style.ERROR(f' -> Failed to delete image {gallery_image.id}: {str(e)}')
282+
)

0 commit comments

Comments
 (0)