diff --git a/planventure-api/.gitignore b/planventure-api/.gitignore new file mode 100644 index 0000000..e46fdc2 --- /dev/null +++ b/planventure-api/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +api-venv/ +ENV/ + +# Flask +instance/ +.webassets-cache + +# Database +*.db +*.sqlite3 + +# Test files +test_*.py +*_test.py +tests/ + +# Environment variables +.env + +# Development tools +bruno_installer.exe +check_*.py +simple_check.py +robust_init_db.py + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/planventure-api/BRUNO_EXAMPLES.md b/planventure-api/BRUNO_EXAMPLES.md new file mode 100644 index 0000000..0a13c04 --- /dev/null +++ b/planventure-api/BRUNO_EXAMPLES.md @@ -0,0 +1,59 @@ +# Ejemplos de peticiones para Bruno API Client + +## 1. Health Check +GET http://127.0.0.1:5000/health + +## 2. Registro de Usuario +POST http://127.0.0.1:5000/auth/register +Content-Type: application/json + +{ + "email": "usuario@ejemplo.com", + "password": "mipassword123" +} + +## 3. Login de Usuario +POST http://127.0.0.1:5000/auth/login +Content-Type: application/json + +{ + "email": "usuario@ejemplo.com", + "password": "mipassword123" +} + +## 4. Obtener Perfil (requiere token JWT) +GET http://127.0.0.1:5000/auth/profile +Authorization: Bearer YOUR_ACCESS_TOKEN_HERE + +--- + +## Formato de respuesta esperado para registro exitoso: +{ + "message": "User registered successfully", + "user": { + "id": 1, + "email": "usuario@ejemplo.com" + }, + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." +} + +## Posibles errores comunes: + +### Email inválido (400): +{ + "error": "invalid_email", + "message": "Please provide a valid email address" +} + +### Usuario ya existe (409): +{ + "error": "user_exists", + "message": "User with this email already exists" +} + +### Contraseña muy corta (400): +{ + "error": "weak_password", + "message": "Password must be at least 6 characters long" +} diff --git a/planventure-api/app.py b/planventure-api/app.py index 3f778d8..248198f 100644 --- a/planventure-api/app.py +++ b/planventure-api/app.py @@ -1,8 +1,66 @@ from flask import Flask, jsonify from flask_cors import CORS +from flask_jwt_extended import JWTManager +from dotenv import load_dotenv +from datetime import timedelta +import os + +from database import init_db + +# Load environment variables +load_dotenv() app = Flask(__name__) -CORS(app) + +# Basic configurations +app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key') +app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///planventure.db') +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'jwt-secret-key') + +# Initialize extensions +CORS(app, origins=os.getenv('CORS_ORIGINS', 'http://localhost:3000').split(',')) +jwt = JWTManager(app) + +# JWT Configuration +app.config['JWT_ACCESS_TOKEN_EXPIRES'] = timedelta(hours=1) +app.config['JWT_REFRESH_TOKEN_EXPIRES'] = timedelta(days=30) + +# JWT Error Handlers +@jwt.expired_token_loader +def expired_token_callback(jwt_header, jwt_payload): + return jsonify({ + 'error': 'token_expired', + 'message': 'The token has expired' + }), 401 + +@jwt.invalid_token_loader +def invalid_token_callback(error): + print(f"Invalid token error: {error}") # Debug log + return jsonify({ + 'error': 'invalid_token', + 'message': f'Invalid token: {str(error)}' + }), 401 + +@jwt.unauthorized_loader +def missing_token_callback(error): + return jsonify({ + 'error': 'authorization_required', + 'message': 'Request does not contain an access token' + }), 401 + +# Initialize database +init_db(app) + +# Import models after db initialization +from models.user import User +from models.trip import Trip + +# Import and register blueprints +from auth_routes import auth_bp +app.register_blueprint(auth_bp) + +# Note: Database tables are created by init_db() function @app.route('/') def home(): @@ -10,7 +68,20 @@ def home(): @app.route('/health') def health_check(): - return jsonify({"status": "healthy"}) + try: + # Test database connection by counting users + user_count = User.query.count() + return jsonify({ + "status": "healthy", + "database": "connected", + "users_count": user_count + }) + except Exception as e: + return jsonify({ + "status": "unhealthy", + "database": "error", + "error": str(e) + }), 500 if __name__ == '__main__': app.run(debug=True) diff --git a/planventure-api/auth_routes.py b/planventure-api/auth_routes.py new file mode 100644 index 0000000..90f7c27 --- /dev/null +++ b/planventure-api/auth_routes.py @@ -0,0 +1,295 @@ +""" +Authentication routes for PlanVenture API +Handles user registration, login, token refresh, and logout +""" + +from flask import Blueprint, request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from email_validator import validate_email, EmailNotValidError +from database import db +from models.user import User +from auth_utils import generate_tokens, create_token_response, refresh_access_token, get_current_user + +# Create blueprint +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') + +@auth_bp.route('/register', methods=['POST']) +def register(): + """ + Register a new user + + Expected JSON: + { + "email": "user@example.com", + "password": "secure_password" + } + """ + try: + data = request.get_json() + + # Validate input data + if not data or not data.get('email') or not data.get('password'): + return jsonify({ + 'error': 'missing_fields', + 'message': 'Email and password are required' + }), 400 + + email = data['email'].lower().strip() + password = data['password'] + + # Validate email format + try: + validate_email(email) + except EmailNotValidError: + return jsonify({ + 'error': 'invalid_email', + 'message': 'Please provide a valid email address' + }), 400 + + # Validate password strength + if len(password) < 6: + return jsonify({ + 'error': 'weak_password', + 'message': 'Password must be at least 6 characters long' + }), 400 + + # Check if user already exists + existing_user = User.query.filter_by(email=email).first() + if existing_user: + return jsonify({ + 'error': 'user_exists', + 'message': 'User with this email already exists' + }), 409 + + # Create new user + user = User(email=email, password=password) + db.session.add(user) + db.session.commit() + + # Generate tokens and return response + response = create_token_response(user) + + return jsonify(response), 201 + + except Exception as e: + db.session.rollback() + return jsonify({ + 'error': 'registration_failed', + 'message': 'Registration failed. Please try again.' + }), 500 + +@auth_bp.route('/login', methods=['POST']) +def login(): + """ + Login user and return JWT tokens + + Expected JSON: + { + "email": "user@example.com", + "password": "secure_password" + } + """ + try: + data = request.get_json() + + # Validate input data + if not data or not data.get('email') or not data.get('password'): + return jsonify({ + 'error': 'missing_credentials', + 'message': 'Email and password are required' + }), 400 + + email = data['email'].lower().strip() + password = data['password'] + + # Find user by email + user = User.query.filter_by(email=email).first() + + # Verify user exists and password is correct + if not user or not user.check_password(password): + return jsonify({ + 'error': 'invalid_credentials', + 'message': 'Invalid email or password' + }), 401 + + # Generate tokens and return response + response = create_token_response(user) + + return jsonify(response), 200 + + except Exception as e: + return jsonify({ + 'error': 'login_failed', + 'message': 'Login failed. Please try again.' + }), 500 + +@auth_bp.route('/refresh', methods=['POST']) +@jwt_required(refresh=True) +def refresh(): + """ + Refresh access token using refresh token + """ + try: + new_tokens = refresh_access_token() + + if not new_tokens: + return jsonify({ + 'error': 'refresh_failed', + 'message': 'Could not refresh token' + }), 401 + + return jsonify({ + 'message': 'Token refreshed successfully', + **new_tokens + }), 200 + + except Exception as e: + return jsonify({ + 'error': 'refresh_failed', + 'message': 'Token refresh failed' + }), 500 + +@auth_bp.route('/profile', methods=['GET']) +@jwt_required() +def get_profile(): + """ + Get current user profile + """ + try: + user = get_current_user() + + if not user: + return jsonify({ + 'error': 'user_not_found', + 'message': 'User not found' + }), 404 + + return jsonify({ + 'user': user.to_dict(include_trips=True) + }), 200 + + except Exception as e: + return jsonify({ + 'error': 'profile_failed', + 'message': 'Could not retrieve profile' + }), 500 + +@auth_bp.route('/profile', methods=['PUT']) +@jwt_required() +def update_profile(): + """ + Update current user profile + + Expected JSON: + { + "email": "new_email@example.com" // optional + } + """ + try: + user = get_current_user() + + if not user: + return jsonify({ + 'error': 'user_not_found', + 'message': 'User not found' + }), 404 + + data = request.get_json() + + # Update email if provided + if data and data.get('email'): + new_email = data['email'].lower().strip() + + # Validate email format + try: + validate_email(new_email) + except EmailNotValidError: + return jsonify({ + 'error': 'invalid_email', + 'message': 'Please provide a valid email address' + }), 400 + + # Check if email is already taken + existing_user = User.query.filter_by(email=new_email).first() + if existing_user and existing_user.id != user.id: + return jsonify({ + 'error': 'email_taken', + 'message': 'Email is already taken' + }), 409 + + user.email = new_email + + db.session.commit() + + return jsonify({ + 'message': 'Profile updated successfully', + 'user': user.to_dict() + }), 200 + + except Exception as e: + db.session.rollback() + return jsonify({ + 'error': 'update_failed', + 'message': 'Profile update failed' + }), 500 + +@auth_bp.route('/change-password', methods=['POST']) +@jwt_required() +def change_password(): + """ + Change user password + + Expected JSON: + { + "current_password": "old_password", + "new_password": "new_secure_password" + } + """ + try: + user = get_current_user() + + if not user: + return jsonify({ + 'error': 'user_not_found', + 'message': 'User not found' + }), 404 + + data = request.get_json() + + if not data or not data.get('current_password') or not data.get('new_password'): + return jsonify({ + 'error': 'missing_fields', + 'message': 'Current password and new password are required' + }), 400 + + current_password = data['current_password'] + new_password = data['new_password'] + + # Verify current password + if not user.check_password(current_password): + return jsonify({ + 'error': 'invalid_password', + 'message': 'Current password is incorrect' + }), 401 + + # Validate new password + if len(new_password) < 6: + return jsonify({ + 'error': 'weak_password', + 'message': 'New password must be at least 6 characters long' + }), 400 + + # Update password + user.set_password(new_password) + db.session.commit() + + return jsonify({ + 'message': 'Password changed successfully' + }), 200 + + except Exception as e: + db.session.rollback() + return jsonify({ + 'error': 'password_change_failed', + 'message': 'Password change failed' + }), 500 diff --git a/planventure-api/auth_utils.py b/planventure-api/auth_utils.py new file mode 100644 index 0000000..30a9142 --- /dev/null +++ b/planventure-api/auth_utils.py @@ -0,0 +1,139 @@ +""" +JWT Token utilities for PlanVenture API +Handles token generation, validation, and user authentication +""" + +from datetime import datetime, timedelta +from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, jwt_required +from flask import current_app +from models.user import User + +def generate_tokens(user): + """ + Generate access and refresh tokens for a user + + Args: + user: User model instance + + Returns: + dict: Contains access_token, refresh_token, and expires_in + """ + # Create access token (expires in 1 hour) + access_token = create_access_token( + identity=str(user.id), # Convert to string + expires_delta=timedelta(hours=1) + ) + + # Create refresh token (expires in 30 days) + refresh_token = create_refresh_token( + identity=str(user.id), # Convert to string + expires_delta=timedelta(days=30) + ) + + return { + 'access_token': access_token, + 'refresh_token': refresh_token, + 'token_type': 'Bearer', + 'expires_in': 3600 # 1 hour in seconds + } + +def get_current_user(): + """ + Get the current authenticated user from JWT token + + Returns: + User: Current user model instance or None + """ + try: + current_user_id = get_jwt_identity() + if current_user_id: + return User.query.get(int(current_user_id)) # Convert back to int + return None + except Exception: + return None + +def verify_token_user(user_id): + """ + Verify that the current token belongs to the specified user + + Args: + user_id: ID of the user to verify + + Returns: + bool: True if token belongs to the user, False otherwise + """ + current_user_id = get_jwt_identity() + return current_user_id == str(user_id) # Compare as strings + +def refresh_access_token(): + """ + Generate a new access token using the refresh token + + Returns: + dict: Contains new access_token and expires_in + """ + current_user_id = get_jwt_identity() + user = User.query.get(int(current_user_id)) # Convert to int + + if not user: + return None + + # Create new access token + access_token = create_access_token( + identity=user.id, + expires_delta=timedelta(hours=1) + ) + + return { + 'access_token': access_token, + 'token_type': 'Bearer', + 'expires_in': 3600 + } + +def create_token_response(user): + """ + Create a complete token response with user information + + Args: + user: User model instance + + Returns: + dict: Complete authentication response + """ + tokens = generate_tokens(user) + + return { + 'message': 'Authentication successful', + 'user': user.to_dict(), + **tokens + } + +# Decorator for routes that require authentication +def auth_required(f): + """ + Decorator to require authentication for routes + Usage: @auth_required + """ + from functools import wraps + + @wraps(f) + @jwt_required() + def decorated_function(*args, **kwargs): + return f(*args, **kwargs) + + return decorated_function + +# Decorator for optional authentication +def auth_optional(f): + """ + Decorator for routes where authentication is optional + Usage: @auth_optional + """ + from functools import wraps + + @wraps(f) + @jwt_required(optional=True) + def decorated_function(*args, **kwargs): + return f(*args, **kwargs) + + return decorated_function diff --git a/planventure-api/database.py b/planventure-api/database.py new file mode 100644 index 0000000..9d7d529 --- /dev/null +++ b/planventure-api/database.py @@ -0,0 +1,24 @@ +""" +Database configuration and initialization for PlanVenture API +""" + +from flask_sqlalchemy import SQLAlchemy + +# Create the database instance +db = SQLAlchemy() + +def init_db(app): + """Initialize the database with the Flask app""" + db.init_app(app) + + # Import all models to ensure they are registered + from models.user import User + from models.trip import Trip + + with app.app_context(): + db.create_all() + print("Database tables created successfully!") + +def get_db(): + """Get the database instance""" + return db diff --git a/planventure-api/init_db.py b/planventure-api/init_db.py new file mode 100644 index 0000000..f884ad8 --- /dev/null +++ b/planventure-api/init_db.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Database initialization script for PlanVenture API + +This script creates all database tables defined in the SQLAlchemy models. +Run this script to set up your database before starting the Flask application. + +Usage: + python init_db.py +""" + +import os +from flask import Flask +from dotenv import load_dotenv +from database import db + +# Load environment variables +load_dotenv() + +def create_app(): + """Create a Flask app instance for database initialization""" + app = Flask(__name__) + + # Basic configurations + app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key') + app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///planventure.db') + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + + return app + +def init_database(): + """Initialize the database and create all tables""" + app = create_app() + + # Initialize SQLAlchemy with the app + db.init_app(app) + + # Import models to register them with SQLAlchemy + # This ensures the tables are created + with app.app_context(): + try: + # Import all models here + from models.user import User + from models.trip import Trip + + print("Creating database tables...") + + # Drop all tables first (for clean slate) + db.drop_all() + print("✓ Existing tables dropped") + + # Create all tables + db.create_all() + + # Commit the transaction explicitly + db.session.commit() + + print("✓ Database tables created successfully!") + + # Verify the creation by inspecting the database + from sqlalchemy import inspect + inspector = inspect(db.engine) + table_names = inspector.get_table_names() + print(f"✓ Tables in database: {table_names}") + + print(f"✓ Database location: {app.config['SQLALCHEMY_DATABASE_URI']}") + + # Verify tables were created by checking if we can query them + user_count = User.query.count() + trip_count = Trip.query.count() + print(f"✓ Users table verified (current count: {user_count})") + print(f"✓ Trips table verified (current count: {trip_count})") + + except Exception as e: + print(f"✗ Error creating database tables: {e}") + import traceback + traceback.print_exc() + return False + + return True + +if __name__ == '__main__': + print("=" * 50) + print("PlanVenture API - Database Initialization") + print("=" * 50) + + success = init_database() + + if success: + print("\nDatabase initialization completed successfully!") + print("You can now start your Flask application with: python app.py") + else: + print("\nDatabase initialization failed!") + print("Please check the error messages above and try again.") + + print("=" * 50) diff --git a/planventure-api/models/__init__.py b/planventure-api/models/__init__.py new file mode 100644 index 0000000..27356b2 --- /dev/null +++ b/planventure-api/models/__init__.py @@ -0,0 +1,4 @@ +from .user import User +from .trip import Trip + +__all__ = ['User', 'Trip'] diff --git a/planventure-api/models/trip.py b/planventure-api/models/trip.py new file mode 100644 index 0000000..7084caf --- /dev/null +++ b/planventure-api/models/trip.py @@ -0,0 +1,110 @@ +from datetime import datetime +from database import db + +class Trip(db.Model): + __tablename__ = 'trips' + + id = db.Column(db.Integer, primary_key=True) + + # User relationship + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + + # Trip details + destination = db.Column(db.String(255), nullable=False) + start_date = db.Column(db.Date, nullable=False) + end_date = db.Column(db.Date, nullable=False) + + # Coordinates (latitude and longitude) + latitude = db.Column(db.Float, nullable=True) + longitude = db.Column(db.Float, nullable=True) + + # Itinerary as JSON text + itinerary = db.Column(db.Text, nullable=True) + + # Additional trip information + description = db.Column(db.Text, nullable=True) + budget = db.Column(db.Float, nullable=True) + status = db.Column(db.String(50), default='planned') # planned, active, completed, cancelled + + # Timestamps + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def __init__(self, user_id, destination, start_date, end_date, latitude=None, longitude=None, itinerary=None, description=None, budget=None): + self.user_id = user_id + self.destination = destination + self.start_date = start_date + self.end_date = end_date + self.latitude = latitude + self.longitude = longitude + self.itinerary = itinerary + self.description = description + self.budget = budget + + def set_coordinates(self, latitude, longitude): + """Set the coordinates for the trip destination""" + self.latitude = latitude + self.longitude = longitude + + def get_coordinates(self): + """Get the coordinates as a tuple (latitude, longitude)""" + if self.latitude is not None and self.longitude is not None: + return (self.latitude, self.longitude) + return None + + def set_itinerary(self, itinerary_data): + """Set the itinerary (can be a string or will be converted to string)""" + if isinstance(itinerary_data, str): + self.itinerary = itinerary_data + else: + # If it's a dict or list, convert to JSON string + import json + self.itinerary = json.dumps(itinerary_data) + + def get_itinerary(self): + """Get the itinerary as a parsed object (if it's JSON) or as string""" + if self.itinerary: + try: + import json + return json.loads(self.itinerary) + except json.JSONDecodeError: + return self.itinerary + return None + + def get_duration_days(self): + """Calculate the duration of the trip in days""" + if self.start_date and self.end_date: + return (self.end_date - self.start_date).days + 1 + return 0 + + def is_active(self): + """Check if the trip is currently active (between start and end dates)""" + today = datetime.now().date() + if self.start_date and self.end_date: + return self.start_date <= today <= self.end_date + return False + + def to_dict(self): + """Convert trip object to dictionary""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'destination': self.destination, + 'start_date': self.start_date.isoformat() if self.start_date else None, + 'end_date': self.end_date.isoformat() if self.end_date else None, + 'coordinates': { + 'latitude': self.latitude, + 'longitude': self.longitude + } if self.latitude is not None and self.longitude is not None else None, + 'itinerary': self.get_itinerary(), + 'description': self.description, + 'budget': self.budget, + 'status': self.status, + 'duration_days': self.get_duration_days(), + 'is_active': self.is_active(), + 'created_at': self.created_at.isoformat(), + 'updated_at': self.updated_at.isoformat() + } + + def __repr__(self): + return f'' diff --git a/planventure-api/models/user.py b/planventure-api/models/user.py new file mode 100644 index 0000000..1c3194f --- /dev/null +++ b/planventure-api/models/user.py @@ -0,0 +1,67 @@ +from datetime import datetime +from database import db +from bcrypt import hashpw, gensalt, checkpw + +class User(db.Model): + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationship with trips + trips = db.relationship('Trip', backref='user', lazy=True, cascade='all, delete-orphan') + + def __init__(self, email, password): + self.email = email + self.set_password(password) + + def set_password(self, password): + """Hash and set the password""" + self.password_hash = hashpw(password.encode('utf-8'), gensalt()).decode('utf-8') + + def check_password(self, password): + """Check if provided password matches the hash""" + return checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8')) + + def get_trips_count(self): + """Get the total number of trips for this user""" + return len(self.trips) + + def get_active_trips(self): + """Get all currently active trips for this user""" + from datetime import date + today = date.today() + return [trip for trip in self.trips if trip.start_date <= today <= trip.end_date] + + def get_upcoming_trips(self): + """Get all upcoming trips for this user""" + from datetime import date + today = date.today() + return [trip for trip in self.trips if trip.start_date > today] + + def get_past_trips(self): + """Get all past trips for this user""" + from datetime import date + today = date.today() + return [trip for trip in self.trips if trip.end_date < today] + + def to_dict(self, include_trips=False): + """Convert user object to dictionary (excluding password_hash)""" + user_dict = { + 'id': self.id, + 'email': self.email, + 'created_at': self.created_at.isoformat(), + 'updated_at': self.updated_at.isoformat(), + 'trips_count': self.get_trips_count() + } + + if include_trips: + user_dict['trips'] = [trip.to_dict() for trip in self.trips] + + return user_dict + + def __repr__(self): + return f'' diff --git a/planventure-api/requirements.txt b/planventure-api/requirements.txt index 11babe5..b4c461e 100644 --- a/planventure-api/requirements.txt +++ b/planventure-api/requirements.txt @@ -2,4 +2,7 @@ flask==2.3.3 flask-sqlalchemy==3.1.1 flask-cors==4.0.0 -python-dotenv==1.0.0 \ No newline at end of file +flask-jwt-extended==4.5.3 +python-dotenv==1.0.0 +bcrypt==4.0.1 +email-validator==2.1.0 \ No newline at end of file