diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..7d6e641 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/devcontainers/python:1-3-bookworm + +ENV PYTHONUNBUFFERED 1 + +# [Optional] If your requirements rarely change, uncomment this section to add them to the image. +# COPY requirements.txt /tmp/pip-tmp/ +# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ +# && rm -rf /tmp/pip-tmp + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 292c1c7..c73d7a3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,40 +1,28 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/go +// README at: https://github.com/devcontainers/templates/tree/main/src/postgres { - "name": "Game of Life Walkthrough", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/universal:latest", - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - "forwardPorts": [ - 3000 - ], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "" - - // Configure tool-specific properties. - "customizations": { - "codespaces": { - "openFiles": [ - "index.html", - "README.md" - ] - }, - "vscode": { - "extensions": [ - "GitHub.codespaces", - "GitHub.copilot", - "GitHub.copilot-chat", - "github.copilot-workspace", - "GitHub.remotehub", - "github.vscode-github-actions", - "GitHub.vscode-pull-request-github", - "ms-vscode.live-server" - ] - } - } - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" - } + "name": "Python 3 & PostgreSQL", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers/features/node:1": {} + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // This can be used to network with other containers or the host. + // "forwardPorts": [5000, 5432], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "pip install --user -r requirements.txt", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..42dfa2a --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' + +services: + app: + build: + context: .. + dockerfile: .devcontainer/Dockerfile + + volumes: + - ../..:/workspaces:cached + + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity + + # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. + network_mode: service:db + + # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + + db: + image: postgres:latest + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_PASSWORD: postgres + + # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. + # (Adding the "ports" property to this file will not forward from a Codespace.) + +volumes: + postgres-data: \ No newline at end of file diff --git a/.gitignore b/.gitignore index 51e4ddf..1b51498 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,181 @@ -# Replace the .gitignore with the appropriate one from https://github.com/github/gitignore \ No newline at end of file +.vscode +# Bruno adds a dir to your vscode workspace +Planventure +__pycache__ +.DS_Store + +# Add the .gitignore from https://github.com/github/gitignore/blob/main/Python.gitignore +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# 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 + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc \ No newline at end of file diff --git a/README.md b/README.md index 404aa9f..165606a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,58 @@ -# Walkthrough Template +# Planventure API 🚁 -This repository serves as a template for creating a walkthrough. Follow the steps below to get started. +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/github-samples/planventure) -## Getting Started +A Flask-based REST API backend for the Planventure application. -1. Clone this repository. -2. Update the documentation in the `docs` folder (including the `README.md` folder). -3. Customize the code and other content as needed. -4. Update the `SUPPORT.md` file with the appropriate information. -5. Review the default LICENSE (MIT), CODE_OF_CONDUCT, and CONTRIBUTING files to ensure they meet your needs. These use the samples provided as part of the OSPO release process. -6. Update the `README.md` file in the repository root with the appropriate information. You can find an example at [github-samples/game-of-life-walkthrough](https://github.com/github-samples/game-of-life-walkthrough). -7. When you are ready to publish the repository, please make sure that the Git history is clean. Then, raise an issue for a 'sample release' at [https://github.com/github/open-source-releases](https://github.com/github/open-source-releases). +## Prerequisites +Before you begin, ensure you have the following: + +- A GitHub account - [sign up for FREE](https://github.com) +- Access to GitHub Copilot - [sign up for FREE](https://gh.io/gfb-copilot)! +- A Code Editor - [VS Code](https://code.visualstudio.com/download) is recommended +- API Client (like [Bruno](https://github.com/usebruno/bruno)) +- Git - [Download & Install Git](https://git-scm.com/downloads) + +## πŸš€ Getting Started + +## Build along in a Codespace + +1. Click the "Open in GitHub Codespaces" button above to start developing in a GitHub Codespace. + +### Local Development Setup + +If you prefer to develop locally, follow the steps below: + +1.Fork and clone the repository and navigate to the [planventue-api](/planventure-api/) directory: +```sh +cd planventure-api +``` + +2. Create a virtual environment and activate it: +```sh +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +3. Install the required dependencies: +```sh +pip install -r requirements.txt +``` + +4. Create an `.env` file based on [.sample.env](/planventure-api/.sample.env): +```sh +cp .sample.env .env +``` + +5. Start the Flask development server: +```sh +flask run +``` + +## πŸ“š API Endpoints +- GET / - Welcome message +- GET /health - Health check endpoint + +## πŸ“ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c176aa9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security + +Thanks for helping make GitHub safe for everyone. + +GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). + +Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation. + +## Reporting Security Issues + +If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure. + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Instead, please send an email to opensource-security[@]github.com. + +Please include as much of the information listed below as you can to help us better understand and resolve the issue: + + * The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Policy + +See [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms) \ No newline at end of file diff --git a/SUPPORT.md b/SUPPORT.md index 0726719..be056c0 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,20 +1,14 @@ -# TODO: The maintainer of this repo has not updated this file - # Support ## How to file issues and get help This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. -For help or questions about using this project, please **TODO:** REPO MAINTAINER TO INSERT INSTRUCTIONS ON HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A SLACK OR DISCORD OR OTHER CHANNEL FOR HELP. WHERE WILL YOU HELP PEOPLE? +For help or questions about using this project, please open a new issue. The maintainers and community will try to help you as best as they can. -**TODO: REPO MAINTAINERS** Please include one of the following statements file: +**PLANVENTURE** is not actively developed but is maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support and community questions in a timely manner. -- **THIS PROJECT NAME** is under active development and maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support, feature requests, and community questions in a timely manner. -- **THIS PROJECT NAME** is not actively developed but is maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support and community questions in a timely manner. -- **THIS PROJECT NAME** is no longer supported or maintained by GitHub staff. We will not respond to support or community questions. -- **THIS PROJECT NAME** is archived and deprecated. As an unsupported project, feel free to fork. ## GitHub Support Policy diff --git a/docs/0-pre-requisites.md b/docs/0-pre-requisites.md deleted file mode 100644 index e0d51b4..0000000 --- a/docs/0-pre-requisites.md +++ /dev/null @@ -1,16 +0,0 @@ -# Pre-requisites - -| [← Back to README][walkthrough-previous] | [Next: Introduction β†’][walkthrough-next] | -|:-----------------------------------|------------------------------------------:| - -List all the pre-requisites needed for this walkthrough. This may include software, tools, accounts, etc. - -- Pre-requisite 1 -- Pre-requisite 2 -- Pre-requisite 3 - -| [← Back to README][walkthrough-previous] | [Next: Introduction β†’][walkthrough-next] | -|:-----------------------------------|------------------------------------------:| - -[walkthrough-previous]: ../README.md -[walkthrough-next]: 1-introduction.md diff --git a/docs/1-introduction.md b/docs/1-introduction.md deleted file mode 100644 index 66a07fe..0000000 --- a/docs/1-introduction.md +++ /dev/null @@ -1,16 +0,0 @@ -# Introduction - -| [← Back to Pre-requisites][walkthrough-previous] | [Next: Template β†’][walkthrough-next] | -|:-----------------------------------|------------------------------------------:| - -Provide an introduction to your walkthrough. Explain the purpose, goals, and any other relevant information. - -- Overview of the walkthrough -- Objectives -- Expected outcomes - -| [← Back to Pre-requisites][walkthrough-previous] | [Next: Template β†’][walkthrough-next] | -|:-----------------------------------|------------------------------------------:| - -[walkthrough-previous]: 0-pre-requisites.md -[walkthrough-next]: template.md diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 36a21d1..0000000 --- a/docs/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Table of Contents - -This folder contains the documentation for your walkthrough. Please update the individual pages to reflect the content of your walkthrough. - -## Pages - -1. [Pre-requisites](0-pre-requisites.md) -2. [Introduction](1-introduction.md) -3. [Template](template.md) - -Feel free to add more pages as needed. diff --git a/docs/images/README.md b/docs/images/README.md deleted file mode 100644 index 2fd76e4..0000000 --- a/docs/images/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Supporting Images - -Please upload your images to this folder for consistency. \ No newline at end of file diff --git a/docs/template.md b/docs/template.md deleted file mode 100644 index e68d394..0000000 --- a/docs/template.md +++ /dev/null @@ -1,24 +0,0 @@ -# Page Title - -| [← Back to Introduction][walkthrough-previous] | [Next: (Next Page) β†’][walkthrough-next] | -|:-----------------------------------|------------------------------------------:| - -Use this template to create new pages for your walkthrough. Replace the headings and content with your own. - -## Heading 1 - -Content for heading 1. - -## Heading 2 - -Content for heading 2. - -## Heading 3 - -Content for heading 3. - -| [← Back to Introduction][walkthrough-previous] | [Next: (Next Page) β†’][walkthrough-next] | -|:-----------------------------------|------------------------------------------:| - -[walkthrough-previous]: 1-introduction.md -[walkthrough-next]: (next-page).md diff --git a/index.html b/index.html deleted file mode 100644 index e69de29..0000000 diff --git a/planventure-api/.sample.env b/planventure-api/.sample.env new file mode 100644 index 0000000..53df029 --- /dev/null +++ b/planventure-api/.sample.env @@ -0,0 +1,4 @@ +SECRET_KEY=your-secret-key-here +JWT_SECRET_KEY=your-jwt-secret-key-here +DATABASE_URL=your-sqldatabase-url-here +CORS_ORIGINS=your-cors-origins-here-host-hopefully-localhost:3000 \ No newline at end of file diff --git a/planventure-api/PROMPTS.md b/planventure-api/PROMPTS.md new file mode 100644 index 0000000..60b4ee9 --- /dev/null +++ b/planventure-api/PROMPTS.md @@ -0,0 +1,252 @@ +# Building the Planventure API with GitHub Copilot + +This guide will walk you through creating a Flask-based REST API with SQLAlchemy and JWT authentication using GitHub Copilot to accelerate development. + +## Prerequisites + +- Python 3.8 or higher +- VS Code with GitHub Copilot extension +- Bruno API Client (for testing API endpoints) +- Git installed + +## Project Structure + +We'll be working in the `api-start` branch and creating a structured API with: +- Authentication system +- Database models +- CRUD operations for trips +- JWT token protection + +## Step 1: Project Setup +### Prompts to Configure Flask with SQLAlchemy + +Open Copilot Chat and type: +``` +@workspace Update the Flask app with SQLAlchemy and basic configurations +``` + +When the code is generated, click "Apply in editor" to update your `app.py` file. + +### Update Dependencies + +In Copilot Chat, type: +``` +update requirements.txt with necessary packages for Flask API with SQLAlchemy and JWT +``` + +Install the updated dependencies: +```bash +pip install -r requirements.txt +``` + +### Create .env File + +Create a `.env` file for environment variables and add it to `.gitignore`. + +## Step 2: Database Models + +### User Model + +In Copilot Edits, type: +``` +Create SQLAlchemy User model with email, password_hash, and timestamps. add code in new files +``` + +Review and accept the generated code. + +### Initialize Database Tables + +Ask Copilot to create a database initialization script: +``` +update code to be able to create the db tables with a python shell script +``` + +Run the initialization script: +```bash +python init_db.py +``` + +### Install SQLite Viewer Extension + +1. Go to VS Code extensions +2. Search for "SQLite viewer" +3. Install the extension +4. Click on `init_db.py` to view the created tables + +### Trip Model + +In Copilot Edits, type: +``` +Create SQLAlchemy Trip model with user relationship, destination, start date, end date, coordinates and itinerary +``` + +Accept changes and run the initialization script again: +```bash +python3 init_db.py +``` + +### Commit Your Changes + +Use Source Control in VS Code: +1. Stage all changes +2. Click the sparkle icon to generate a commit message with Copilot +3. Click commit + +## Step 3: Authentication System + +### Password Hashing Utilities + +In Copilot Edits, type: +``` +Create password hashing and salt utility functions for the User model +``` + +Review, accept changes, and install required packages: +```bash +pip install bcrypt +``` + +### JWT Token Functions + +In Copilot Edits, type: +``` +Setup JWT token generation and validation functions +``` + +Review, accept changes, and install the JWT package: +```bash +pip install flask-jwt-extended +``` + +### Registration Route + +In Copilot Edits, type: +``` +Create auth routes for user registration with email validation +``` + +Review and accept the changes. + +### Test Registration Route + +Use Bruno API Client: +1. Create a new POST request +2. Set URL to `http://localhost:5000/auth/register` +3. Add header: `Content-Type: application/json` +4. Add JSON body: +```json +{ + "email": "user@example.com", + "password": "test1234" +} +``` +5. Send the request and verify the response + +### Login Route + +In Copilot Edits, type: +``` +Create login route with JWT token generation +``` + +Review, accept changes, and restart the Flask server. + +### Enable Development Mode + +To have Flask automatically reload on code changes: + +```bash +export FLASK_DEBUG=1 +flask run +``` + +### Authentication Middleware + +In Copilot Edits, type: +``` +Create auth middleware to protect routes +``` + +Review and accept the changes. + +### Commit Your Changes + +Use Source Control and Copilot to create a commit message. + +## Step 4: Trip Routes + +### Create Trip Routes Blueprint + +In Copilot Edits, type: +``` +Create Trip routes blueprint with CRUD operations +``` + +Review and accept the changes. + +> **Note**: Ensure that `verify_jwt_in_request` is set to `verify_jwt_in_request(optional=True)` if needed + +### Test Trip Routes + +Use Bruno API Client to test: +1. CREATE a new trip +2. GET a trip by ID + +### Add Itinerary Template Generator + +In Copilot Edits, type: +``` +Create function to generate default itinerary template +``` + +Review, accept changes, and test the updated route. + +## Step 5: Finalize API + +### Configure CORS for Frontend Access + +In Copilot Edits, type: +``` +Setup CORS configuration for React frontend +``` + +Review and accept the changes. + +### Add Health Check Endpoint + +In Copilot Edits, type: +``` +Create basic health check endpoint +``` + +Review and accept the changes. + +### Commit Final Changes + +Use Source Control with Copilot to create your final commit. + +### Create README + +Ask Copilot to write a comprehensive README for your API project. + +## Common Issues and Solutions + +### GOTCHAS: + +- Ensure there are no trailing slashes in any of the routes - especially the base `/trip` route +- Make sure all required packages are installed +- Check that JWT token validation is configured correctly +- Verify database tables are created properly using the SQLite viewer + +## Next Steps + +Consider these enhancements for your API: +- Add more comprehensive input validation +- Create custom error handlers for HTTP exceptions +- Setup logging configuration +- Add validation error handlers for form data +- Configure database migrations + +## Conclusion + +You now have a fully functional API with authentication, database models, and protected routes. This can serve as the backend for your Planventure application! \ No newline at end of file diff --git a/planventure-api/README.md b/planventure-api/README.md new file mode 100644 index 0000000..2a326f8 --- /dev/null +++ b/planventure-api/README.md @@ -0,0 +1,711 @@ +# PlanVenture API + +A comprehensive Flask-based REST API for managing travel planning with user authentication, trip management, and itinerary generation. Built with SQLAlchemy, JWT authentication, and designed for easy integration with frontend applications. + +## πŸ“‹ Table of Contents + +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Configuration](#configuration) +- [Database Setup](#database-setup) +- [API Documentation](#api-documentation) +- [Project Structure](#project-structure) +- [Development](#development) +- [Testing](#testing) +- [Deployment](#deployment) +- [Contributing](#contributing) +- [License](#license) + +## ✨ Features + +- **User Authentication** + + - Secure registration and login with JWT tokens + - Password hashing with bcrypt + - Email validation + - Token refresh mechanism + - Protected routes with authentication middleware + +- **Trip Management** + + - Create, read, update, and delete trips + - Store destination, dates, and coordinates + - Automatic itinerary generation + - Activity suggestions based on destination + - User-specific trip isolation + +- **Security** + + - JWT-based authentication + - CORS configuration + - Environment variable management + - SQL injection protection via SQLAlchemy ORM + +- **Developer Experience** + - Comprehensive API examples + - Shell script for testing + - SQLite database for local development + - PostgreSQL support for production + - Docker support via devcontainer + +## πŸ”§ Prerequisites + +Before you begin, ensure you have: + +- **Python 3.8+** - [Download Python](https://www.python.org/downloads/) +- **pip** - Python package manager (included with Python) +- **Git** - [Download Git](https://git-scm.com/downloads) +- **API Client** - [Bruno](https://github.com/usebruno/bruno), [Postman](https://www.postman.com/), or curl +- **Code Editor** - [VS Code](https://code.visualstudio.com/) recommended + +## πŸš€ Installation + +### Local Development + +1. **Clone the repository** + + ```bash + git clone https://github.com/Josh-pierce2026/planventure.git + cd planventure/planventure-api + ``` + +2. **Create a virtual environment** + + ```bash + python -m venv venv + + # On macOS/Linux + source venv/bin/activate + + # On Windows + venv\Scripts\activate + ``` + +3. **Install dependencies** + ```bash + pip install -r requirements.txt + ``` + +### GitHub Codespaces + +Click the "Open in GitHub Codespaces" button at the top of this README to start developing in a cloud-based environment with all dependencies pre-installed. + +### Docker Development + +1. **Open in VS Code with Dev Containers extension** + + ```bash + code . + ``` + +2. **Reopen in container** (Command Palette: "Dev Containers: Reopen in Container") + +## βš™οΈ Configuration + +1. **Create environment file** + + ```bash + cp .sample.env .env + ``` + +2. **Configure environment variables** in `.env`: + + ```env + SECRET_KEY=your-secret-key-here + JWT_SECRET_KEY=your-jwt-secret-key-here + DATABASE_URL=sqlite:///planventure.db + CORS_ORIGINS=http://localhost:3000 + ``` + + **Environment Variables:** + + - `SECRET_KEY` - Flask secret key for session management + - `JWT_SECRET_KEY` - Secret key for JWT token generation + - `DATABASE_URL` - Database connection string + - `CORS_ORIGINS` - Comma-separated list of allowed origins + +## πŸ—„οΈ Database Setup + +1. **Initialize the database** + + ```bash + python scripts/init_db.py + ``` + +2. **Reset database (optional)** + + ```bash + python scripts/init_db.py --drop + ``` + +3. **View database** (VS Code SQLite extension) + - Install "SQLite Viewer" extension + - Click on `instance/planventure.db` to view tables + +### Database Schema + +**Users Table:** +| Column | Type | Description | +|--------|------|-------------| +| id | Integer | Primary key | +| email | String(120) | Unique, indexed | +| password_hash | String(255) | Bcrypt hashed password | +| created_at | DateTime | Account creation timestamp | +| updated_at | DateTime | Last update timestamp | + +**Trips Table:** +| Column | Type | Description | +|--------|------|-------------| +| id | Integer | Primary key | +| user_id | Integer | Foreign key to users | +| destination | String(200) | Trip destination | +| start_date | Date | Trip start date | +| end_date | Date | Trip end date | +| latitude | Float | Destination latitude (optional) | +| longitude | Float | Destination longitude (optional) | +| itinerary | Text | Trip itinerary (auto-generated if not provided) | +| created_at | DateTime | Trip creation timestamp | +| updated_at | DateTime | Last update timestamp | + +## πŸ“– API Documentation + +### Base URL + +``` +http://localhost:5000 +``` + +### Authentication + +All protected endpoints require a JWT token in the Authorization header: + +``` +Authorization: Bearer +``` + +### Endpoints + +#### Health Check + +```http +GET /health +``` + +Returns API health status and database connection state. + +**Response:** + +```json +{ + "status": "healthy", + "database": "connected" +} +``` + +#### User Registration + +```http +POST /auth/register +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "securepassword123" +} +``` + +**Success Response (201):** + +```json +{ + "message": "User registered successfully", + "user": { + "id": 1, + "email": "user@example.com", + "created_at": "2025-01-15T10:30:00" + }, + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...", + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGc..." +} +``` + +#### User Login + +```http +POST /auth/login +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "securepassword123" +} +``` + +**Success Response (200):** + +```json +{ + "message": "Login successful", + "user": { + "id": 1, + "email": "user@example.com" + }, + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...", + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGc..." +} +``` + +#### Get Current User + +```http +GET /auth/me +Authorization: Bearer +``` + +**Success Response (200):** + +```json +{ + "user": { + "id": 1, + "email": "user@example.com", + "created_at": "2025-01-15T10:30:00" + } +} +``` + +#### Refresh Token + +```http +POST /auth/refresh +Authorization: Bearer +``` + +**Success Response (200):** + +```json +{ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGc..." +} +``` + +#### Logout + +```http +POST /auth/logout +Authorization: Bearer +``` + +**Success Response (200):** + +```json +{ + "message": "Logout successful. Please discard your tokens." +} +``` + +#### Create Trip + +```http +POST /trips +Authorization: Bearer +Content-Type: application/json + +{ + "destination": "Paris, France", + "start_date": "2025-06-15", + "end_date": "2025-06-22", + "latitude": 48.8566, + "longitude": 2.3522, + "itinerary": "Custom itinerary (optional)" +} +``` + +**Success Response (201):** + +```json +{ + "message": "Trip created successfully", + "trip": { + "id": 1, + "user_id": 1, + "destination": "Paris, France", + "start_date": "2025-06-15", + "end_date": "2025-06-22", + "coordinates": { + "latitude": 48.8566, + "longitude": 2.3522 + }, + "itinerary": "Generated or custom itinerary...", + "created_at": "2025-01-15T10:30:00" + }, + "activity_suggestions": [ + "Visit local landmarks and attractions", + "Try local cuisine and restaurants", + "..." + ] +} +``` + +#### Get All Trips + +```http +GET /trips +Authorization: Bearer +``` + +**Success Response (200):** + +```json +{ + "trips": [ + { + "id": 1, + "destination": "Paris, France", + "start_date": "2025-06-15", + "end_date": "2025-06-22", + "..." + } + ], + "count": 1 +} +``` + +#### Get Trip by ID + +```http +GET /trips/{trip_id} +Authorization: Bearer +``` + +**Success Response (200):** + +```json +{ + "trip": { + "id": 1, + "destination": "Paris, France", + "start_date": "2025-06-15", + "end_date": "2025-06-22", + "..." + } +} +``` + +#### Update Trip + +```http +PUT /trips/{trip_id} +Authorization: Bearer +Content-Type: application/json + +{ + "destination": "Paris & Nice, France", + "itinerary": "Updated itinerary..." +} +``` + +**Success Response (200):** + +```json +{ + "message": "Trip updated successfully", + "trip": { + "id": 1, + "destination": "Paris & Nice, France", + "..." + } +} +``` + +#### Delete Trip + +```http +DELETE /trips/{trip_id} +Authorization: Bearer +``` + +**Success Response (200):** + +```json +{ + "message": "Trip deleted successfully" +} +``` + +### Error Responses + +All endpoints may return the following error responses: + +**400 Bad Request:** + +```json +{ + "error": "Validation error message" +} +``` + +**401 Unauthorized:** + +```json +{ + "error": "Missing authorization token" +} +``` + +**404 Not Found:** + +```json +{ + "error": "Resource not found" +} +``` + +**409 Conflict:** + +```json +{ + "error": "Resource already exists" +} +``` + +**500 Internal Server Error:** + +```json +{ + "error": "Error message details" +} +``` + +## πŸ“ Project Structure + +``` +planventure-api/ +β”œβ”€β”€ app.py # Flask application factory +β”œβ”€β”€ database.py # Database initialization +β”œβ”€β”€ requirements.txt # Python dependencies +β”œβ”€β”€ .sample.env # Environment variables template +β”œβ”€β”€ PROMPTS.md # GitHub Copilot development guide +β”œβ”€β”€ middleware/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ └── auth.py # Authentication middleware +β”œβ”€β”€ models/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ user.py # User model +β”‚ └── trip.py # Trip model +β”œβ”€β”€ routes/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ auth.py # Authentication routes +β”‚ └── trips.py # Trip management routes +β”œβ”€β”€ utils/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ auth.py # JWT token utilities +β”‚ β”œβ”€β”€ password.py # Password hashing utilities +β”‚ └── itinerary.py # Itinerary generation utilities +β”œβ”€β”€ scripts/ +β”‚ └── init_db.py # Database initialization script +β”œβ”€β”€ examples/ +β”‚ β”œβ”€β”€ README.md # API testing examples +β”‚ β”œβ”€β”€ test_trips.sh # Automated testing script +β”‚ └── trips_examples.json # Example trip data +└── instance/ + └── planventure.db # SQLite database (created at runtime) +``` + +## πŸ› οΈ Development + +### Running the Development Server + +```bash +# Enable debug mode +export FLASK_DEBUG=1 + +# Run the server +flask run +``` + +The API will be available at `http://localhost:5000` + +### Code Quality + +This project follows Python best practices: + +- **PEP 8** style guide compliance +- **Type hints** where applicable +- **Docstrings** for all functions and classes +- **Error handling** with try-except blocks +- **Input validation** on all endpoints + +### Common Development Tasks + +**Add a new endpoint:** + +1. Create route function in appropriate blueprint (`routes/auth.py` or `routes/trips.py`) +2. Add `@require_auth` decorator if authentication is needed +3. Implement business logic +4. Add error handling +5. Test with API client + +**Add a new model:** + +1. Create model class in `models/` directory +2. Import in `models/__init__.py` +3. Run `python scripts/init_db.py --drop` to recreate tables +4. Update related routes and utilities + +**Add authentication to a route:** + +```python +from middleware import require_auth, get_current_user + +@trips_bp.route('/example') +@require_auth +def example_route(): + user = get_current_user() + # Your route logic here +``` + +## πŸ§ͺ Testing + +### Manual Testing with curl + +See `examples/README.md` for detailed curl examples. + +### Automated Testing Script + +```bash +# First, register and login to get a token +curl -X POST http://localhost:5000/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"password123"}' + +curl -X POST http://localhost:5000/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"password123"}' + +# Run the test script with your token +chmod +x examples/test_trips.sh +./examples/test_trips.sh YOUR_ACCESS_TOKEN +``` + +### Testing with Bruno + +1. Install [Bruno](https://github.com/usebruno/bruno) +2. Create a new collection +3. Import requests from `examples/trips_examples.json` +4. Set environment variable `access_token` with your JWT token +5. Run requests + +## 🚒 Deployment + +### Production Considerations + +1. **Database:** Switch from SQLite to PostgreSQL + + ```bash + # Install PostgreSQL driver + pip install psycopg2-binary + + # Update DATABASE_URL in .env + DATABASE_URL=postgresql://user:password@host:port/database + ``` + +2. **Security:** + + - Use strong `SECRET_KEY` and `JWT_SECRET_KEY` + - Enable HTTPS + - Configure CORS for production domains + - Set `SQLALCHEMY_ECHO=False` + - Use environment-specific configuration + +3. **WSGI Server:** Use Gunicorn instead of Flask development server + + ```bash + pip install gunicorn + gunicorn -w 4 -b 0.0.0.0:5000 app:app + ``` + +4. **Environment Variables:** Never commit `.env` file to version control + +### Docker Deployment + +```bash +# Build image +docker build -t planventure-api . + +# Run container +docker run -p 5000:5000 --env-file .env planventure-api +``` + +### Heroku Deployment + +```bash +# Login to Heroku +heroku login + +# Create app +heroku create your-app-name + +# Add PostgreSQL +heroku addons:create heroku-postgresql:hobby-dev + +# Deploy +git push heroku main +``` + +## 🀝 Contributing + +Contributions are welcome! Please follow these guidelines: + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +See `CODE_OF_CONDUCT.md` for community guidelines. + +## πŸ› Common Issues + +### Issue: Database tables not created + +**Solution:** Run `python scripts/init_db.py` + +### Issue: JWT token expired + +**Solution:** Use the refresh token endpoint to get a new access token + +### Issue: CORS errors + +**Solution:** Update `CORS_ORIGINS` in `.env` to include your frontend URL + +### Issue: Import errors + +**Solution:** Ensure virtual environment is activated and dependencies are installed + +### Issue: Route trailing slashes + +**Solution:** Ensure no trailing slashes in route definitions (especially `/trips` base route) + +## πŸ“š Additional Resources + +- [Flask Documentation](https://flask.palletsprojects.com/) +- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/) +- [Flask-JWT-Extended Documentation](https://flask-jwt-extended.readthedocs.io/) +- [GitHub Copilot](https://gh.io/gfb-copilot) + +## πŸ“„ License + +This project is licensed under the MIT License - see the `LICENSE` file for details. + +## πŸ™ Acknowledgments + +- Built with [GitHub Copilot](https://gh.io/gfb-copilot) +- Maintained by GitHub staff and the community +- See `SUPPORT.md` for support information +- See `SECURITY.md` for security policies + +--- + +**Made with ❀️ by the PlanVenture Team** diff --git a/planventure-api/app.py b/planventure-api/app.py new file mode 100644 index 0000000..6f73e6b --- /dev/null +++ b/planventure-api/app.py @@ -0,0 +1,61 @@ +from flask import Flask, jsonify +from flask_cors import CORS +from flask_jwt_extended import JWTManager +from database import db +from dotenv import load_dotenv +import os + +# Load environment variables +load_dotenv() + +app = Flask(__name__) + +# Basic Flask configuration +app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key') + +# JWT configuration +app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', app.config['SECRET_KEY']) +app.config['JWT_ACCESS_TOKEN_EXPIRES'] = 3600 # 1 hour in seconds +app.config['JWT_REFRESH_TOKEN_EXPIRES'] = 2592000 # 30 days in seconds + +# SQLAlchemy configuration +app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///planventure.db') +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['SQLALCHEMY_ECHO'] = True # Set to False in production + +# Initialize extensions with app +db.init_app(app) +jwt = JWTManager(app) + +# CORS configuration +cors_origins = os.getenv('CORS_ORIGINS', 'http://localhost:3000') +CORS(app, + resources={r"/*": {"origins": cors_origins.split(',')}}, + supports_credentials=True, + allow_headers=["Content-Type", "Authorization"], + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) + +# Import models after db initialization +from models import User, Trip + +# Register blueprints +from routes import auth_bp, trips_bp +app.register_blueprint(auth_bp) +app.register_blueprint(trips_bp) + +@app.route('/') +def home(): + return jsonify({"message": "Welcome to PlanVenture API"}) + +@app.route('/health') +def health_check(): + return jsonify({ + "status": "healthy", + "database": "connected" if db.engine else "disconnected" + }) + +# Database tables are created via scripts/init_db.py +# Run: python scripts/init_db.py + +if __name__ == '__main__': + app.run(debug=True) diff --git a/planventure-api/database.py b/planventure-api/database.py new file mode 100644 index 0000000..f0b13d6 --- /dev/null +++ b/planventure-api/database.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/planventure-api/examples/README.md b/planventure-api/examples/README.md new file mode 100644 index 0000000..dd73a3b --- /dev/null +++ b/planventure-api/examples/README.md @@ -0,0 +1,100 @@ +# PlanVenture API Testing Examples + +## Getting Started + +### 1. First, register and login to get an access token: + +```bash +# Register +curl -X POST http://localhost:5000/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"password123"}' + +# Login +curl -X POST http://localhost:5000/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"password123"}' +``` + +Save the `access_token` from the response. + +### 2. Test Trip Routes + +#### Create a Trip + +```bash +curl -X POST http://localhost:5000/trips \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d @examples/trips_examples.json +``` + +Or use specific examples: + +```bash +# Full trip with itinerary +curl -X POST http://localhost:5000/trips \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "destination": "Paris, France", + "start_date": "2025-06-15", + "end_date": "2025-06-22", + "latitude": 48.8566, + "longitude": 2.3522, + "itinerary": "Day 1: Eiffel Tower..." + }' +``` + +#### Get All Trips + +```bash +curl -X GET http://localhost:5000/trips \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +#### Get Specific Trip + +```bash +curl -X GET http://localhost:5000/trips/1 \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +#### Update a Trip + +```bash +curl -X PUT http://localhost:5000/trips/1 \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"destination": "Paris & Nice, France"}' +``` + +#### Delete a Trip + +```bash +curl -X DELETE http://localhost:5000/trips/1 \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### 3. Using the Test Script + +Make the script executable and run it: + +```bash +chmod +x examples/test_trips.sh +./examples/test_trips.sh YOUR_ACCESS_TOKEN +``` + +## API Endpoints Summary + +| Method | Endpoint | Description | Auth Required | +| ------ | ---------------- | -------------------- | ------------- | +| POST | `/auth/register` | Register new user | No | +| POST | `/auth/login` | Login user | No | +| GET | `/auth/me` | Get current user | Yes | +| POST | `/auth/logout` | Logout user | Yes | +| GET | `/trips` | Get all user's trips | Yes | +| POST | `/trips` | Create new trip | Yes | +| GET | `/trips/:id` | Get specific trip | Yes | +| PUT | `/trips/:id` | Update trip | Yes | +| DELETE | `/trips/:id` | Delete trip | Yes | diff --git a/planventure-api/examples/test_trips.sh b/planventure-api/examples/test_trips.sh new file mode 100644 index 0000000..7aacbf5 --- /dev/null +++ b/planventure-api/examples/test_trips.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# PlanVenture API - Trip Routes Testing Script +# Usage: ./test_trips.sh YOUR_ACCESS_TOKEN + +if [ -z "$1" ]; then + echo "Usage: ./test_trips.sh YOUR_ACCESS_TOKEN" + echo "Get your access token by logging in first:" + echo "curl -X POST http://localhost:5000/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"your@email.com\",\"password\":\"yourpassword\"}'" + exit 1 +fi + +TOKEN=$1 +BASE_URL="http://localhost:5000/trips" + +echo "=== Testing PlanVenture Trip Routes ===" +echo "" + +# 1. Create a new trip +echo "1. Creating a new trip..." +RESPONSE=$(curl -s -X POST $BASE_URL \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "destination": "Paris, France", + "start_date": "2025-06-15", + "end_date": "2025-06-22", + "latitude": 48.8566, + "longitude": 2.3522, + "itinerary": "Day 1: Eiffel Tower\nDay 2: Louvre Museum\nDay 3: Versailles" + }') +echo $RESPONSE | python3 -m json.tool +TRIP_ID=$(echo $RESPONSE | python3 -c "import sys, json; print(json.load(sys.stdin)['trip']['id'])" 2>/dev/null) +echo "" + +# 2. Get all trips +echo "2. Getting all trips..." +curl -s -X GET $BASE_URL \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +echo "" + +# 3. Get specific trip +if [ ! -z "$TRIP_ID" ]; then + echo "3. Getting trip with ID $TRIP_ID..." + curl -s -X GET $BASE_URL/$TRIP_ID \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool + echo "" + + # 4. Update trip + echo "4. Updating trip $TRIP_ID..." + curl -s -X PUT $BASE_URL/$TRIP_ID \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "destination": "Paris & Nice, France", + "itinerary": "Extended trip with Nice added!" + }' | python3 -m json.tool + echo "" + + # 5. Delete trip + echo "5. Deleting trip $TRIP_ID..." + curl -s -X DELETE $BASE_URL/$TRIP_ID \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool + echo "" +fi + +echo "=== Testing Complete ===" diff --git a/planventure-api/examples/trips_examples.json b/planventure-api/examples/trips_examples.json new file mode 100644 index 0000000..08b2f1b --- /dev/null +++ b/planventure-api/examples/trips_examples.json @@ -0,0 +1,35 @@ +{ + "create_trip": { + "destination": "Paris, France", + "start_date": "2025-06-15", + "end_date": "2025-06-22", + "latitude": 48.8566, + "longitude": 2.3522, + "itinerary": "Day 1: Arrive in Paris, check into hotel near the Eiffel Tower\nDay 2: Visit the Louvre Museum and walk along the Seine\nDay 3: Explore Montmartre and SacrΓ©-CΕ“ur\nDay 4: Day trip to Versailles Palace\nDay 5: Visit Notre-Dame and Latin Quarter\nDay 6: Shopping on Champs-Γ‰lysΓ©es\nDay 7: Final day - MusΓ©e d'Orsay and farewell dinner" + }, + "create_trip_minimal": { + "destination": "Tokyo, Japan", + "start_date": "2025-09-01", + "end_date": "2025-09-10" + }, + "create_trip_with_coordinates": { + "destination": "New York City, USA", + "start_date": "2025-07-04", + "end_date": "2025-07-11", + "latitude": 40.7128, + "longitude": -74.006, + "itinerary": "Day 1: Statue of Liberty and Ellis Island\nDay 2: Central Park and Metropolitan Museum\nDay 3: Times Square and Broadway show\nDay 4: Brooklyn Bridge walk\nDay 5: 9/11 Memorial\nDay 6: Fifth Avenue shopping\nDay 7: Departure" + }, + "update_trip": { + "destination": "Paris & Nice, France", + "itinerary": "Updated itinerary with additional days in Nice" + }, + "update_trip_dates": { + "start_date": "2025-06-20", + "end_date": "2025-06-27" + }, + "update_trip_coordinates": { + "latitude": 43.7102, + "longitude": 7.262 + } +} diff --git a/planventure-api/middleware/__init__.py b/planventure-api/middleware/__init__.py new file mode 100644 index 0000000..b45ac0f --- /dev/null +++ b/planventure-api/middleware/__init__.py @@ -0,0 +1,3 @@ +from .auth import require_auth, get_current_user + +__all__ = ['require_auth', 'get_current_user'] diff --git a/planventure-api/middleware/auth.py b/planventure-api/middleware/auth.py new file mode 100644 index 0000000..8f384d1 --- /dev/null +++ b/planventure-api/middleware/auth.py @@ -0,0 +1,58 @@ +from functools import wraps +from flask import jsonify, request +from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity +from flask_jwt_extended.exceptions import NoAuthorizationError, InvalidHeaderError +from models import User +import jwt + +def require_auth(fn): + """ + Decorator to protect routes that require authentication. + Usage: @require_auth + + This will verify the JWT token and ensure the user exists in the database. + """ + @wraps(fn) + def wrapper(*args, **kwargs): + try: + # Verify JWT token is present and valid + verify_jwt_in_request() + + # Get user ID from token + user_id = get_jwt_identity() + + # Verify user still exists in database + user = User.query.get(user_id) + if not user: + return jsonify({'error': 'User not found'}), 404 + + # Call the original function + return fn(*args, **kwargs) + + except NoAuthorizationError: + return jsonify({'error': 'Missing authorization token'}), 401 + except InvalidHeaderError: + return jsonify({'error': 'Invalid authorization header'}), 401 + except jwt.ExpiredSignatureError: + return jsonify({'error': 'Token has expired'}), 401 + except jwt.InvalidTokenError: + return jsonify({'error': 'Invalid token'}), 401 + except Exception as e: + return jsonify({'error': f'Authentication failed: {str(e)}'}), 401 + + return wrapper + +def get_current_user(): + """ + Get the current authenticated user from the JWT token. + Must be called within a request context with a valid JWT token. + + Returns: + User object or None if not authenticated + """ + try: + verify_jwt_in_request() + user_id = get_jwt_identity() + return User.query.get(user_id) + except: + return None 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..99a9178 --- /dev/null +++ b/planventure-api/models/trip.py @@ -0,0 +1,39 @@ +from datetime import datetime +from database import db + +class Trip(db.Model): + __tablename__ = 'trips' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + destination = db.Column(db.String(200), nullable=False) + start_date = db.Column(db.Date, nullable=False) + end_date = db.Column(db.Date, nullable=False) + latitude = db.Column(db.Float, nullable=True) + longitude = db.Column(db.Float, nullable=True) + itinerary = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationship to User + user = db.relationship('User', backref=db.backref('trips', lazy=True, cascade='all, delete-orphan')) + + def __repr__(self): + return f'' + + 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 and self.longitude else None, + 'itinerary': self.itinerary, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } diff --git a/planventure-api/models/user.py b/planventure-api/models/user.py new file mode 100644 index 0000000..f669758 --- /dev/null +++ b/planventure-api/models/user.py @@ -0,0 +1,32 @@ +from datetime import datetime +from database import db +from utils.password import hash_password, verify_password + +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, nullable=False, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f'' + + def set_password(self, password: str): + """Hash and set the user's password""" + self.password_hash = hash_password(password) + + def check_password(self, password: str) -> bool: + """Verify the user's password""" + return verify_password(password, self.password_hash) + + def to_dict(self): + """Convert user object to dictionary (excluding password_hash)""" + return { + 'id': self.id, + 'email': self.email, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } diff --git a/planventure-api/requirements.txt b/planventure-api/requirements.txt new file mode 100644 index 0000000..0ed6c0b --- /dev/null +++ b/planventure-api/requirements.txt @@ -0,0 +1,20 @@ +# Core Flask dependencies +flask==3.0.0 +flask-sqlalchemy==3.1.1 +flask-cors==4.0.0 +flask-jwt-extended==4.6.0 + +# Environment and configuration +python-dotenv==1.0.0 + +# Database +sqlalchemy>=2.0.35 + +# Password hashing +bcrypt==4.1.2 + +# Validation +email-validator==2.1.0 + +# PostgreSQL support (optional, uncomment when needed for production) +# psycopg2-binary==2.9.9 \ No newline at end of file diff --git a/planventure-api/routes/__init__.py b/planventure-api/routes/__init__.py new file mode 100644 index 0000000..d437beb --- /dev/null +++ b/planventure-api/routes/__init__.py @@ -0,0 +1,4 @@ +from .auth import auth_bp +from .trips import trips_bp + +__all__ = ['auth_bp', 'trips_bp'] diff --git a/planventure-api/routes/auth.py b/planventure-api/routes/auth.py new file mode 100644 index 0000000..ced2e18 --- /dev/null +++ b/planventure-api/routes/auth.py @@ -0,0 +1,163 @@ +from flask import Blueprint, request, jsonify +from database import db +from models import User +from utils import hash_password, generate_tokens +from email_validator import validate_email, EmailNotValidError +from middleware import require_auth, get_current_user + +auth_bp = Blueprint('auth', __name__, url_prefix='/auth') + +@auth_bp.route('/register', methods=['POST', 'OPTIONS']) +def register(): + """ + Register a new user. + Expected JSON: { "email": "user@example.com", "password": "secure_password" } + """ + # Handle preflight OPTIONS request + if request.method == 'OPTIONS': + return '', 204 + + try: + data = request.get_json() + + # Validate required fields + if not data: + return jsonify({'error': 'No data provided'}), 400 + + email = data.get('email') + password = data.get('password') + + if not email or not password: + return jsonify({'error': 'Email and password are required'}), 400 + + # Validate email format + try: + validated_email = validate_email(email, check_deliverability=False) + email = validated_email.normalized + except EmailNotValidError as e: + return jsonify({'error': f'Invalid email: {str(e)}'}), 400 + + # Validate password strength + if len(password) < 8: + return jsonify({'error': 'Password must be at least 8 characters long'}), 400 + + # Check if user already exists + existing_user = User.query.filter_by(email=email).first() + if existing_user: + return jsonify({'error': 'User with this email already exists'}), 409 + + # Create new user + new_user = User(email=email) + new_user.set_password(password) + + db.session.add(new_user) + db.session.commit() + + # Generate tokens + tokens = generate_tokens(new_user.id) + + return jsonify({ + 'message': 'User registered successfully', + 'user': new_user.to_dict(), + 'access_token': tokens['access_token'], + 'refresh_token': tokens['refresh_token'] + }), 201 + + except Exception as e: + db.session.rollback() + print(f"Registration error: {str(e)}") + return jsonify({'error': f'Registration failed: {str(e)}'}), 500 + +@auth_bp.route('/login', methods=['POST', 'OPTIONS']) +def login(): + """ + Login user. + Expected JSON: { "email": "user@example.com", "password": "secure_password" } + """ + # Handle preflight OPTIONS request + if request.method == 'OPTIONS': + return '', 204 + + try: + data = request.get_json() + + if not data: + return jsonify({'error': 'No data provided'}), 400 + + email = data.get('email') + password = data.get('password') + + if not email or not password: + return jsonify({'error': 'Email and password are required'}), 400 + + # Find user + user = User.query.filter_by(email=email).first() + + if not user: + return jsonify({'error': 'Invalid email or password'}), 401 + + if not user.check_password(password): + return jsonify({'error': 'Invalid email or password'}), 401 + + # Generate JWT tokens + tokens = generate_tokens(user.id) + + return jsonify({ + 'message': 'Login successful', + 'user': user.to_dict(), + 'access_token': tokens['access_token'], + 'refresh_token': tokens['refresh_token'] + }), 200 + + except Exception as e: + print(f"Login error: {str(e)}") + return jsonify({'error': f'Login failed: {str(e)}'}), 500 + +@auth_bp.route('/refresh', methods=['POST']) +def refresh(): + """ + Refresh access token using refresh token. + Requires refresh token in Authorization header. + """ + from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token + from datetime import timedelta + + try: + # This will verify the refresh token + jwt_required(refresh=True)() + + user_id = get_jwt_identity() + new_access_token = create_access_token( + identity=user_id, + expires_delta=timedelta(hours=1) + ) + + return jsonify({ + 'access_token': new_access_token + }), 200 + + except Exception as e: + return jsonify({'error': f'Token refresh failed: {str(e)}'}), 401 + +@auth_bp.route('/me', methods=['GET']) +@require_auth +def get_profile(): + """ + Get current user's profile. + Requires valid JWT token in Authorization header. + """ + user = get_current_user() + return jsonify({ + 'user': user.to_dict() + }), 200 + +@auth_bp.route('/logout', methods=['POST']) +@require_auth +def logout(): + """ + Logout user (client should discard tokens). + Requires valid JWT token in Authorization header. + """ + return jsonify({ + 'message': 'Logout successful. Please discard your tokens.' + }), 200 diff --git a/planventure-api/routes/trips.py b/planventure-api/routes/trips.py new file mode 100644 index 0000000..278189a --- /dev/null +++ b/planventure-api/routes/trips.py @@ -0,0 +1,197 @@ +from flask import Blueprint, request, jsonify +from database import db +from models import Trip, User +from middleware import require_auth, get_current_user +from datetime import datetime +from utils.itinerary import generate_default_itinerary, generate_activity_suggestions + +trips_bp = Blueprint('trips', __name__, url_prefix='/trips') + +@trips_bp.route('', methods=['GET']) +@require_auth +def get_trips(): + """ + Get all trips for the current user. + """ + try: + user = get_current_user() + trips = Trip.query.filter_by(user_id=user.id).all() + + return jsonify({ + 'trips': [trip.to_dict() for trip in trips], + 'count': len(trips) + }), 200 + + except Exception as e: + return jsonify({'error': f'Failed to fetch trips: {str(e)}'}), 500 + +@trips_bp.route('/', methods=['GET']) +@require_auth +def get_trip(trip_id): + """ + Get a specific trip by ID. + """ + try: + user = get_current_user() + trip = Trip.query.filter_by(id=trip_id, user_id=user.id).first() + + if not trip: + return jsonify({'error': 'Trip not found'}), 404 + + return jsonify({'trip': trip.to_dict()}), 200 + + except Exception as e: + return jsonify({'error': f'Failed to fetch trip: {str(e)}'}), 500 + +@trips_bp.route('', methods=['POST']) +@require_auth +def create_trip(): + """ + Create a new trip. + Expected JSON: { + "destination": "Paris, France", + "start_date": "2025-01-15", + "end_date": "2025-01-22", + "latitude": 48.8566, + "longitude": 2.3522, + "itinerary": "Day 1: Eiffel Tower..." (optional - will auto-generate if not provided) + } + """ + try: + user = get_current_user() + data = request.get_json() + + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # Validate required fields + required_fields = ['destination', 'start_date', 'end_date'] + for field in required_fields: + if field not in data: + return jsonify({'error': f'{field} is required'}), 400 + + # Parse dates + try: + start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() + end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date() + except ValueError: + return jsonify({'error': 'Invalid date format. Use YYYY-MM-DD'}), 400 + + # Validate date logic + if end_date < start_date: + return jsonify({'error': 'End date must be after start date'}), 400 + + # Generate default itinerary if not provided + itinerary = data.get('itinerary') + if not itinerary: + itinerary = generate_default_itinerary( + data['destination'], + start_date, + end_date + ) + + # Create new trip + new_trip = Trip( + user_id=user.id, + destination=data['destination'], + start_date=start_date, + end_date=end_date, + latitude=data.get('latitude'), + longitude=data.get('longitude'), + itinerary=itinerary + ) + + db.session.add(new_trip) + db.session.commit() + + # Get activity suggestions + suggestions = generate_activity_suggestions(data['destination']) + + return jsonify({ + 'message': 'Trip created successfully', + 'trip': new_trip.to_dict(), + 'activity_suggestions': suggestions + }), 201 + + except Exception as e: + db.session.rollback() + return jsonify({'error': f'Failed to create trip: {str(e)}'}), 500 + +@trips_bp.route('/', methods=['PUT']) +@require_auth +def update_trip(trip_id): + """ + Update an existing trip. + """ + try: + user = get_current_user() + trip = Trip.query.filter_by(id=trip_id, user_id=user.id).first() + + if not trip: + return jsonify({'error': 'Trip not found'}), 404 + + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + # Update fields if provided + if 'destination' in data: + trip.destination = data['destination'] + + if 'start_date' in data: + try: + trip.start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() + except ValueError: + return jsonify({'error': 'Invalid start_date format. Use YYYY-MM-DD'}), 400 + + if 'end_date' in data: + try: + trip.end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date() + except ValueError: + return jsonify({'error': 'Invalid end_date format. Use YYYY-MM-DD'}), 400 + + # Validate date logic + if trip.end_date < trip.start_date: + return jsonify({'error': 'End date must be after start date'}), 400 + + if 'latitude' in data: + trip.latitude = data['latitude'] + + if 'longitude' in data: + trip.longitude = data['longitude'] + + if 'itinerary' in data: + trip.itinerary = data['itinerary'] + + db.session.commit() + + return jsonify({ + 'message': 'Trip updated successfully', + 'trip': trip.to_dict() + }), 200 + + except Exception as e: + db.session.rollback() + return jsonify({'error': f'Failed to update trip: {str(e)}'}), 500 + +@trips_bp.route('/', methods=['DELETE']) +@require_auth +def delete_trip(trip_id): + """ + Delete a trip. + """ + try: + user = get_current_user() + trip = Trip.query.filter_by(id=trip_id, user_id=user.id).first() + + if not trip: + return jsonify({'error': 'Trip not found'}), 404 + + db.session.delete(trip) + db.session.commit() + + return jsonify({'message': 'Trip deleted successfully'}), 200 + + except Exception as e: + db.session.rollback() + return jsonify({'error': f'Failed to delete trip: {str(e)}'}), 500 diff --git a/planventure-api/scripts/init_db.py b/planventure-api/scripts/init_db.py new file mode 100644 index 0000000..ff2b5b9 --- /dev/null +++ b/planventure-api/scripts/init_db.py @@ -0,0 +1,48 @@ +import sys +import os + +# Add parent directory to path to import app modules +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from app import app +from database import db +from models import User, Trip + +def init_database(): + """Initialize the database and create all tables""" + try: + with app.app_context(): + print("Creating database tables...") + db.create_all() + print("βœ“ Database tables created successfully!") + + # Print created tables + from sqlalchemy import inspect + inspector = inspect(db.engine) + tables = inspector.get_table_names() + print(f"\nCreated tables: {', '.join(tables)}") + except Exception as e: + print(f"βœ— Error creating tables: {e}") + sys.exit(1) + +def drop_database(): + """Drop all database tables (use with caution!)""" + try: + with app.app_context(): + print("Dropping all database tables...") + db.drop_all() + print("βœ“ All tables dropped!") + except Exception as e: + print(f"βœ— Error dropping tables: {e}") + sys.exit(1) + +if __name__ == '__main__': + if len(sys.argv) > 1 and sys.argv[1] == '--drop': + confirm = input("Are you sure you want to drop all tables? (yes/no): ") + if confirm.lower() == 'yes': + drop_database() + init_database() + else: + print("Operation cancelled.") + else: + init_database() diff --git a/planventure-api/utils/__init__.py b/planventure-api/utils/__init__.py new file mode 100644 index 0000000..1197835 --- /dev/null +++ b/planventure-api/utils/__init__.py @@ -0,0 +1,13 @@ +from .password import hash_password, verify_password +from .auth import generate_tokens, get_current_user_id, token_required +from .itinerary import generate_default_itinerary, generate_activity_suggestions + +__all__ = [ + 'hash_password', + 'verify_password', + 'generate_tokens', + 'get_current_user_id', + 'token_required', + 'generate_default_itinerary', + 'generate_activity_suggestions' +] diff --git a/planventure-api/utils/auth.py b/planventure-api/utils/auth.py new file mode 100644 index 0000000..8119468 --- /dev/null +++ b/planventure-api/utils/auth.py @@ -0,0 +1,63 @@ +from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity +from datetime import timedelta +from functools import wraps +from flask import jsonify +from flask_jwt_extended import verify_jwt_in_request +from flask_jwt_extended.exceptions import NoAuthorizationError, InvalidHeaderError +import jwt + +def generate_tokens(user_id: int) -> dict: + """ + Generate access and refresh tokens for a user. + + Args: + user_id: The user's database ID + + Returns: + Dictionary containing access_token and refresh_token + """ + access_token = create_access_token( + identity=user_id, + expires_delta=timedelta(hours=1) + ) + refresh_token = create_refresh_token( + identity=user_id, + expires_delta=timedelta(days=30) + ) + + return { + 'access_token': access_token, + 'refresh_token': refresh_token + } + +def get_current_user_id() -> int: + """ + Get the current authenticated user's ID from the JWT token. + + Returns: + User ID from the token + """ + return get_jwt_identity() + +def token_required(fn): + """ + Decorator to protect routes that require authentication. + Usage: @token_required + """ + @wraps(fn) + def wrapper(*args, **kwargs): + try: + verify_jwt_in_request() + return fn(*args, **kwargs) + except NoAuthorizationError: + return jsonify({'error': 'Missing authorization token'}), 401 + except InvalidHeaderError: + return jsonify({'error': 'Invalid authorization header'}), 401 + except jwt.ExpiredSignatureError: + return jsonify({'error': 'Token has expired'}), 401 + except jwt.InvalidTokenError: + return jsonify({'error': 'Invalid token'}), 401 + except Exception as e: + return jsonify({'error': 'Authentication failed'}), 401 + + return wrapper diff --git a/planventure-api/utils/itinerary.py b/planventure-api/utils/itinerary.py new file mode 100644 index 0000000..df2b144 --- /dev/null +++ b/planventure-api/utils/itinerary.py @@ -0,0 +1,126 @@ +from datetime import datetime, timedelta + +def generate_default_itinerary(destination: str, start_date, end_date) -> str: + """ + Generate a default itinerary template for a trip. + + Args: + destination: The trip destination + start_date: Trip start date (date object or string) + end_date: Trip end date (date object or string) + + Returns: + String containing a formatted itinerary template + """ + # Convert string dates to date objects if needed + if isinstance(start_date, str): + start_date = datetime.strptime(start_date, '%Y-%m-%d').date() + if isinstance(end_date, str): + end_date = datetime.strptime(end_date, '%Y-%m-%d').date() + + # Calculate number of days + num_days = (end_date - start_date).days + 1 + + # Build itinerary + itinerary_lines = [ + f"Trip to {destination}", + f"Duration: {num_days} day{'s' if num_days > 1 else ''}", + f"Dates: {start_date.strftime('%B %d, %Y')} - {end_date.strftime('%B %d, %Y')}", + "", + "Itinerary:", + "" + ] + + # Generate day-by-day template + current_date = start_date + day_number = 1 + + while current_date <= end_date: + day_name = current_date.strftime('%A') + date_str = current_date.strftime('%B %d') + + if day_number == 1: + itinerary_lines.append(f"Day {day_number} - {day_name}, {date_str}: Arrival") + itinerary_lines.append(" β€’ Check into accommodation") + itinerary_lines.append(" β€’ Explore local area") + itinerary_lines.append(" β€’ Welcome dinner") + elif day_number == num_days: + itinerary_lines.append(f"Day {day_number} - {day_name}, {date_str}: Departure") + itinerary_lines.append(" β€’ Final breakfast") + itinerary_lines.append(" β€’ Check out") + itinerary_lines.append(" β€’ Travel home") + else: + itinerary_lines.append(f"Day {day_number} - {day_name}, {date_str}:") + itinerary_lines.append(" β€’ Morning: [Add activity]") + itinerary_lines.append(" β€’ Afternoon: [Add activity]") + itinerary_lines.append(" β€’ Evening: [Add activity]") + + itinerary_lines.append("") + current_date += timedelta(days=1) + day_number += 1 + + # Add additional sections + itinerary_lines.extend([ + "Notes:", + " β€’ [Add any special notes or reminders]", + "", + "Packing List:", + " β€’ [Add items to pack]", + "", + "Emergency Contacts:", + " β€’ [Add emergency contact information]" + ]) + + return "\n".join(itinerary_lines) + +def generate_activity_suggestions(destination: str) -> list: + """ + Generate activity suggestions based on destination type. + + Args: + destination: The trip destination + + Returns: + List of suggested activities + """ + # Basic activity categories + general_activities = [ + "Visit local landmarks and attractions", + "Try local cuisine and restaurants", + "Explore museums and galleries", + "Take a walking tour", + "Visit markets and shopping areas", + "Experience local nightlife", + "Take photos at scenic viewpoints", + "Relax at parks or beaches" + ] + + # Destination-specific suggestions based on keywords + destination_lower = destination.lower() + + if any(word in destination_lower for word in ['beach', 'island', 'coast', 'tropical']): + return general_activities + [ + "Water sports and activities", + "Beach relaxation", + "Snorkeling or diving", + "Sunset watching", + "Boat tours" + ] + elif any(word in destination_lower for word in ['mountain', 'alps', 'peak']): + return general_activities + [ + "Hiking trails", + "Mountain views", + "Cable car rides", + "Nature photography", + "Outdoor adventures" + ] + elif any(word in destination_lower for word in ['city', 'urban', 'metropolis']): + return general_activities + [ + "City sightseeing", + "Public transportation experience", + "Rooftop bars and restaurants", + "Shopping districts", + "Theater or shows" + ] + else: + return general_activities diff --git a/planventure-api/utils/password.py b/planventure-api/utils/password.py new file mode 100644 index 0000000..fcac2a8 --- /dev/null +++ b/planventure-api/utils/password.py @@ -0,0 +1,32 @@ +import bcrypt + +def hash_password(password: str) -> str: + """ + Hash a password using bcrypt with automatic salt generation. + + Args: + password: Plain text password to hash + + Returns: + Hashed password as a string + """ + # Generate salt and hash password + password_bytes = password.encode('utf-8') + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password_bytes, salt) + return hashed.decode('utf-8') + +def verify_password(password: str, hashed_password: str) -> bool: + """ + Verify a password against its hash. + + Args: + password: Plain text password to verify + hashed_password: Previously hashed password + + Returns: + True if password matches, False otherwise + """ + password_bytes = password.encode('utf-8') + hashed_bytes = hashed_password.encode('utf-8') + return bcrypt.checkpw(password_bytes, hashed_bytes)