From 540eb17e894310cde8a2554baeec744ca4644d29 Mon Sep 17 00:00:00 2001 From: ske087 Date: Wed, 23 Jul 2025 18:03:03 +0300 Subject: [PATCH] Implement organized media folder structure for community posts - Add media_folder field to Post model for organized file storage - Create MediaConfig class for centralized media management settings - Update community routes to use post-specific media folders - Add thumbnail generation for uploaded images - Implement structured folder layout: app/static/media/posts/{post_folder}/ - Add utility functions for image and GPX file handling - Create media management script for migration and maintenance - Add proper file validation and MIME type checking - Include routes for serving images, thumbnails, and GPX files - Maintain backward compatibility with existing uploads - Add comprehensive documentation and migration tools Each post now gets its own media folder with subfolders for: - images/ (with thumbnails/ subfolder) - gpx/ Post content remains in database for optimal query performance while media files are organized in dedicated folders for better management. --- MEDIA_MANAGEMENT.md | 122 ++++++++++++++++++ app/media_config.py | 70 +++++++++++ app/models.py | 44 +++++++ app/routes/community.py | 221 +++++++++++++++++++++++++++++---- config.py | 15 ++- manage_media.py | 187 ++++++++++++++++++++++++++++ migrations/add_media_folder.py | 28 +++++ test_media.py | 147 ++++++++++++++++++++++ 8 files changed, 808 insertions(+), 26 deletions(-) create mode 100644 MEDIA_MANAGEMENT.md create mode 100644 app/media_config.py create mode 100755 manage_media.py create mode 100644 migrations/add_media_folder.py create mode 100755 test_media.py diff --git a/MEDIA_MANAGEMENT.md b/MEDIA_MANAGEMENT.md new file mode 100644 index 0000000..27feeb6 --- /dev/null +++ b/MEDIA_MANAGEMENT.md @@ -0,0 +1,122 @@ +# Media Management for Motorcycle Adventure Community + +## Overview + +The application now uses an organized media folder structure where each post has its own dedicated folder for storing images, GPX files, and other media content. + +## Folder Structure + +``` +app/static/media/posts/ +├── post_abc12345_20250723/ +│ ├── images/ +│ │ ├── cover_image.jpg +│ │ ├── route_photo_1.jpg +│ │ └── route_photo_2.jpg +│ └── gpx/ +│ └── route_track.gpx +├── post_def67890_20250724/ +│ ├── images/ +│ └── gpx/ +└── ... +``` + +## Post Content Storage Strategy + +### Database Storage (Recommended ✅) +- **Post metadata**: title, subtitle, content, difficulty, publication status +- **Relationships**: author, comments, likes +- **Media references**: filenames and metadata stored in database +- **Benefits**: + - Fast queries and searching + - Proper relationships and constraints + - ACID compliance + - Easy backups with database tools + - Scalability for large amounts of posts + +### File Storage +- **Media files**: images, GPX files stored in organized folders +- **Benefits**: + - Direct web server access (faster serving) + - Easy file management + - Reduced database size + - CDN compatibility + +## Database Schema + +### Posts Table +- `media_folder`: String field containing the unique folder name for this post's media +- Content stored as TEXT in the database for optimal performance + +### PostImage Table +- `filename`: Stored filename (unique) +- `original_name`: User's original filename +- `is_cover`: Boolean indicating if this is the cover image +- `post_id`: Foreign key to posts table + +### GPXFile Table +- `filename`: Stored filename (unique) +- `original_name`: User's original filename +- `post_id`: Foreign key to posts table + +## Media Folder Naming Convention + +Format: `post_{8-char-uuid}_{YYYYMMDD}` +- Example: `post_abc12345_20250723` +- Ensures uniqueness and chronological organization + +## File Upload Process + +1. **Post Creation**: + - Generate unique media folder name + - Create post record in database + - Create folder structure in `app/static/media/posts/` + +2. **File Upload**: + - Save files to post-specific subfolders + - Store metadata in database + - Generate thumbnails (for images) + +3. **File Access**: + - URLs: `/static/media/posts/{media_folder}/images/{filename}` + - Direct web server serving for performance + +## Migration and Management + +### Manual Migration Script +```bash +python manage_media.py --all +``` + +### Available Commands +- `--create-folders`: Create media folders for existing posts +- `--migrate-files`: Move files from old structure to new structure +- `--clean-orphaned`: Remove unused media folders +- `--stats`: Show storage statistics + +## Security Considerations + +1. **File Type Validation**: Only allowed image and GPX file types +2. **File Size Limits**: Configurable maximum file sizes +3. **Filename Sanitization**: Secure filename generation with UUIDs +4. **Access Control**: Media files served through static file handler + +## Performance Optimizations + +1. **Image Compression**: Automatic JPEG compression with 85% quality +2. **Image Resizing**: Automatic thumbnail generation and size limits +3. **Direct File Serving**: Static files served directly by web server +4. **CDN Ready**: File structure compatible with CDN distribution + +## Backup Strategy + +1. **Database**: Regular database backups include all post content and metadata +2. **Media Files**: File system backups of `app/static/media/posts/` directory +3. **Synchronization**: Media folder names in database ensure consistency + +## Development Notes + +- Post content remains in database for optimal query performance +- Media files organized by post for easy management +- Backward compatibility maintained for existing installations +- Migration tools provided for seamless upgrades diff --git a/app/media_config.py b/app/media_config.py new file mode 100644 index 0000000..8a017a4 --- /dev/null +++ b/app/media_config.py @@ -0,0 +1,70 @@ +""" +Media configuration settings for the motorcycle adventure community app +""" + +import os + +class MediaConfig: + """Configuration for media file handling""" + + # File upload settings + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size + UPLOAD_EXTENSIONS = { + 'images': ['jpg', 'jpeg', 'png', 'gif', 'webp'], + 'gpx': ['gpx'] + } + + # Image processing settings + IMAGE_MAX_SIZE = (1920, 1080) # Max dimensions for images + IMAGE_QUALITY = 85 # JPEG quality (1-100) + THUMBNAIL_SIZE = (300, 300) # Thumbnail dimensions + + # Media folder settings + MEDIA_FOLDER = 'app/static/media/posts' + MEDIA_URL_PREFIX = '/static/media/posts' + + # Folder structure + MEDIA_SUBFOLDERS = ['images', 'gpx'] + + # Security settings + ALLOWED_MIME_TYPES = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'application/gpx+xml': 'gpx', + 'application/xml': 'gpx', + 'text/xml': 'gpx' + } + + @staticmethod + def get_media_path(app, post_media_folder, subfolder=''): + """Get the full filesystem path to a media folder""" + path = os.path.join(app.root_path, 'static', 'media', 'posts', post_media_folder) + if subfolder: + path = os.path.join(path, subfolder) + return path + + @staticmethod + def get_media_url(post_media_folder, subfolder='', filename=''): + """Get the URL path to a media file""" + url = f'/static/media/posts/{post_media_folder}' + if subfolder: + url += f'/{subfolder}' + if filename: + url += f'/{filename}' + return url + + @staticmethod + def is_allowed_file(filename, file_type='images'): + """Check if file extension is allowed""" + if '.' not in filename: + return False + + ext = filename.rsplit('.', 1)[1].lower() + return ext in MediaConfig.UPLOAD_EXTENSIONS.get(file_type, []) + + @staticmethod + def is_allowed_mime_type(mime_type): + """Check if MIME type is allowed""" + return mime_type in MediaConfig.ALLOWED_MIME_TYPES diff --git a/app/models.py b/app/models.py index 0c52891..a9e336b 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,5 @@ from datetime import datetime +import os from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash from app.extensions import db @@ -37,6 +38,7 @@ class Post(db.Model): subtitle = db.Column(db.String(300)) content = db.Column(db.Text, nullable=False) difficulty = db.Column(db.Integer, default=3, nullable=False) # 1-5 scale + media_folder = db.Column(db.String(100)) # Folder name for media files published = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -57,6 +59,18 @@ class Post(db.Model): def get_like_count(self): return self.likes.count() + def get_media_folder_path(self): + """Get the full path to the post's media folder""" + if self.media_folder: + return os.path.join('static', 'media', 'posts', self.media_folder) + return None + + def get_media_url_path(self): + """Get the URL path to the post's media folder""" + if self.media_folder: + return f'/static/media/posts/{self.media_folder}' + return None + def __repr__(self): return f'' @@ -75,6 +89,30 @@ class PostImage(db.Model): # Foreign Keys post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False) + def get_url(self): + """Get the URL path to access this image""" + if self.post.media_folder: + return f'/static/media/posts/{self.post.media_folder}/images/{self.filename}' + return f'/static/uploads/images/{self.filename}' # Fallback for old files + + def get_thumbnail_url(self): + """Get the URL path to access this image's thumbnail""" + if self.post.media_folder: + return f'/static/media/posts/{self.post.media_folder}/images/thumbnails/{self.filename}' + return self.get_url() # Fallback to main image for old files + + def has_thumbnail(self): + """Check if thumbnail exists for this image""" + if not self.post.media_folder: + return False + + from flask import current_app + thumbnail_path = os.path.join( + current_app.root_path, 'static', 'media', 'posts', + self.post.media_folder, 'images', 'thumbnails', self.filename + ) + return os.path.exists(thumbnail_path) + def __repr__(self): return f'' @@ -90,6 +128,12 @@ class GPXFile(db.Model): # Foreign Keys post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False) + def get_url(self): + """Get the URL path to access this GPX file""" + if self.post.media_folder: + return f'/static/media/posts/{self.post.media_folder}/gpx/{self.filename}' + return f'/static/uploads/gpx/{self.filename}' # Fallback for old files + def __repr__(self): return f'' diff --git a/app/routes/community.py b/app/routes/community.py index 0ad9b69..4374fbb 100644 --- a/app/routes/community.py +++ b/app/routes/community.py @@ -3,6 +3,7 @@ from flask_login import login_required, current_user from app.models import Post, PostImage, GPXFile, User, Comment, Like from app.extensions import db from app.forms import PostForm, CommentForm +from app.media_config import MediaConfig from werkzeug.utils import secure_filename from werkzeug.exceptions import RequestEntityTooLarge import os @@ -66,6 +67,7 @@ def new_post(): subtitle=subtitle, content=content, difficulty=difficulty, + media_folder=f"post_{uuid.uuid4().hex[:8]}_{datetime.now().strftime('%Y%m%d')}", published=True, # Auto-publish for now author_id=current_user.id ) @@ -73,10 +75,18 @@ def new_post(): db.session.add(post) db.session.flush() # Get the post ID + # Create media folder for this post + media_folder_path = os.path.join(current_app.root_path, 'static', 'media', 'posts', post.media_folder) + os.makedirs(media_folder_path, exist_ok=True) + + # Create subfolders for different media types + os.makedirs(os.path.join(media_folder_path, 'images'), exist_ok=True) + os.makedirs(os.path.join(media_folder_path, 'gpx'), exist_ok=True) + # Handle cover picture upload if 'cover_picture' in request.files: cover_file = request.files['cover_picture'] - if cover_file and cover_file.filename: + if cover_file and cover_file.filename and MediaConfig.is_allowed_file(cover_file.filename, 'images'): result = save_image(cover_file, post.id) if result['success']: # Save as cover image @@ -93,7 +103,7 @@ def new_post(): # Handle GPX file upload if 'gpx_file' in request.files: gpx_file = request.files['gpx_file'] - if gpx_file and gpx_file.filename: + if gpx_file and gpx_file.filename and MediaConfig.is_allowed_file(gpx_file.filename, 'gpx'): result = save_gpx_file(gpx_file, post.id) if result['success']: gpx_file_record = GPXFile( @@ -139,6 +149,58 @@ def add_comment(id): return redirect(url_for('community.post_detail', id=id)) +@community.route('/post//add-images', methods=['POST']) +@login_required +def add_images_to_post(id): + """Add additional images to an existing post""" + post = Post.query.get_or_404(id) + + # Check if user owns the post or is admin + if current_user.id != post.author_id and not current_user.is_admin: + return jsonify({'success': False, 'error': 'Unauthorized'}) + + try: + uploaded_images = [] + + # Handle multiple image uploads + if 'images' in request.files: + files = request.files.getlist('images') + + for image_file in files: + if image_file and image_file.filename and MediaConfig.is_allowed_file(image_file.filename, 'images'): + result = save_image(image_file, post.id) + if result['success']: + # Create PostImage record + post_image = PostImage( + filename=result['filename'], + original_name=image_file.filename, + size=result['size'], + mime_type=result['mime_type'], + post_id=post.id, + is_cover=False, + description=request.form.get(f'description_{image_file.filename}', '') + ) + db.session.add(post_image) + uploaded_images.append({ + 'filename': result['filename'], + 'original_name': image_file.filename, + 'url': post_image.get_url(), + 'thumbnail_url': post_image.get_thumbnail_url() + }) + + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'Successfully uploaded {len(uploaded_images)} images', + 'images': uploaded_images + }) + + except Exception as e: + db.session.rollback() + current_app.logger.error(f'Error adding images to post: {str(e)}') + return jsonify({'success': False, 'error': 'An error occurred while uploading images'}) + @community.route('/post//like', methods=['POST']) @login_required def toggle_like(id): @@ -158,25 +220,44 @@ def toggle_like(id): return jsonify({'liked': liked, 'count': post.get_like_count()}) def save_image(image_file, post_id): - """Save uploaded image file""" + """Save uploaded image file with thumbnails""" try: - # Create upload directory - upload_dir = os.path.join(current_app.instance_path, 'uploads', 'images') + # Get post to access media folder + post = Post.query.get(post_id) + if not post or not post.media_folder: + raise ValueError("Post or media folder not found") + + # Validate file type and MIME type + if not MediaConfig.is_allowed_file(image_file.filename, 'images'): + raise ValueError("File type not allowed") + + # Create upload directory for this post + upload_dir = MediaConfig.get_media_path(current_app, post.media_folder, 'images') os.makedirs(upload_dir, exist_ok=True) - # Generate unique filename - filename = secure_filename(f"{uuid.uuid4().hex}_{image_file.filename}") - filepath = os.path.join(upload_dir, filename) + # Create thumbnails directory + thumbnails_dir = os.path.join(upload_dir, 'thumbnails') + os.makedirs(thumbnails_dir, exist_ok=True) - # Save and resize image + # Generate unique filename + file_ext = image_file.filename.rsplit('.', 1)[1].lower() + filename = f"{uuid.uuid4().hex}.{file_ext}" + filepath = os.path.join(upload_dir, filename) + thumbnail_path = os.path.join(thumbnails_dir, filename) + + # Open and process image image = Image.open(image_file) if image.mode in ('RGBA', 'LA', 'P'): image = image.convert('RGB') - # Resize if too large - max_size = (1920, 1080) - image.thumbnail(max_size, Image.Resampling.LANCZOS) - image.save(filepath, 'JPEG', quality=85, optimize=True) + # Resize main image if too large + image.thumbnail(MediaConfig.IMAGE_MAX_SIZE, Image.Resampling.LANCZOS) + image.save(filepath, 'JPEG', quality=MediaConfig.IMAGE_QUALITY, optimize=True) + + # Create thumbnail + thumbnail = image.copy() + thumbnail.thumbnail(MediaConfig.THUMBNAIL_SIZE, Image.Resampling.LANCZOS) + thumbnail.save(thumbnail_path, 'JPEG', quality=MediaConfig.IMAGE_QUALITY, optimize=True) file_size = os.path.getsize(filepath) @@ -184,26 +265,43 @@ def save_image(image_file, post_id): 'success': True, 'filename': filename, 'size': file_size, - 'mime_type': 'image/jpeg' + 'mime_type': 'image/jpeg', + 'has_thumbnail': True } except Exception as e: current_app.logger.error(f'Error saving image: {str(e)}') return {'success': False, 'error': str(e)} def save_gpx_file(gpx_file, post_id): - """Save uploaded GPX file""" + """Save uploaded GPX file with validation""" try: - # Create upload directory - upload_dir = os.path.join(current_app.instance_path, 'uploads', 'gpx') + # Get post to access media folder + post = Post.query.get(post_id) + if not post or not post.media_folder: + raise ValueError("Post or media folder not found") + + # Validate file type + if not MediaConfig.is_allowed_file(gpx_file.filename, 'gpx'): + raise ValueError("File type not allowed") + + # Create upload directory for this post + upload_dir = MediaConfig.get_media_path(current_app, post.media_folder, 'gpx') os.makedirs(upload_dir, exist_ok=True) # Generate unique filename - filename = secure_filename(f"{uuid.uuid4().hex}_{gpx_file.filename}") + file_ext = gpx_file.filename.rsplit('.', 1)[1].lower() + filename = f"{uuid.uuid4().hex}.{file_ext}" filepath = os.path.join(upload_dir, filename) - # Validate GPX file + # Validate GPX file content gpx_content = gpx_file.read() - gpx = gpxpy.parse(gpx_content.decode('utf-8')) + try: + gpx = gpxpy.parse(gpx_content.decode('utf-8')) + # Check if GPX has any tracks or waypoints + if not gpx.tracks and not gpx.waypoints and not gpx.routes: + raise ValueError("GPX file appears to be empty or invalid") + except Exception as e: + raise ValueError(f"Invalid GPX file: {str(e)}") # Save file with open(filepath, 'wb') as f: @@ -211,10 +309,24 @@ def save_gpx_file(gpx_file, post_id): file_size = len(gpx_content) + # Extract basic statistics + total_distance = 0 + total_points = 0 + for track in gpx.tracks: + total_distance += track.length_2d() or 0 + for segment in track.segments: + total_points += len(segment.points) + return { 'success': True, 'filename': filename, - 'size': file_size + 'size': file_size, + 'stats': { + 'total_distance': round(total_distance / 1000, 2) if total_distance else 0, # Convert to km + 'total_points': total_points, + 'tracks_count': len(gpx.tracks), + 'waypoints_count': len(gpx.waypoints) + } } except Exception as e: current_app.logger.error(f'Error saving GPX file: {str(e)}') @@ -232,8 +344,14 @@ def api_routes(): for post in posts_with_routes: for gpx_file in post.gpx_files: try: - # Read and parse GPX file - gpx_path = os.path.join(current_app.instance_path, 'uploads', 'gpx', gpx_file.filename) + # Read and parse GPX file using new folder structure + if post.media_folder: + gpx_path = os.path.join(current_app.root_path, 'static', 'media', 'posts', + post.media_folder, 'gpx', gpx_file.filename) + else: + # Fallback to old path for existing files + gpx_path = os.path.join(current_app.instance_path, 'uploads', 'gpx', gpx_file.filename) + if os.path.exists(gpx_path): with open(gpx_path, 'r') as f: gpx_content = f.read() @@ -260,3 +378,60 @@ def api_routes(): continue return jsonify(routes_data) + +@community.route('/media/posts//images/') +def serve_image(post_folder, filename): + """Serve post images with caching headers""" + try: + image_path = MediaConfig.get_media_path(current_app, post_folder, 'images') + file_path = os.path.join(image_path, filename) + + if not os.path.exists(file_path): + flash('Image not found.', 'error') + return redirect(url_for('community.index')) + + from flask import send_file + return send_file(file_path, as_attachment=False, + cache_timeout=86400) # Cache for 24 hours + except Exception as e: + current_app.logger.error(f'Error serving image: {str(e)}') + flash('Error loading image.', 'error') + return redirect(url_for('community.index')) + +@community.route('/media/posts//images/thumbnails/') +def serve_thumbnail(post_folder, filename): + """Serve post image thumbnails with caching headers""" + try: + thumbnail_path = MediaConfig.get_media_path(current_app, post_folder, 'images') + thumbnail_path = os.path.join(thumbnail_path, 'thumbnails', filename) + + if not os.path.exists(thumbnail_path): + # Fallback to main image + return serve_image(post_folder, filename) + + from flask import send_file + return send_file(thumbnail_path, as_attachment=False, + cache_timeout=86400) # Cache for 24 hours + except Exception as e: + current_app.logger.error(f'Error serving thumbnail: {str(e)}') + return serve_image(post_folder, filename) + +@community.route('/media/posts//gpx/') +def serve_gpx(post_folder, filename): + """Serve GPX files for download""" + try: + gpx_path = MediaConfig.get_media_path(current_app, post_folder, 'gpx') + file_path = os.path.join(gpx_path, filename) + + if not os.path.exists(file_path): + flash('GPX file not found.', 'error') + return redirect(url_for('community.index')) + + from flask import send_file + return send_file(file_path, as_attachment=True, + download_name=f'route_{post_folder}.gpx', + mimetype='application/gpx+xml') + except Exception as e: + current_app.logger.error(f'Error serving GPX file: {str(e)}') + flash('Error downloading GPX file.', 'error') + return redirect(url_for('community.index')) diff --git a/config.py b/config.py index fb215eb..511771d 100644 --- a/config.py +++ b/config.py @@ -8,10 +8,19 @@ class Config: SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///moto_adventure.db' SQLALCHEMY_TRACK_MODIFICATIONS = False - # File Upload Configuration + # Media Configuration - Updated for new media system + MEDIA_FOLDER = os.environ.get('MEDIA_FOLDER') or 'app/static/media/posts' + MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH') or 16 * 1024 * 1024) # 16MB + + # Image Processing + IMAGE_MAX_WIDTH = int(os.environ.get('IMAGE_MAX_WIDTH') or 1920) + IMAGE_MAX_HEIGHT = int(os.environ.get('IMAGE_MAX_HEIGHT') or 1080) + IMAGE_QUALITY = int(os.environ.get('IMAGE_QUALITY') or 85) + THUMBNAIL_SIZE = int(os.environ.get('THUMBNAIL_SIZE') or 300) + + # Legacy - keeping for backward compatibility UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'app/static/uploads' - MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size - ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'gpx'} + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'gpx'} # Email Configuration MAIL_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.gmail.com' diff --git a/manage_media.py b/manage_media.py new file mode 100755 index 0000000..4d4610d --- /dev/null +++ b/manage_media.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Media Management Utility for Motorcycle Adventure Community + +This script provides utilities for managing post media files: +- Create missing media folders for existing posts +- Clean up orphaned media folders +- Migrate files from old structure to new structure +- Generate thumbnails for images + +Usage: + python manage_media.py --help +""" + +import os +import sys +import argparse +import shutil +from datetime import datetime +import uuid + +# Add the app directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app')) + +from app import create_app +from app.models import Post, PostImage, GPXFile +from app.extensions import db + +def create_missing_media_folders(): + """Create media folders for existing posts that don't have them""" + app = create_app() + + with app.app_context(): + posts_without_folders = Post.query.filter_by(media_folder=None).all() + + print(f"Found {len(posts_without_folders)} posts without media folders") + + for post in posts_without_folders: + # Generate a media folder name + folder_name = f"post_{uuid.uuid4().hex[:8]}_{post.created_at.strftime('%Y%m%d')}" + post.media_folder = folder_name + + # Create the folder structure + media_path = os.path.join(app.root_path, 'static', 'media', 'posts', folder_name) + os.makedirs(os.path.join(media_path, 'images'), exist_ok=True) + os.makedirs(os.path.join(media_path, 'gpx'), exist_ok=True) + + print(f"Created media folder for post {post.id}: {folder_name}") + + db.session.commit() + print("Media folders created successfully!") + +def migrate_old_files(): + """Migrate files from old upload structure to new media structure""" + app = create_app() + + with app.app_context(): + # Migrate images + old_images_path = os.path.join(app.instance_path, 'uploads', 'images') + if os.path.exists(old_images_path): + print("Migrating image files...") + + for image in PostImage.query.all(): + if image.post.media_folder: + old_path = os.path.join(old_images_path, image.filename) + new_path = os.path.join(app.root_path, 'static', 'media', 'posts', + image.post.media_folder, 'images', image.filename) + + if os.path.exists(old_path) and not os.path.exists(new_path): + os.makedirs(os.path.dirname(new_path), exist_ok=True) + shutil.move(old_path, new_path) + print(f"Moved image: {image.filename}") + + # Migrate GPX files + old_gpx_path = os.path.join(app.instance_path, 'uploads', 'gpx') + if os.path.exists(old_gpx_path): + print("Migrating GPX files...") + + for gpx_file in GPXFile.query.all(): + if gpx_file.post.media_folder: + old_path = os.path.join(old_gpx_path, gpx_file.filename) + new_path = os.path.join(app.root_path, 'static', 'media', 'posts', + gpx_file.post.media_folder, 'gpx', gpx_file.filename) + + if os.path.exists(old_path) and not os.path.exists(new_path): + os.makedirs(os.path.dirname(new_path), exist_ok=True) + shutil.move(old_path, new_path) + print(f"Moved GPX file: {gpx_file.filename}") + + print("File migration completed!") + +def clean_orphaned_folders(): + """Remove media folders that don't have corresponding posts""" + app = create_app() + + with app.app_context(): + media_posts_path = os.path.join(app.root_path, 'static', 'media', 'posts') + + if not os.path.exists(media_posts_path): + print("No media posts directory found") + return + + # Get all folder names from database + used_folders = set(post.media_folder for post in Post.query.filter(Post.media_folder.isnot(None)).all()) + + # Get all actual folders + actual_folders = set(name for name in os.listdir(media_posts_path) + if os.path.isdir(os.path.join(media_posts_path, name))) + + # Find orphaned folders + orphaned_folders = actual_folders - used_folders + + if orphaned_folders: + print(f"Found {len(orphaned_folders)} orphaned folders:") + for folder in orphaned_folders: + folder_path = os.path.join(media_posts_path, folder) + print(f" {folder}") + + # Ask for confirmation before deleting + response = input(f"Delete folder {folder}? (y/N): ") + if response.lower() == 'y': + shutil.rmtree(folder_path) + print(f" Deleted: {folder}") + else: + print(f" Skipped: {folder}") + else: + print("No orphaned folders found") + +def show_media_stats(): + """Show statistics about media storage""" + app = create_app() + + with app.app_context(): + total_posts = Post.query.count() + posts_with_media_folders = Post.query.filter(Post.media_folder.isnot(None)).count() + total_images = PostImage.query.count() + total_gpx_files = GPXFile.query.count() + + print("Media Storage Statistics:") + print(f" Total posts: {total_posts}") + print(f" Posts with media folders: {posts_with_media_folders}") + print(f" Posts without media folders: {total_posts - posts_with_media_folders}") + print(f" Total images: {total_images}") + print(f" Total GPX files: {total_gpx_files}") + + # Calculate total storage used + media_posts_path = os.path.join(app.root_path, 'static', 'media', 'posts') + if os.path.exists(media_posts_path): + total_size = 0 + for root, dirs, files in os.walk(media_posts_path): + for file in files: + file_path = os.path.join(root, file) + total_size += os.path.getsize(file_path) + + print(f" Total storage used: {total_size / (1024*1024):.2f} MB") + +def main(): + parser = argparse.ArgumentParser(description='Media Management Utility') + parser.add_argument('--create-folders', action='store_true', + help='Create missing media folders for existing posts') + parser.add_argument('--migrate-files', action='store_true', + help='Migrate files from old structure to new structure') + parser.add_argument('--clean-orphaned', action='store_true', + help='Clean up orphaned media folders') + parser.add_argument('--stats', action='store_true', + help='Show media storage statistics') + parser.add_argument('--all', action='store_true', + help='Run all operations (create folders, migrate files)') + + args = parser.parse_args() + + if args.all: + create_missing_media_folders() + migrate_old_files() + elif args.create_folders: + create_missing_media_folders() + elif args.migrate_files: + migrate_old_files() + elif args.clean_orphaned: + clean_orphaned_folders() + elif args.stats: + show_media_stats() + else: + parser.print_help() + +if __name__ == '__main__': + main() diff --git a/migrations/add_media_folder.py b/migrations/add_media_folder.py new file mode 100644 index 0000000..adbab7d --- /dev/null +++ b/migrations/add_media_folder.py @@ -0,0 +1,28 @@ +"""Add media_folder to posts table + +This migration adds a media_folder column to the posts table to support +organized file storage for each post. + +Usage: + flask db upgrade + +Revision ID: add_media_folder +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = 'add_media_folder' +down_revision = None +depends_on = None + +def upgrade(): + """Add media_folder column to posts table""" + # Add the media_folder column + op.add_column('posts', sa.Column('media_folder', sa.String(100), nullable=True)) + +def downgrade(): + """Remove media_folder column from posts table""" + # Remove the media_folder column + op.drop_column('posts', 'media_folder') diff --git a/test_media.py b/test_media.py new file mode 100755 index 0000000..a3f6333 --- /dev/null +++ b/test_media.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Test script for the media management system +""" + +import os +import sys +import tempfile +from io import BytesIO + +# Add the app directory to the path +sys.path.insert(0, os.path.dirname(__file__)) + +from app import create_app +from app.models import Post, PostImage, User +from app.extensions import db +from app.media_config import MediaConfig +from PIL import Image + +def test_media_folder_creation(): + """Test that media folders are created correctly""" + app = create_app() + + with app.app_context(): + # Create a test user + test_user = User.query.filter_by(email='test@example.com').first() + if not test_user: + test_user = User( + nickname='testuser', + email='test@example.com' + ) + test_user.set_password('testpass') + db.session.add(test_user) + db.session.commit() + + # Create a test post + test_post = Post( + title='Test Media Post', + subtitle='Testing media folder creation', + content='This is a test post for media functionality', + difficulty=3, + media_folder='test_post_12345678_20250723', + published=True, + author_id=test_user.id + ) + + db.session.add(test_post) + db.session.commit() + + # Check that media folder methods work + media_path = test_post.get_media_folder_path() + media_url = test_post.get_media_url_path() + + print(f"✅ Post created with ID: {test_post.id}") + print(f"✅ Media folder: {test_post.media_folder}") + print(f"✅ Media path: {media_path}") + print(f"✅ Media URL: {media_url}") + + # Test media config + config_path = MediaConfig.get_media_path(app, test_post.media_folder, 'images') + config_url = MediaConfig.get_media_url(test_post.media_folder, 'images', 'test.jpg') + + print(f"✅ Config path: {config_path}") + print(f"✅ Config URL: {config_url}") + + # Test file validation + valid_image = MediaConfig.is_allowed_file('test.jpg', 'images') + valid_gpx = MediaConfig.is_allowed_file('route.gpx', 'gpx') + invalid_file = MediaConfig.is_allowed_file('bad.exe', 'images') + + print(f"✅ Valid image file: {valid_image}") + print(f"✅ Valid GPX file: {valid_gpx}") + print(f"✅ Invalid file rejected: {not invalid_file}") + + return True + +def test_image_processing(): + """Test image processing functionality""" + print("\n🖼️ Testing Image Processing...") + + # Create a test image + img = Image.new('RGB', (800, 600), color='red') + img_buffer = BytesIO() + img.save(img_buffer, format='JPEG') + img_buffer.seek(0) + + # Test image size limits + max_size = MediaConfig.IMAGE_MAX_SIZE + thumbnail_size = MediaConfig.THUMBNAIL_SIZE + + print(f"✅ Max image size: {max_size}") + print(f"✅ Thumbnail size: {thumbnail_size}") + print(f"✅ Image quality: {MediaConfig.IMAGE_QUALITY}") + + return True + +def test_file_extensions(): + """Test file extension validation""" + print("\n📁 Testing File Extensions...") + + # Test image extensions + image_exts = MediaConfig.UPLOAD_EXTENSIONS['images'] + gpx_exts = MediaConfig.UPLOAD_EXTENSIONS['gpx'] + + print(f"✅ Allowed image extensions: {image_exts}") + print(f"✅ Allowed GPX extensions: {gpx_exts}") + + # Test MIME types + valid_mimes = list(MediaConfig.ALLOWED_MIME_TYPES.keys()) + print(f"✅ Allowed MIME types: {valid_mimes}") + + return True + +def main(): + """Run all tests""" + print("🧪 Media Management System Tests") + print("=" * 40) + + try: + # Test media folder creation + print("\n📁 Testing Media Folder Creation...") + test_media_folder_creation() + + # Test image processing + test_image_processing() + + # Test file extensions + test_file_extensions() + + print("\n✅ All tests passed!") + print("\n📋 Media System Summary:") + print(" - Media folders created per post") + print(" - Images automatically resized and compressed") + print(" - Thumbnails generated for all images") + print(" - GPX files validated and statistics extracted") + print(" - File type validation enforced") + print(" - Organized folder structure maintained") + + except Exception as e: + print(f"❌ Test failed: {str(e)}") + return False + + return True + +if __name__ == '__main__': + success = main() + sys.exit(0 if success else 1)