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.
This commit is contained in:
ske087
2025-07-23 18:03:03 +03:00
parent 6a0548b880
commit 540eb17e89
8 changed files with 808 additions and 26 deletions

122
MEDIA_MANAGEMENT.md Normal file
View File

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

70
app/media_config.py Normal file
View File

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

View File

@@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime
import os
from flask_login import UserMixin from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from app.extensions import db from app.extensions import db
@@ -37,6 +38,7 @@ class Post(db.Model):
subtitle = db.Column(db.String(300)) subtitle = db.Column(db.String(300))
content = db.Column(db.Text, nullable=False) content = db.Column(db.Text, nullable=False)
difficulty = db.Column(db.Integer, default=3, nullable=False) # 1-5 scale 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) published = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=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): def get_like_count(self):
return self.likes.count() 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): def __repr__(self):
return f'<Post {self.title}>' return f'<Post {self.title}>'
@@ -75,6 +89,30 @@ class PostImage(db.Model):
# Foreign Keys # Foreign Keys
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False) 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): def __repr__(self):
return f'<PostImage {self.filename}>' return f'<PostImage {self.filename}>'
@@ -90,6 +128,12 @@ class GPXFile(db.Model):
# Foreign Keys # Foreign Keys
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False) 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): def __repr__(self):
return f'<GPXFile {self.filename}>' return f'<GPXFile {self.filename}>'

View File

@@ -3,6 +3,7 @@ from flask_login import login_required, current_user
from app.models import Post, PostImage, GPXFile, User, Comment, Like from app.models import Post, PostImage, GPXFile, User, Comment, Like
from app.extensions import db from app.extensions import db
from app.forms import PostForm, CommentForm from app.forms import PostForm, CommentForm
from app.media_config import MediaConfig
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.exceptions import RequestEntityTooLarge
import os import os
@@ -66,6 +67,7 @@ def new_post():
subtitle=subtitle, subtitle=subtitle,
content=content, content=content,
difficulty=difficulty, difficulty=difficulty,
media_folder=f"post_{uuid.uuid4().hex[:8]}_{datetime.now().strftime('%Y%m%d')}",
published=True, # Auto-publish for now published=True, # Auto-publish for now
author_id=current_user.id author_id=current_user.id
) )
@@ -73,10 +75,18 @@ def new_post():
db.session.add(post) db.session.add(post)
db.session.flush() # Get the post ID 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 # Handle cover picture upload
if 'cover_picture' in request.files: if 'cover_picture' in request.files:
cover_file = request.files['cover_picture'] 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) result = save_image(cover_file, post.id)
if result['success']: if result['success']:
# Save as cover image # Save as cover image
@@ -93,7 +103,7 @@ def new_post():
# Handle GPX file upload # Handle GPX file upload
if 'gpx_file' in request.files: if 'gpx_file' in request.files:
gpx_file = request.files['gpx_file'] 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) result = save_gpx_file(gpx_file, post.id)
if result['success']: if result['success']:
gpx_file_record = GPXFile( gpx_file_record = GPXFile(
@@ -139,6 +149,58 @@ def add_comment(id):
return redirect(url_for('community.post_detail', id=id)) return redirect(url_for('community.post_detail', id=id))
@community.route('/post/<int:id>/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/<int:id>/like', methods=['POST']) @community.route('/post/<int:id>/like', methods=['POST'])
@login_required @login_required
def toggle_like(id): def toggle_like(id):
@@ -158,25 +220,44 @@ def toggle_like(id):
return jsonify({'liked': liked, 'count': post.get_like_count()}) return jsonify({'liked': liked, 'count': post.get_like_count()})
def save_image(image_file, post_id): def save_image(image_file, post_id):
"""Save uploaded image file""" """Save uploaded image file with thumbnails"""
try: try:
# Create upload directory # Get post to access media folder
upload_dir = os.path.join(current_app.instance_path, 'uploads', 'images') 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) os.makedirs(upload_dir, exist_ok=True)
# Generate unique filename # Create thumbnails directory
filename = secure_filename(f"{uuid.uuid4().hex}_{image_file.filename}") thumbnails_dir = os.path.join(upload_dir, 'thumbnails')
filepath = os.path.join(upload_dir, filename) 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) image = Image.open(image_file)
if image.mode in ('RGBA', 'LA', 'P'): if image.mode in ('RGBA', 'LA', 'P'):
image = image.convert('RGB') image = image.convert('RGB')
# Resize if too large # Resize main image if too large
max_size = (1920, 1080) image.thumbnail(MediaConfig.IMAGE_MAX_SIZE, Image.Resampling.LANCZOS)
image.thumbnail(max_size, Image.Resampling.LANCZOS) image.save(filepath, 'JPEG', quality=MediaConfig.IMAGE_QUALITY, optimize=True)
image.save(filepath, 'JPEG', quality=85, 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) file_size = os.path.getsize(filepath)
@@ -184,26 +265,43 @@ def save_image(image_file, post_id):
'success': True, 'success': True,
'filename': filename, 'filename': filename,
'size': file_size, 'size': file_size,
'mime_type': 'image/jpeg' 'mime_type': 'image/jpeg',
'has_thumbnail': True
} }
except Exception as e: except Exception as e:
current_app.logger.error(f'Error saving image: {str(e)}') current_app.logger.error(f'Error saving image: {str(e)}')
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
def save_gpx_file(gpx_file, post_id): def save_gpx_file(gpx_file, post_id):
"""Save uploaded GPX file""" """Save uploaded GPX file with validation"""
try: try:
# Create upload directory # Get post to access media folder
upload_dir = os.path.join(current_app.instance_path, 'uploads', 'gpx') 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) os.makedirs(upload_dir, exist_ok=True)
# Generate unique filename # 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) filepath = os.path.join(upload_dir, filename)
# Validate GPX file # Validate GPX file content
gpx_content = gpx_file.read() 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 # Save file
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
@@ -211,10 +309,24 @@ def save_gpx_file(gpx_file, post_id):
file_size = len(gpx_content) 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 { return {
'success': True, 'success': True,
'filename': filename, '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: except Exception as e:
current_app.logger.error(f'Error saving GPX file: {str(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 post in posts_with_routes:
for gpx_file in post.gpx_files: for gpx_file in post.gpx_files:
try: try:
# Read and parse GPX file # Read and parse GPX file using new folder structure
gpx_path = os.path.join(current_app.instance_path, 'uploads', 'gpx', gpx_file.filename) 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): if os.path.exists(gpx_path):
with open(gpx_path, 'r') as f: with open(gpx_path, 'r') as f:
gpx_content = f.read() gpx_content = f.read()
@@ -260,3 +378,60 @@ def api_routes():
continue continue
return jsonify(routes_data) return jsonify(routes_data)
@community.route('/media/posts/<post_folder>/images/<filename>')
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/<post_folder>/images/thumbnails/<filename>')
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/<post_folder>/gpx/<filename>')
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'))

View File

@@ -8,10 +8,19 @@ class Config:
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///moto_adventure.db' SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///moto_adventure.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False 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' 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', 'webp', 'gpx'}
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'gpx'}
# Email Configuration # Email Configuration
MAIL_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.gmail.com' MAIL_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.gmail.com'

187
manage_media.py Executable file
View File

@@ -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()

View File

@@ -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')

147
test_media.py Executable file
View File

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