| 
 | 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