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:
122
MEDIA_MANAGEMENT.md
Normal file
122
MEDIA_MANAGEMENT.md
Normal 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
70
app/media_config.py
Normal 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
|
||||||
@@ -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}>'
|
||||||
|
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
15
config.py
15
config.py
@@ -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
187
manage_media.py
Executable 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()
|
||||||
28
migrations/add_media_folder.py
Normal file
28
migrations/add_media_folder.py
Normal 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
147
test_media.py
Executable 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)
|
||||||
Reference in New Issue
Block a user