From 6a0548b88072447ee08f669a52f7badb9db2abef Mon Sep 17 00:00:00 2001 From: ske087 Date: Wed, 23 Jul 2025 17:20:52 +0300 Subject: [PATCH] Final cleanup: Complete Flask motorcycle adventure app - Removed all Node.js/Next.js dependencies and files - Cleaned up project structure to contain only Flask application - Updated .gitignore to exclude Python cache files, virtual environments, and development artifacts - Complete motorcycle adventure community website with: * Interactive Romania map with GPX route plotting * Advanced post creation with cover images, sections, highlights * User authentication and authorization system * Community features with likes and comments * Responsive design with blue-purple-teal gradient theme * Docker and production deployment configuration * SQLite database with proper models and relationships * Image and GPX file upload handling * Modern UI with improved form layouts and visual feedback Technical stack: - Flask 3.0.0 with SQLAlchemy, Flask-Login, Flask-Mail, Flask-WTF - Jinja2 templates with Tailwind CSS styling - Leaflet.js for interactive mapping - PostgreSQL/SQLite database support - Docker containerization with Nginx reverse proxy - Gunicorn WSGI server for production Project is now production-ready Flask application focused on motorcycle adventure sharing in Romania. --- .env.example | 0 .gitignore | 121 ++++++ app/__init__.py | 15 +- app/extensions.py | 10 + app/models.py | 7 +- app/routes/auth.py | 5 +- app/routes/community.py | 153 +++++-- app/templates/base.html | 6 +- app/templates/community/index.html | 328 ++++++++++++++ app/templates/community/new_post.html | 595 ++++++++++++++++++++++++++ app/templates/index.html | 14 +- config.py | 4 +- 12 files changed, 1186 insertions(+), 72 deletions(-) create mode 100644 .env.example create mode 100644 app/extensions.py create mode 100644 app/templates/community/index.html create mode 100644 app/templates/community/new_post.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index e69de29..8578775 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,121 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Application specific +uploads/ +*.db +*.sqlite \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index bda8bb1..4e479e2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,16 +1,8 @@ from flask import Flask -from flask_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate -from flask_login import LoginManager -from flask_mail import Mail +from app.extensions import db, migrate, login_manager, mail from config import config import os -db = SQLAlchemy() -migrate = Migrate() -login_manager = LoginManager() -mail = Mail() - def create_app(config_name=None): app = Flask(__name__) @@ -53,8 +45,3 @@ def create_app(config_name=None): os.makedirs(os.path.join(upload_dir, 'gpx'), exist_ok=True) return app - -@login_manager.user_loader -def load_user(user_id): - from app.models import User - return User.query.get(int(user_id)) diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..894167d --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,10 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_mail import Mail + +# Initialize extensions +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() +mail = Mail() diff --git a/app/models.py b/app/models.py index 7ed355b..0c52891 100644 --- a/app/models.py +++ b/app/models.py @@ -1,9 +1,7 @@ from datetime import datetime -from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash - -db = SQLAlchemy() +from app.extensions import db class User(UserMixin, db.Model): __tablename__ = 'users' @@ -70,7 +68,8 @@ class PostImage(db.Model): original_name = db.Column(db.String(255), nullable=False) description = db.Column(db.Text) size = db.Column(db.Integer, nullable=False) - mime_type = db.Column(db.String(100), nullable=False) + mime_type = db.Column(db.String(100), default='image/jpeg', nullable=False) + is_cover = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) # Foreign Keys diff --git a/app/routes/auth.py b/app/routes/auth.py index 0daa5ed..f981e1f 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -61,8 +61,9 @@ def register(): try: db.session.add(user) db.session.commit() - flash('Registration successful! You can now log in.', 'success') - return redirect(url_for('auth.login')) + login_user(user) + flash('Registration successful! Welcome to the community!', 'success') + return redirect(url_for('community.index')) except Exception as e: db.session.rollback() flash('An error occurred during registration. Please try again.', 'error') diff --git a/app/routes/community.py b/app/routes/community.py index 27c3dfe..0ad9b69 100644 --- a/app/routes/community.py +++ b/app/routes/community.py @@ -1,6 +1,7 @@ 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, db +from app.models import Post, PostImage, GPXFile, User, Comment, Like +from app.extensions import db from app.forms import PostForm, CommentForm from werkzeug.utils import secure_filename from werkzeug.exceptions import RequestEntityTooLarge @@ -14,12 +15,16 @@ community = Blueprint('community', __name__) @community.route('/') def index(): - """Community posts listing page""" + """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=10, error_out=False + page=page, per_page=12, error_out=False ) - return render_template('community/index.html', posts=posts) + + # 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/') def post_detail(id): @@ -39,61 +44,81 @@ def post_detail(id): @login_required def new_post(): """Create new post page""" - form = PostForm() - - if form.validate_on_submit(): + 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=form.title.data, - subtitle=form.subtitle.data, - content=form.content.data, - difficulty=int(form.difficulty.data), - published=form.published.data, + title=title, + subtitle=subtitle, + content=content, + difficulty=difficulty, + published=True, # Auto-publish for now author_id=current_user.id ) db.session.add(post) db.session.flush() # Get the post ID - # Handle image uploads - if form.images.data and form.images.data.filename: - images = request.files.getlist('images') - for image_file in images: - if image_file and image_file.filename: - result = save_image(image_file, post.id) - if result['success']: - post_image = PostImage( - filename=result['filename'], - original_name=image_file.filename, - size=result['size'], - mime_type=image_file.content_type, - post_id=post.id - ) - db.session.add(post_image) + # Handle cover picture upload + if 'cover_picture' in request.files: + cover_file = request.files['cover_picture'] + if cover_file and cover_file.filename: + 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 form.gpx_file.data and form.gpx_file.data.filename: - result = save_gpx_file(form.gpx_file.data, post.id) - if result['success']: - gpx_file = GPXFile( - filename=result['filename'], - original_name=form.gpx_file.data.filename, - size=result['size'], - post_id=post.id - ) - db.session.add(gpx_file) + if 'gpx_file' in request.files: + gpx_file = request.files['gpx_file'] + if gpx_file and gpx_file.filename: + 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() - flash('Your adventure has been shared!', 'success') - return redirect(url_for('community.post_detail', id=post.id)) + + 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() - flash('An error occurred while creating your post. Please try again.', 'error') current_app.logger.error(f'Error creating post: {str(e)}') + return jsonify({'success': False, 'error': 'An error occurred while creating your post'}) - return render_template('community/new_post.html', form=form) + # GET request - show the form + return render_template('community/new_post.html') @community.route('/post//comment', methods=['POST']) @login_required @@ -158,7 +183,8 @@ def save_image(image_file, post_id): return { 'success': True, 'filename': filename, - 'size': file_size + 'size': file_size, + 'mime_type': 'image/jpeg' } except Exception as e: current_app.logger.error(f'Error saving image: {str(e)}') @@ -192,4 +218,45 @@ def save_gpx_file(gpx_file, post_id): } except Exception as e: current_app.logger.error(f'Error saving GPX file: {str(e)}') - return {'success': False, 'error': 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 + 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) diff --git a/app/templates/base.html b/app/templates/base.html index 2464d4b..7c29ca2 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -22,12 +22,12 @@ @@ -40,6 +40,12 @@ Join our passionate community of motorcycle enthusiasts as we explore the most spectacular routes Romania has to offer. From winding mountain passes to scenic coastal roads.

+
diff --git a/config.py b/config.py index b700107..fb215eb 100644 --- a/config.py +++ b/config.py @@ -5,7 +5,7 @@ load_dotenv() class Config: SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' - SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost/moto_adventure' + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///moto_adventure.db' SQLALCHEMY_TRACK_MODIFICATIONS = False # File Upload Configuration @@ -31,7 +31,7 @@ class Config: class DevelopmentConfig(Config): DEBUG = True - SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or 'postgresql://postgres:password@localhost/moto_adventure_dev' + SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or 'sqlite:///moto_adventure_dev.db' class ProductionConfig(Config): DEBUG = False