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:
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
|
||||
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'<Post {self.title}>'
|
||||
|
||||
@@ -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'<PostImage {self.filename}>'
|
||||
|
||||
@@ -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'<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.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/<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'])
|
||||
@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/<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'))
|
||||
|
||||
Reference in New Issue
Block a user