- 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.
438 lines
18 KiB
Python
438 lines
18 KiB
Python
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
|
|
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
|
|
import uuid
|
|
from datetime import datetime
|
|
from PIL import Image
|
|
import gpxpy
|
|
|
|
community = Blueprint('community', __name__)
|
|
|
|
@community.route('/')
|
|
def index():
|
|
"""Community main page with map and posts"""
|
|
page = request.args.get('page', 1, type=int)
|
|
posts = Post.query.filter_by(published=True).order_by(Post.created_at.desc()).paginate(
|
|
page=page, per_page=12, error_out=False
|
|
)
|
|
|
|
# Get posts with GPX files for map display
|
|
posts_with_routes = Post.query.filter_by(published=True).join(GPXFile).all()
|
|
|
|
return render_template('community/index.html', posts=posts, posts_with_routes=posts_with_routes)
|
|
|
|
@community.route('/post/<int:id>')
|
|
def post_detail(id):
|
|
"""Individual post detail page"""
|
|
post = Post.query.get_or_404(id)
|
|
if not post.published and (not current_user.is_authenticated or
|
|
(current_user.id != post.author_id and not current_user.is_admin)):
|
|
flash('Post not found.', 'error')
|
|
return redirect(url_for('community.index'))
|
|
|
|
form = CommentForm()
|
|
comments = Comment.query.filter_by(post_id=id).order_by(Comment.created_at.asc()).all()
|
|
|
|
return render_template('community/post_detail.html', post=post, form=form, comments=comments)
|
|
|
|
@community.route('/new-post', methods=['GET', 'POST'])
|
|
@login_required
|
|
def new_post():
|
|
"""Create new post page"""
|
|
if request.method == 'POST':
|
|
try:
|
|
# Get form data
|
|
title = request.form.get('title', '').strip()
|
|
subtitle = request.form.get('subtitle', '').strip()
|
|
content = request.form.get('content', '').strip()
|
|
difficulty = request.form.get('difficulty', type=int)
|
|
|
|
# Validation
|
|
if not title:
|
|
return jsonify({'success': False, 'error': 'Title is required'})
|
|
if not difficulty or difficulty < 1 or difficulty > 5:
|
|
return jsonify({'success': False, 'error': 'Valid difficulty level is required'})
|
|
if not content:
|
|
return jsonify({'success': False, 'error': 'Content is required'})
|
|
|
|
# Create post
|
|
post = Post(
|
|
title=title,
|
|
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
|
|
)
|
|
|
|
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 and MediaConfig.is_allowed_file(cover_file.filename, 'images'):
|
|
result = save_image(cover_file, post.id)
|
|
if result['success']:
|
|
# Save as cover image
|
|
cover_image = PostImage(
|
|
filename=result['filename'],
|
|
original_name=cover_file.filename,
|
|
size=result['size'],
|
|
mime_type=result['mime_type'],
|
|
post_id=post.id,
|
|
is_cover=True
|
|
)
|
|
db.session.add(cover_image)
|
|
|
|
# Handle GPX file upload
|
|
if 'gpx_file' in request.files:
|
|
gpx_file = request.files['gpx_file']
|
|
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(
|
|
filename=result['filename'],
|
|
original_name=gpx_file.filename,
|
|
size=result['size'],
|
|
post_id=post.id
|
|
)
|
|
db.session.add(gpx_file_record)
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Adventure shared successfully!',
|
|
'redirect_url': url_for('community.post_detail', id=post.id)
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
current_app.logger.error(f'Error creating post: {str(e)}')
|
|
return jsonify({'success': False, 'error': 'An error occurred while creating your post'})
|
|
|
|
# GET request - show the form
|
|
return render_template('community/new_post.html')
|
|
|
|
@community.route('/post/<int:id>/comment', methods=['POST'])
|
|
@login_required
|
|
def add_comment(id):
|
|
"""Add comment to post"""
|
|
post = Post.query.get_or_404(id)
|
|
form = CommentForm()
|
|
|
|
if form.validate_on_submit():
|
|
comment = Comment(
|
|
content=form.content.data,
|
|
author_id=current_user.id,
|
|
post_id=post.id
|
|
)
|
|
db.session.add(comment)
|
|
db.session.commit()
|
|
flash('Your comment has been added.', 'success')
|
|
|
|
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):
|
|
"""Toggle like on post"""
|
|
post = Post.query.get_or_404(id)
|
|
existing_like = Like.query.filter_by(user_id=current_user.id, post_id=post.id).first()
|
|
|
|
if existing_like:
|
|
db.session.delete(existing_like)
|
|
liked = False
|
|
else:
|
|
like = Like(user_id=current_user.id, post_id=post.id)
|
|
db.session.add(like)
|
|
liked = True
|
|
|
|
db.session.commit()
|
|
return jsonify({'liked': liked, 'count': post.get_like_count()})
|
|
|
|
def save_image(image_file, post_id):
|
|
"""Save uploaded image file with thumbnails"""
|
|
try:
|
|
# 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)
|
|
|
|
# Create thumbnails directory
|
|
thumbnails_dir = os.path.join(upload_dir, 'thumbnails')
|
|
os.makedirs(thumbnails_dir, exist_ok=True)
|
|
|
|
# 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 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)
|
|
|
|
return {
|
|
'success': True,
|
|
'filename': filename,
|
|
'size': file_size,
|
|
'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 with validation"""
|
|
try:
|
|
# 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
|
|
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 content
|
|
gpx_content = gpx_file.read()
|
|
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:
|
|
f.write(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 {
|
|
'success': True,
|
|
'filename': filename,
|
|
'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)}')
|
|
return {
|
|
'success': False,
|
|
'error': str(e)
|
|
}
|
|
|
|
@community.route('/api/routes')
|
|
def api_routes():
|
|
"""API endpoint to get all routes for map display"""
|
|
posts_with_routes = Post.query.filter_by(published=True).join(GPXFile).all()
|
|
routes_data = []
|
|
|
|
for post in posts_with_routes:
|
|
for gpx_file in post.gpx_files:
|
|
try:
|
|
# 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()
|
|
|
|
gpx = gpxpy.parse(gpx_content)
|
|
|
|
# Extract coordinates
|
|
coordinates = []
|
|
for track in gpx.tracks:
|
|
for segment in track.segments:
|
|
for point in segment.points:
|
|
coordinates.append([point.latitude, point.longitude])
|
|
|
|
if coordinates:
|
|
routes_data.append({
|
|
'id': post.id,
|
|
'title': post.title,
|
|
'author': post.author.nickname,
|
|
'coordinates': coordinates,
|
|
'url': url_for('community.post_detail', id=post.id)
|
|
})
|
|
except Exception as e:
|
|
current_app.logger.error(f'Error processing GPX file {gpx_file.filename}: {str(e)}')
|
|
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'))
|