mirror of
https://github.com/wger-project/wger.git
synced 2026-02-18 00:17:51 +01:00
Merge pull request #2077 from saadpy0/gallery-image-validation-command
feat: add management command to check gallery images for animation
This commit is contained in:
13
wger/gallery/management/__init__.py
Normal file
13
wger/gallery/management/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# This file is part of wger Workout Manager.
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
13
wger/gallery/management/commands/__init__.py
Normal file
13
wger/gallery/management/commands/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# This file is part of wger Workout Manager.
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
282
wger/gallery/management/commands/check_gallery_images.py
Normal file
282
wger/gallery/management/commands/check_gallery_images.py
Normal file
@@ -0,0 +1,282 @@
|
||||
# This file is part of wger Workout Manager.
|
||||
#
|
||||
# wger Workout Manager is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# wger Workout Manager is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
|
||||
# Standard Library
|
||||
import os
|
||||
from argparse import RawTextHelpFormatter
|
||||
|
||||
# Django
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
# Third Party
|
||||
from PIL import (
|
||||
Image,
|
||||
UnidentifiedImageError,
|
||||
)
|
||||
|
||||
# wger
|
||||
from wger.gallery.models import Image as GalleryImage
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Check existing gallery images in the database for validation issues.
|
||||
|
||||
This command validates all existing gallery images against the same rules
|
||||
that would be applied to new uploads, including:
|
||||
- File size limits (20MB max)
|
||||
- Supported formats (JPEG, PNG, WEBP)
|
||||
- Static images only (no animated images)
|
||||
- File accessibility
|
||||
"""
|
||||
|
||||
help = (
|
||||
'Check existing gallery images in the database for validation issues.\n\n'
|
||||
'This command will:\n'
|
||||
'- Check if image files exist on disk\n'
|
||||
'- Validate file size (max 20MB)\n'
|
||||
'- Validate supported formats (JPEG, PNG, WEBP)\n'
|
||||
'- Check for animated images (reject animated WEBP)\n'
|
||||
'- Report any issues found\n\n'
|
||||
'Use --delete-invalid to remove invalid images from the database.\n'
|
||||
'Use --dry-run to see what would be done without making changes.'
|
||||
)
|
||||
|
||||
def create_parser(self, *args, **kwargs):
|
||||
parser = super(Command, self).create_parser(*args, **kwargs)
|
||||
parser.formatter_class = RawTextHelpFormatter
|
||||
return parser
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
dest='dry_run',
|
||||
default=False,
|
||||
help='Show what would be done without making any changes',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--delete-invalid',
|
||||
action='store_true',
|
||||
dest='delete_invalid',
|
||||
default=False,
|
||||
help='Delete invalid images from the database and filesystem',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--user-id',
|
||||
type=int,
|
||||
dest='user_id',
|
||||
help='Check only images for a specific user ID',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
dest='verbose',
|
||||
default=False,
|
||||
help='Show detailed output for each image processed',
|
||||
)
|
||||
|
||||
def handle(self, **options):
|
||||
"""
|
||||
Process the gallery images validation
|
||||
"""
|
||||
dry_run = options['dry_run']
|
||||
delete_invalid = options['delete_invalid']
|
||||
user_id = options['user_id']
|
||||
verbose = options['verbose']
|
||||
|
||||
if delete_invalid and dry_run:
|
||||
self.stdout.write(self.style.ERROR('Cannot use --delete-invalid with --dry-run'))
|
||||
return
|
||||
|
||||
# Get the queryset
|
||||
queryset = GalleryImage.objects.all()
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
total_images = queryset.count()
|
||||
if total_images == 0:
|
||||
self.stdout.write('No gallery images found to check.')
|
||||
return
|
||||
|
||||
self.stdout.write(f'Checking {total_images} gallery images...')
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('DRY RUN MODE - No changes will be made'))
|
||||
|
||||
# Statistics
|
||||
valid_count = 0
|
||||
invalid_count = 0
|
||||
missing_files = 0
|
||||
animated_images = 0
|
||||
invalid_formats = 0
|
||||
oversized_files = 0
|
||||
corrupted_files = 0
|
||||
|
||||
for gallery_image in queryset:
|
||||
try:
|
||||
result = self._validate_image(gallery_image, verbose)
|
||||
|
||||
if result['valid']:
|
||||
valid_count += 1
|
||||
|
||||
else:
|
||||
invalid_count += 1
|
||||
issue_type = result['issue_type']
|
||||
|
||||
if issue_type == 'missing_file':
|
||||
missing_files += 1
|
||||
elif issue_type == 'animated':
|
||||
animated_images += 1
|
||||
elif issue_type == 'invalid_format':
|
||||
invalid_formats += 1
|
||||
elif issue_type == 'oversized':
|
||||
oversized_files += 1
|
||||
elif issue_type == 'corrupted':
|
||||
corrupted_files += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'✗ Image {gallery_image.id}: {result["message"]}')
|
||||
)
|
||||
|
||||
# Delete invalid images if requested
|
||||
if delete_invalid:
|
||||
self._delete_invalid_image(gallery_image, result['issue_type'])
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'✗ Image {gallery_image.id}: Unexpected error - {str(e)}')
|
||||
)
|
||||
invalid_count += 1
|
||||
|
||||
# Summary
|
||||
self.stdout.write('\n' + '=' * 50)
|
||||
self.stdout.write('VALIDATION SUMMARY')
|
||||
self.stdout.write('=' * 50)
|
||||
self.stdout.write(f'Total images checked: {total_images}')
|
||||
self.stdout.write(f'Valid images: {valid_count}')
|
||||
self.stdout.write(f'Invalid images: {invalid_count}')
|
||||
|
||||
if invalid_count > 0:
|
||||
self.stdout.write('\nInvalid image breakdown:')
|
||||
if missing_files > 0:
|
||||
self.stdout.write(f' - Missing files: {missing_files}')
|
||||
if animated_images > 0:
|
||||
self.stdout.write(f' - Animated images: {animated_images}')
|
||||
if invalid_formats > 0:
|
||||
self.stdout.write(f' - Invalid formats: {invalid_formats}')
|
||||
if oversized_files > 0:
|
||||
self.stdout.write(f' - Oversized files: {oversized_files}')
|
||||
if corrupted_files > 0:
|
||||
self.stdout.write(f' - Corrupted files: {corrupted_files}')
|
||||
|
||||
if delete_invalid and invalid_count > 0:
|
||||
self.stdout.write(f'\n{invalid_count} invalid images have been deleted.')
|
||||
|
||||
def _validate_image(self, gallery_image, verbose=False):
|
||||
"""
|
||||
Validate a single gallery image against the validation rules
|
||||
"""
|
||||
# Check if file exists
|
||||
if not gallery_image.image or not gallery_image.image.name:
|
||||
return {
|
||||
'valid': False,
|
||||
'issue_type': 'missing_file',
|
||||
'message': 'No image file associated with this record',
|
||||
}
|
||||
|
||||
# Check if file exists on disk
|
||||
try:
|
||||
file_path = gallery_image.image.path
|
||||
if not os.path.exists(file_path):
|
||||
return {
|
||||
'valid': False,
|
||||
'issue_type': 'missing_file',
|
||||
'message': f'File not found on disk: {file_path}',
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'valid': False,
|
||||
'issue_type': 'missing_file',
|
||||
'message': f'Cannot access file: {str(e)}',
|
||||
}
|
||||
|
||||
# File size check (20MB max)
|
||||
MAX_FILE_SIZE_MB = 20
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size > 1024 * 1024 * MAX_FILE_SIZE_MB:
|
||||
return {
|
||||
'valid': False,
|
||||
'issue_type': 'oversized',
|
||||
'message': f'File too large: {file_size / (1024 * 1024):.1f}MB (max {MAX_FILE_SIZE_MB}MB)',
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'valid': False,
|
||||
'issue_type': 'corrupted',
|
||||
'message': f'Cannot determine file size: {str(e)}',
|
||||
}
|
||||
|
||||
# Try opening the file with PIL
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
img = Image.open(f)
|
||||
img_format = img.format.lower() if img.format else 'unknown'
|
||||
except UnidentifiedImageError:
|
||||
return {
|
||||
'valid': False,
|
||||
'issue_type': 'corrupted',
|
||||
'message': 'File is not a valid image',
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'valid': False,
|
||||
'issue_type': 'corrupted',
|
||||
'message': f'Cannot open image: {str(e)}',
|
||||
}
|
||||
|
||||
# Supported types
|
||||
allowed_formats = {'jpeg', 'jpg', 'png', 'webp'}
|
||||
if img_format not in allowed_formats:
|
||||
return {
|
||||
'valid': False,
|
||||
'issue_type': 'invalid_format',
|
||||
'message': f'Unsupported format: {img_format} (allowed: {", ".join(allowed_formats)})',
|
||||
}
|
||||
|
||||
# Check for animation
|
||||
if img_format == 'webp' and getattr(img, 'is_animated', False):
|
||||
return {
|
||||
'valid': False,
|
||||
'issue_type': 'animated',
|
||||
'message': 'Animated images are not supported',
|
||||
}
|
||||
|
||||
return {'valid': True, 'issue_type': None, 'message': 'Valid image'}
|
||||
|
||||
def _delete_invalid_image(self, gallery_image, issue_type):
|
||||
"""
|
||||
Delete an invalid image from the database and filesystem
|
||||
"""
|
||||
try:
|
||||
# The model has a post_delete signal that will handle file cleanup
|
||||
gallery_image.delete()
|
||||
self.stdout.write(f' -> Deleted image {gallery_image.id}')
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f' -> Failed to delete image {gallery_image.id}: {str(e)}')
|
||||
)
|
||||
Reference in New Issue
Block a user