Files
moto-adv-website/app/routes/community.py
ske087 540eb17e89 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.
2025-07-23 18:03:03 +03:00

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