diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 5e8d02b..20f9378 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -1,60 +1,100 @@ -name: Backend CI Pipeline +name: CI Backend + Frontend Workflow -# Trigger workflow on push or pull request to develop and main branches on: push: - branches: - - develop - - main + branches: [ "main" ] pull_request: - branches: - - develop - - main + branches: [ "main" ] jobs: - lint: - runs-on: ubuntu-latest + backend-ci: + runs-on: self-hosted + defaults: + run: + working-directory: ./backend + + services: + postgres: + image: postgres:13 + env: + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpassword + POSTGRES_DB: testdb + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - - name: Checkout repository - uses: actions/checkout@v2 + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@v2 + - name: Set up Python 3.11 + uses: actions/setup-python@v5 with: - python-version: '3.11' # Adjust Python version if needed + python-version: '3.11' - - name: Install dependencies + - name: Install backend dependencies run: | - python -m venv venv - source venv/bin/activate + sudo apt-get update + sudo apt-get install -y jq + python -m pip install --upgrade pip pip install -r requirements.txt + pip install pylint pytest requests - - name: Run pylint on Python code + - name: Run pylint on backend run: | - source venv/bin/activate - pylint backend/main.py backend/db.py backend/init_db.py # Add more files as needed + git fetch origin main + changed_files=$(git diff --name-only origin/main...HEAD | grep '\.py$' || true) + if [ -n "$changed_files" ]; then + pylint $changed_files --exit-zero | tee pylint.log + SCORE=$(tail -n 2 pylint.log | grep -oP '[0-9]+\.[0-9]+(?=/10)') + echo "Pylint score: $SCORE" + python -c "import sys; sys.exit(0 if float('$SCORE') >= 8.0 else 1)" + else + echo "No Python files changed, skipping pylint." + exit 0 + fi - test: - runs-on: ubuntu-latest - needs: lint # Ensure this job runs after linting + - name: Start backend server (background) + run: | + python main.py & + for i in {1..10}; do + curl -s http://localhost:5000/api/pets && break || sleep 2 + done + + - name: Initialize database + run: | + python init_db.py + + - name: Run pytest on backend (HTTP API tests) + run: | + pytest tests/ + + frontend-build: + runs-on: self-hosted + defaults: + run: + working-directory: ./frontend steps: - - name: Checkout repository - uses: actions/checkout@v2 + - name: Checkout code + uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v2 + - name: Set up Node.js + uses: actions/setup-node@v4 with: - python-version: '3.11' + node-version: '20' - - name: Install dependencies + - name: Install frontend dependencies run: | - python -m venv venv - source venv/bin/activate - pip install -r requirements.txt + npm install - - name: Run tests with pytest + - name: Build frontend run: | - source venv/bin/activate - pytest backend/tests --maxfail=1 --disable-warnings -q + npm run build diff --git a/README.md b/README.md index e4b39a2..7f576a7 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,4 @@ https://trello.com/b/1z9zUv2a/cse-2102-project Figma Prototype https://www.figma.com/design/ackeSbcEwP2kJQmJknsge5/Milestone3_Figma?node-id=0-1&t=ItY8uyCUknB0nwIz-1 +# trigger cleanup diff --git a/backend/.github/workflows/backend-ci.yml b/backend/.github/workflows/backend-ci.yml index 93a0191..68668fe 100644 --- a/backend/.github/workflows/backend-ci.yml +++ b/backend/.github/workflows/backend-ci.yml @@ -1,39 +1,144 @@ -name: Backend CI +name: Backend CI Pipeline on: push: - paths: - - 'backend/**' + branches: [main, develop] pull_request: - paths: - - 'backend/**' + branches: [main, develop] jobs: - backend-ci: - runs-on: ubuntu-latest + lint: + runs-on: self-hosted + defaults: + run: + working-directory: backend steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Checkout code without submodules + uses: actions/checkout@v2 # Using v2 for actions/checkout + with: + submodules: false # Skips submodule initialization + + - name: Set up Python + uses: actions/setup-python@v2 # Using v2 for setup-python + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m venv venv + source venv/bin/activate + pip install -r requirements.txt # Ensure all dependencies are installed, including requests + + - name: Cache Python dependencies + uses: actions/cache@v2 # Using v2 for actions/cache + with: + path: venv + key: ${{ runner.os }}-python-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-python- + + - name: Run pylint (backend only on changed files) + run: | + source venv/bin/activate + changed_files=$(git diff --name-only origin/main...HEAD | grep '\.py$' || true) + + if [ -n "$changed_files" ]; then + pylint $changed_files --exit-zero | tee pylint.log + SCORE=$(tail -n 2 pylint.log | grep -oP '[0-9]+\.[0-9]+(?=/10)') + echo "Pylint score: $SCORE" + python -c "import sys; sys.exit(0 if float('$SCORE') >= 8.0 else 1)" + else + echo "No Python files changed, skipping pylint." + exit 0 # Exit with status 0 (success) if no Python files changed + fi + + test: + runs-on: self-hosted + needs: lint + + steps: + - name: Checkout code without submodules + uses: actions/checkout@v2 # Using v2 for actions/checkout + with: + submodules: false # Skips submodule initialization - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v2 # Using v2 for setup-python with: python-version: '3.11' - name: Install dependencies - working-directory: ./backend run: | - pip install -r requirements.txt + python -m venv venv + source venv/bin/activate + pip install -r backend/requirements.txt # Ensure all dependencies are installed, including requests + pip install pytest-html - - name: Run pylint on backend - working-directory: ./backend + - name: Cache Python dependencies + uses: actions/cache@v2 # Using v2 for actions/cache + with: + path: venv + key: ${{ runner.os }}-python-${{ hashFiles('backend/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-python- + + - name: Start Flask API server + run: | + nohup python backend/main.py > flask.log 2>&1 & + for i in {1..10}; do + if curl --silent --fail http://127.0.0.1:5000/; then + echo "Flask is ready" + break + fi + echo "Waiting for Flask... Attempt $i/10" + sleep 5 + done + + - name: Run backend tests with pytest + env: + BASE_URL: http://127.0.0.1:5000 run: | - pylint main.py db.py init_db.py + source venv/bin/activate # Activate the virtual environment before running tests + pytest backend/tests --html=backend/test-report.html --self-contained-html - - name: Run Pytest - working-directory: ./backend + - name: Upload test report + uses: actions/upload-artifact@v2 # Using v2 for actions/upload-artifact + with: + name: backend-test-report + path: backend/test-report.html + + - name: Kill Flask server run: | - python main.py & # Run server - sleep 5 # Give it time to start - pytest tests/ + pkill -f "python backend/main.py" || echo "Flask already exited" + + build-frontend: + runs-on: self-hosted + needs: [lint, test] + + steps: + - name: Checkout repository without submodules + uses: actions/checkout@v2 # Using v2 for actions/checkout + with: + submodules: false # Skips submodule initialization + + - name: Set up Node.js + uses: actions/setup-node@v3 # Using the latest stable version for Node.js + with: + node-version: '20' + + - name: Cache node modules + uses: actions/cache@v2 # Using v2 for actions/cache + with: + path: frontend/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('frontend/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install frontend dependencies + working-directory: frontend + run: npm install + + - name: Build frontend app + working-directory: frontend + run: npm run build diff --git a/backend/__pycache__/main.cpython-311.pyc b/backend/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..efda774 Binary files /dev/null and b/backend/__pycache__/main.cpython-311.pyc differ diff --git a/backend/__pycache__/pets.cpython-311.pyc b/backend/__pycache__/pets.cpython-311.pyc new file mode 100644 index 0000000..5923e98 Binary files /dev/null and b/backend/__pycache__/pets.cpython-311.pyc differ diff --git a/backend/__pycache__/test_main.cpython-311-pytest-8.3.4.pyc b/backend/__pycache__/test_main.cpython-311-pytest-8.3.4.pyc new file mode 100644 index 0000000..8f35d95 Binary files /dev/null and b/backend/__pycache__/test_main.cpython-311-pytest-8.3.4.pyc differ diff --git a/backend/__pycache__/utils.cpython-311.pyc b/backend/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000..7216985 Binary files /dev/null and b/backend/__pycache__/utils.cpython-311.pyc differ diff --git a/backend/db.py b/backend/db.py index 93f618f..306010d 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,42 +1,28 @@ +"""Database utility functions for managing the pet adoption database.""" + import sqlite3 import os +from utils import create_tables + def init_db(): """Initialize the database, creating tables and inserting initial data.""" - db_path = os.path.join(os.path.dirname(__file__), 'animal_shelter.db') + db_path = os.path.join(os.path.dirname(__file__), "animal_shelter.db") if os.path.exists(db_path): print("Database already initialized.") return - # Connect to SQLite database (it will create the file if it doesn't exist) conn = sqlite3.connect(db_path) - cursor = conn.cursor() + create_tables(conn) - # Create tables - cursor.execute('''CREATE TABLE IF NOT EXISTS animals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - species TEXT NOT NULL, - breed TEXT, - age INTEGER, - personality TEXT, - image_path TEXT, - adoption_status TEXT DEFAULT 'Available' - )''') - - cursor.execute('''CREATE TABLE IF NOT EXISTS adopters ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - email TEXT UNIQUE, - join_date TEXT DEFAULT CURRENT_TIMESTAMP - )''') - - # Insert initial data (e.g., a test animal) - cursor.execute('''INSERT INTO animals (name, species, breed, age, personality, image_path) - VALUES (?, ?, ?, ?, ?, ?)''', - ('TestDog', 'Dog', 'Mixed', 2, 'Friendly', '/images/test.jpg')) + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO animals (name, species, breed, age, personality, image_path) + VALUES (?, ?, ?, ?, ?, ?)""", + ("TestDog", "Dog", "Mixed", 2, "Friendly", "/images/test.jpg"), + ) conn.commit() conn.close() diff --git a/backend/images/bella.jpg b/backend/images/bella.jpg new file mode 100644 index 0000000..0e2d759 Binary files /dev/null and b/backend/images/bella.jpg differ diff --git a/backend/images/max.jpg b/backend/images/max.jpg new file mode 100644 index 0000000..427e891 Binary files /dev/null and b/backend/images/max.jpg differ diff --git a/backend/images/mittens.jpg b/backend/images/mittens.jpg new file mode 100644 index 0000000..1724025 Binary files /dev/null and b/backend/images/mittens.jpg differ diff --git a/backend/images/oldy.jpg b/backend/images/oldy.jpg new file mode 100644 index 0000000..7ffc935 Binary files /dev/null and b/backend/images/oldy.jpg differ diff --git a/backend/init_db.py b/backend/init_db.py index 8c52eea..f1852ef 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -1,53 +1,66 @@ +"""Script to initialize the database and populate it with initial data.""" + import sqlite3 -import random +import os +from utils import create_tables + +# Constants +DB_PATH = os.path.join(os.path.dirname(__file__), "animal_shelter.db") -def create_database_tables(conn): - cursor = conn.cursor() - cursor.execute(''' - CREATE TABLE IF NOT EXISTS animals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - species TEXT NOT NULL, - breed TEXT, - age INTEGER, - personality TEXT, - image_path TEXT, - adoption_status TEXT DEFAULT 'Available' - ) - ''') - cursor.execute(''' - CREATE TABLE IF NOT EXISTS adopters ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - email TEXT UNIQUE, - join_date TEXT DEFAULT CURRENT_TIMESTAMP - ) - ''') - conn.commit() def populate_initial_data(conn): + """Populate the database with initial data for testing.""" cursor = conn.cursor() - - # Sample animals data + animals = [ - ('Luna', 'Dog', 'Labrador Mix', 2, 'Playful and energetic', '/images/luna.jpg', 'Available'), - ('Oliver', 'Cat', 'Tabby', 4, 'Independent but affectionate', '/images/oliver.jpg', 'Available'), - ('Max', 'Dog', 'German Shepherd', 3, 'Loyal and intelligent', '/images/max.jpg', 'Available') + ( + "Luna", + "Dog", + "Labrador Mix", + 2, + "Playful and energetic", + "/images/luna.jpg", + "Available", + ), + ( + "Oliver", + "Cat", + "Tabby", + 4, + "Independent but affectionate", + "/images/oliver.jpg", + "Available", + ), + ( + "Max", + "Dog", + "German Shepherd", + 3, + "Loyal and intelligent", + "/images/max.jpg", + "Available", + ), ] - + for animal in animals: - cursor.execute(''' - INSERT INTO animals (name, species, breed, age, personality, image_path, adoption_status) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', animal) - + cursor.execute( + """ + INSERT INTO animals (name, species, breed, age, personality, image_path, adoption_status) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + animal, + ) + conn.commit() -if __name__ == "__main__": - print("Initializing pet adoption database...") - conn = sqlite3.connect('animal_shelter.db') - create_database_tables(conn) + +def initialize_database(db_path): + """Initialize the database by creating tables and populating initial data.""" + conn = sqlite3.connect(db_path) + create_tables(conn) populate_initial_data(conn) - print("Database setup complete!") conn.close() + print("Database initialized successfully with initial data.") + + +if __name__ == "__main__": + initialize_database(DB_PATH) diff --git a/backend/main.py b/backend/main.py index f0ab6ce..b88b3f9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,152 +1,123 @@ -from flask import Flask, jsonify, request -from flasgger import Swagger +"""This is the main Flask application for the pet adoption API.""" + import sqlite3 import os -from backend.pets import add_pet, update_pet, delete_pet, search_pets, reset_pets - -# Initialize Flask app -app = Flask(__name__) +from flask import Flask, jsonify, request +from flasgger import Swagger +from flask_cors import CORS +from utils import create_tables -# Initialize Swagger (Flasgger) +app = Flask(__name__, static_folder="images", static_url_path="/images") +CORS(app) Swagger(app) -# SQLite database file -DATABASE = 'animal_shelter.db' +DATABASE = "animal_shelter.db" + -# Helper function to get a database connection def get_db(): + """Establish a connection to the SQLite database.""" conn = sqlite3.connect(DATABASE) - conn.row_factory = sqlite3.Row # Allows row access by column name + conn.row_factory = sqlite3.Row return conn -# Database initialization function to create tables and initial data + def init_db(): - """Create tables and add initial data if they don't exist.""" + """Initialize the database and insert sample data if not already present.""" if not os.path.exists(DATABASE): conn = get_db() - cursor = conn.cursor() + create_tables(conn) - # Create the animals table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS animals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - species TEXT NOT NULL, - breed TEXT, - age INTEGER, - personality TEXT, - image_path TEXT, - adoption_status TEXT DEFAULT 'Available' - ) - ''') - - # Insert initial pet data for testing pets = [ - ('Buddy', 'Dog', 'Golden Retriever', 3, 'Friendly', '/images/buddy.jpg', 'Available'), - ('Whiskers', 'Cat', 'Siamese', 2, 'Independent', '/images/whiskers.jpg', 'Available'), - ('Rex', 'Dog', 'German Shepherd', 4, 'Protective', '/images/rex.jpg', 'Available'), - ('Max', 'Dog', 'Labrador Retriever', 5, 'Playful', '/images/max.jpg', 'Adopted'), - ('Mittens', 'Cat', 'Persian', 1, 'Lazy', '/images/mittens.jpg', 'Available'), - ('Bella', 'Dog', 'Bulldog', 2, 'Gentle', '/images/bella.jpg', 'Available') + ("Buddy", + "Dog", + "Golden Retriever", + 3, + "Friendly", + "/images/buddy.jpg", + "Available", + ), + ("Whiskers", + "Cat", + "Siamese", + 2, + "Independent", + "/images/whiskers.jpg", + "Available", + ), + ("Rex", + "Dog", + "German Shepherd", + 4, + "Protective", + "/images/rex.jpg", + "Available", + ), + ("Max", + "Dog", + "Labrador Retriever", + 5, + "Playful", + "/images/max.jpg", + "Adopted", + ), + ("Mittens", + "Cat", + "Persian", + 1, + "Lazy", + "/images/mittens.jpg", + "Available", + ), + ("Bella", + "Dog", + "Bulldog", + 2, + "Gentle", + "/images/bella.jpg", + "Available"), ] - # Insert data into the animals table - cursor.executemany(''' + cursor = conn.cursor() + cursor.executemany( + """ INSERT INTO animals (name, species, breed, age, personality, image_path, adoption_status) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', pets) - + VALUES (?, ?, ?, ?, ?, ?, ?)""", + pets, + ) + conn.commit() conn.close() -# API Routes -@app.route('/api/pets', methods=['GET']) + +@app.route("/api/pets", methods=["GET"]) def get_pets(): - """Retrieve all pets - --- - responses: - 200: - description: A list of pets - schema: - type: array - items: - type: object - properties: - id: - type: integer - name: - type: string - species: - type: string - breed: - type: string - age: - type: integer - personality: - type: string - image_path: - type: string - adoption_status: - type: string - """ + """Retrieve and return all pets from the database.""" try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM animals") pets = cursor.fetchall() - - # Convert rows to a list of dictionaries pet_list = [dict(pet) for pet in pets] conn.close() - return jsonify(pet_list), 200 - except Exception as e: + except sqlite3.Error as e: print(f"An error occurred: {e}") - return jsonify({'error': 'Internal Server Error'}), 500 + return jsonify({"error": "Internal Server Error"}), 500 -@app.route('/', methods=['GET']) -def home(): - return jsonify({'message': 'Welcome to the Pet API! Visit /apidocs for the Swagger documentation.'}) +@app.route("/", methods=["GET"]) +def home(): + """Return a welcome message for the API.""" + return jsonify( + { + "message": "Welcome to the Pet API! Visit /apidocs for the Swagger documentation." + } + ) -@app.route('/api/pets/', methods=['GET']) +@app.route("/api/pets/", methods=["GET"]) def get_pet(pet_id): - """Retrieve a single pet by ID - --- - parameters: - - name: pet_id - in: path - type: integer - required: true - description: The unique ID of the pet - responses: - 200: - description: A pet - schema: - type: object - properties: - id: - type: integer - name: - type: string - species: - type: string - breed: - type: string - age: - type: integer - personality: - type: string - image_path: - type: string - adoption_status: - type: string - 404: - description: Pet not found - 500: - description: Internal Server Error - """ + """Retrieve a pet by its ID.""" try: conn = get_db() cursor = conn.cursor() @@ -154,81 +125,72 @@ def get_pet(pet_id): pet = cursor.fetchone() if pet is None: - return jsonify({'error': 'Pet not found'}), 404 + return jsonify({"error": "Pet not found"}), 404 - # Convert the SQLite row object to a dictionary pet_dict = {key: pet[key] for key in pet.keys()} conn.close() - return jsonify(pet_dict), 200 - except Exception as e: + except sqlite3.Error as e: print(f"An error occurred: {e}") - return jsonify({'error': 'Internal Server Error'}), 500 + return jsonify({"error": "Internal Server Error"}), 500 -@app.route('/api/pets', methods=['POST']) + +@app.route("/api/pets", methods=["POST"]) def add_pet(): - """Add a new pet - --- - parameters: - - name: pet - in: body - required: true - schema: - type: object - properties: - name: - type: string - species: - type: string - breed: - type: string - age: - type: integer - personality: - type: string - image_path: - type: string - adoption_status: - type: string - responses: - 201: - description: Pet created successfully - 400: - description: Invalid pet data - 500: - description: Internal Server Error - """ + """Add a new pet to the database.""" try: pet_data = request.get_json() + required_fields = ["name", "species", "breed", "age", "personality"] - # Validate required fields - required_fields = ['name', 'species', 'breed', 'age', 'personality'] for field in required_fields: if field not in pet_data: - return jsonify({'error': f'Missing required field: {field}'}), 400 + return jsonify( + {"error": f"Missing required field: {field}"}), 400 + + if not (0 < pet_data["age"] < 100): # adding validation + return jsonify( + {"error": "Invalid age. Must be between 1 and 99"}), 400 conn = get_db() cursor = conn.cursor() - - cursor.execute(''' + + # Check for duplicate pet name + cursor.execute("SELECT * FROM animals WHERE name = ?", + (pet_data["name"],)) + existing = cursor.fetchone() + if existing: + return jsonify({"error": "Pet already exists"}), 409 + + cursor.execute( + """ INSERT INTO animals (name, species, breed, age, personality, image_path, adoption_status) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', (pet_data['name'], pet_data['species'], pet_data['breed'], pet_data['age'], - pet_data['personality'], pet_data.get('image_path', '/images/default.jpg'), 'Available')) - + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ( + pet_data["name"], + pet_data["species"], + pet_data["breed"], + pet_data["age"], + pet_data["personality"], + pet_data.get("image_path", "/images/default.jpg"), + "Available", + ), + ) + conn.commit() conn.close() + return jsonify({"message": "Pet created successfully"}), 201 - return jsonify({'message': 'Pet created successfully'}), 201 - except Exception as e: + except sqlite3.Error as e: print(f"An error occurred: {e}") - return jsonify({'error': 'Internal Server Error'}), 500 + return jsonify({"error": "Internal Server Error"}), 500 + -# Initialize database on first run -@app.before_first_request def before_first_request(): + """Run once before the first request to initialize the database.""" init_db() -if __name__ == "__main__": - app.run(debug=True, use_reloader=False) +app.before_request_funcs.setdefault(None, []).append(before_first_request) + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/backend/pets.py b/backend/pets.py index f2224ff..47c9dae 100644 --- a/backend/pets.py +++ b/backend/pets.py @@ -8,7 +8,7 @@ "breed": "Golden Retriever", "age": 3, "temperament": "Friendly", - "pictureUrl": "/images/buddy.jpg" + "pictureUrl": "/images/buddy.jpg", }, { "id": 2, @@ -16,7 +16,7 @@ "breed": "Siamese Cat", "age": 2, "temperament": "Independent", - "pictureUrl": "/images/whiskers.jpg" + "pictureUrl": "/images/whiskers.jpg", }, { "id": 3, @@ -24,14 +24,17 @@ "breed": "German Shepherd", "age": 4, "temperament": "Protective", - "pictureUrl": "/images/rex.jpg" - } + "pictureUrl": "/images/rex.jpg", + }, ] + class PetNotFoundError(Exception): """Exception raised when a pet is not found.""" + pass + def get_pets(): """Get a list of available pets. --- @@ -66,7 +69,8 @@ def get_pets(): return jsonify(PETS.copy()), 200 except Exception as e: print(f"An error occurred: {e}") - return jsonify({'error': 'Internal Server Error'}), 500 + return jsonify({"error": "Internal Server Error"}), 500 + def get_pet(pet_id): """Get a pet by its ID. @@ -90,14 +94,15 @@ def get_pet(pet_id): try: # Find the pet with the given ID pet = next((p for p in PETS if p["id"] == pet_id), None) - + if pet is None: - return jsonify({'error': 'Pet not found'}), 404 - + return jsonify({"error": "Pet not found"}), 404 + return jsonify(pet), 200 except Exception as e: print(f"An error occurred: {e}") - return jsonify({'error': 'Internal Server Error'}), 500 + return jsonify({"error": "Internal Server Error"}), 500 + def add_pet(pet_data): """Add a new pet to the system. @@ -118,14 +123,15 @@ def add_pet(pet_data): """ try: # Validate required fields - required_fields = ['name', 'breed', 'age', 'temperament'] + required_fields = ["name", "breed", "age", "temperament"] for field in required_fields: if field not in pet_data: - return jsonify({'error': f'Missing required field: {field}'}), 400 - + return jsonify( + {"error": f"Missing required field: {field}"}), 400 + # Generate a new ID (in a real app, the database would handle this) new_id = max(pet["id"] for pet in PETS) + 1 if PETS else 1 - + # Create new pet with the generated ID new_pet = { "id": new_id, @@ -133,16 +139,17 @@ def add_pet(pet_data): "breed": pet_data["breed"], "age": pet_data["age"], "temperament": pet_data["temperament"], - "pictureUrl": pet_data.get("pictureUrl", "/images/default.jpg") + "pictureUrl": pet_data.get("pictureUrl", "/images/default.jpg"), } - + # Add to our list PETS.append(new_pet) - + return jsonify(new_pet), 201 except Exception as e: print(f"An error occurred: {e}") - return jsonify({'error': 'Internal Server Error'}), 500 + return jsonify({"error": "Internal Server Error"}), 500 + def update_pet(pet_id, pet_data): """Update an existing pet. @@ -168,20 +175,22 @@ def update_pet(pet_id, pet_data): """ try: # Find the pet with the given ID - pet_index = next((i for i, p in enumerate(PETS) if p["id"] == pet_id), None) - + pet_index = next( + (i for i, p in enumerate(PETS) if p["id"] == pet_id), None) + if pet_index is None: - return jsonify({'error': 'Pet not found'}), 404 - + return jsonify({"error": "Pet not found"}), 404 + # Update pet fields that are provided for key, value in pet_data.items(): - if key != 'id': # Don't allow changing the ID + if key != "id": # Don't allow changing the ID PETS[pet_index][key] = value - + return jsonify(PETS[pet_index]), 200 except Exception as e: print(f"An error occurred: {e}") - return jsonify({'error': 'Internal Server Error'}), 500 + return jsonify({"error": "Internal Server Error"}), 500 + def delete_pet(pet_id): """Delete a pet. @@ -202,18 +211,20 @@ def delete_pet(pet_id): """ try: # Find the pet with the given ID - pet_index = next((i for i, p in enumerate(PETS) if p["id"] == pet_id), None) - + pet_index = next( + (i for i, p in enumerate(PETS) if p["id"] == pet_id), None) + if pet_index is None: - return jsonify({'error': 'Pet not found'}), 404 - + return jsonify({"error": "Pet not found"}), 404 + # Remove the pet deleted_pet = PETS.pop(pet_index) - - return '', 204 # No content response for successful deletion + + return "", 204 # No content response for successful deletion except Exception as e: print(f"An error occurred: {e}") - return jsonify({'error': 'Internal Server Error'}), 500 + return jsonify({"error": "Internal Server Error"}), 500 + def search_pets(query_params): """Search for pets based on criteria. @@ -239,32 +250,35 @@ def search_pets(query_params): """ try: filtered_pets = PETS.copy() - + # Filter by breed if specified - if 'breed' in query_params: - breed = query_params.get('breed') - filtered_pets = [p for p in filtered_pets if breed.lower() in p['breed'].lower()] - + if "breed" in query_params: + breed = query_params.get("breed") + filtered_pets = [ + p for p in filtered_pets if breed.lower() in p["breed"].lower() + ] + # Filter by minimum age if specified - if 'min_age' in query_params: - min_age = int(query_params.get('min_age')) - filtered_pets = [p for p in filtered_pets if p['age'] >= min_age] - + if "min_age" in query_params: + min_age = int(query_params.get("min_age")) + filtered_pets = [p for p in filtered_pets if p["age"] >= min_age] + # Filter by maximum age if specified - if 'max_age' in query_params: - max_age = int(query_params.get('max_age')) - filtered_pets = [p for p in filtered_pets if p['age'] <= max_age] - + if "max_age" in query_params: + max_age = int(query_params.get("max_age")) + filtered_pets = [p for p in filtered_pets if p["age"] <= max_age] + return jsonify(filtered_pets), 200 except Exception as e: print(f"An error occurred: {e}") - return jsonify({'error': 'Internal Server Error'}), 500 + return jsonify({"error": "Internal Server Error"}), 500 + # Helper function to reset data (useful for testing) def reset_pets(): """Reset the pets data to initial state.""" global PETS - + PETS = [ { "id": 1, @@ -272,7 +286,7 @@ def reset_pets(): "breed": "Golden Retriever", "age": 3, "temperament": "Friendly", - "pictureUrl": "/images/buddy.jpg" + "pictureUrl": "/images/buddy.jpg", }, { "id": 2, @@ -280,7 +294,7 @@ def reset_pets(): "breed": "Siamese Cat", "age": 2, "temperament": "Independent", - "pictureUrl": "/images/whiskers.jpg" + "pictureUrl": "/images/whiskers.jpg", }, { "id": 3, @@ -288,6 +302,6 @@ def reset_pets(): "breed": "German Shepherd", "age": 4, "temperament": "Protective", - "pictureUrl": "/images/rex.jpg" - } - ] \ No newline at end of file + "pictureUrl": "/images/rex.jpg", + }, + ] diff --git a/backend/requirements.txt b/backend/requirements.txt index a9f380d..97e9765 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,5 @@ -Flask==3.1.1 -Flask-Cors==5.0.2 +Flask==3.1.0 +Flask-Cors==5.0.1 flasgger==0.9.7.1 Werkzeug==3.1.3 Jinja2==3.1.5 @@ -9,4 +9,5 @@ click==8.1.8 blinker==1.9.0 PyYAML==6.0.2 mistune==3.1.2 -jsonschema==4.24.0 \ No newline at end of file +jsonschema==4.23.0 +requests>=2.0.0 diff --git a/backend/test-main.py b/backend/test-main.py index 3784523..ea7d4e3 100644 --- a/backend/test-main.py +++ b/backend/test-main.py @@ -2,9 +2,10 @@ import json from main import app, ANIMALS, USERS + @pytest.fixture def client(): - app.config['TESTING'] = True + app.config["TESTING"] = True with app.test_client() as client: # Reset data before each test global ANIMALS, USERS @@ -17,7 +18,7 @@ def client(): "age": 2, "personality": "Playful and energetic", "image_path": "/images/luna.jpg", - "adoption_status": "Available" + "adoption_status": "Available", }, { "id": 2, @@ -27,7 +28,7 @@ def client(): "age": 4, "personality": "Independent but affectionate", "image_path": "/images/oliver.jpg", - "adoption_status": "Available" + "adoption_status": "Available", }, { "id": 3, @@ -37,179 +38,191 @@ def client(): "age": 3, "personality": "Loyal and intelligent", "image_path": "/images/max.jpg", - "adoption_status": "Available" - } + "adoption_status": "Available", + }, ] USERS = [ { - "id": 1, + "id": 1, "username": "admin", "password": "admin123", - "email": "admin@example.com" + "email": "admin@example.com", } ] yield client + def test_homepage(client): """Test that the homepage returns a 200 status code.""" - response = client.get('/') + response = client.get("/") assert response.status_code == 200 - assert b'Animal Shelter API' in response.data + assert b"Animal Shelter API" in response.data + def test_register_user(client): """Test user registration with valid data.""" - response = client.post('/api/register', - json={ - 'username': 'testuser', - 'password': 'password123', - 'email': 'test@example.com' - }) + response = client.post( + "/api/register", + json={ + "username": "testuser", + "password": "password123", + "email": "test@example.com", + }, + ) assert response.status_code == 201 data = json.loads(response.data) - assert 'Registration successful' in data['message'] - assert data['user_id'] == 2 # Should be the second user + assert "Registration successful" in data["message"] + assert data["user_id"] == 2 # Should be the second user + def test_register_duplicate_username(client): """Test registration with a duplicate username.""" # First registration - client.post('/api/register', - json={ - 'username': 'testuser', - 'password': 'password123', - 'email': 'test@example.com' - }) - + client.post( + "/api/register", + json={ + "username": "testuser", + "password": "password123", + "email": "test@example.com", + }, + ) + # Attempt to register with same username - response = client.post('/api/register', - json={ - 'username': 'testuser', - 'password': 'different', - 'email': 'different@example.com' - }) + response = client.post( + "/api/register", + json={ + "username": "testuser", + "password": "different", + "email": "different@example.com", + }, + ) assert response.status_code == 409 data = json.loads(response.data) - assert 'Username already exists' in data['error'] + assert "Username already exists" in data["error"] + def test_register_missing_fields(client): """Test registration with missing fields.""" - response = client.post('/api/register', - json={ - 'username': 'testuser', - # Missing password and email - }) + response = client.post( + "/api/register", + json={ + "username": "testuser", + # Missing password and email + }, + ) assert response.status_code == 400 data = json.loads(response.data) - assert 'All fields are required' in data['error'] + assert "All fields are required" in data["error"] + def test_login_success(client): """Test successful login.""" - response = client.post('/api/login', - json={ - 'username': 'admin', - 'password': 'admin123' - }) + response = client.post( + "/api/login", json={"username": "admin", "password": "admin123"} + ) assert response.status_code == 200 data = json.loads(response.data) - assert 'Login successful' in data['message'] - assert data['username'] == 'admin' + assert "Login successful" in data["message"] + assert data["username"] == "admin" + def test_login_incorrect_password(client): """Test login with incorrect password.""" - response = client.post('/api/login', - json={ - 'username': 'admin', - 'password': 'wrong_password' - }) + response = client.post( + "/api/login", json={"username": "admin", "password": "wrong_password"} + ) assert response.status_code == 401 data = json.loads(response.data) - assert 'Invalid username or password' in data['error'] + assert "Invalid username or password" in data["error"] + def test_login_nonexistent_user(client): """Test login with a username that doesn't exist.""" - response = client.post('/api/login', - json={ - 'username': 'nonexistent', - 'password': 'anything' - }) + response = client.post( + "/api/login", json={"username": "nonexistent", "password": "anything"} + ) assert response.status_code == 401 data = json.loads(response.data) - assert 'Invalid username or password' in data['error'] + assert "Invalid username or password" in data["error"] + def test_get_animals(client): """Test getting all available animals.""" - response = client.get('/api/animals') + response = client.get("/api/animals") assert response.status_code == 200 data = json.loads(response.data) assert len(data) == 3 - assert data[0]['name'] == 'Luna' - assert data[1]['name'] == 'Oliver' - assert data[2]['name'] == 'Max' + assert data[0]["name"] == "Luna" + assert data[1]["name"] == "Oliver" + assert data[2]["name"] == "Max" + def test_get_animal_details(client): """Test getting details for a specific animal.""" - response = client.get('/api/animals/2') + response = client.get("/api/animals/2") assert response.status_code == 200 data = json.loads(response.data) - assert data['name'] == 'Oliver' - assert data['species'] == 'Cat' - assert data['breed'] == 'Tabby' + assert data["name"] == "Oliver" + assert data["species"] == "Cat" + assert data["breed"] == "Tabby" + def test_get_nonexistent_animal(client): """Test getting details for an animal that doesn't exist.""" - response = client.get('/api/animals/999') + response = client.get("/api/animals/999") assert response.status_code == 404 data = json.loads(response.data) - assert 'Animal not found' in data['error'] + assert "Animal not found" in data["error"] + def test_adopt_animal(client): """Test adopting an animal.""" - response = client.post('/api/animals/1/adopt', - json={ - 'user_id': 1 - }) + response = client.post("/api/animals/1/adopt", json={"user_id": 1}) assert response.status_code == 200 data = json.loads(response.data) - assert 'Adoption successful' in data['message'] - assert data['animal']['adoption_status'] == 'Adopted' - + assert "Adoption successful" in data["message"] + assert data["animal"]["adoption_status"] == "Adopted" + # Check that the animal is no longer available - response = client.get('/api/animals') + response = client.get("/api/animals") assert response.status_code == 200 data = json.loads(response.data) assert len(data) == 2 # Only 2 animals should now be available - + + def test_adopt_already_adopted_animal(client): """Test trying to adopt an animal that's already adopted.""" # First adoption - client.post('/api/animals/1/adopt', json={'user_id': 1}) - + client.post("/api/animals/1/adopt", json={"user_id": 1}) + # Attempt second adoption - response = client.post('/api/animals/1/adopt', json={'user_id': 1}) + response = client.post("/api/animals/1/adopt", json={"user_id": 1}) assert response.status_code == 400 data = json.loads(response.data) - assert 'Animal not available for adoption' in data['error'] + assert "Animal not available for adoption" in data["error"] + def test_animal_search(client): """Test searching for animals with filters.""" # Search by species - response = client.get('/api/animals/search?species=Dog') + response = client.get("/api/animals/search?species=Dog") assert response.status_code == 200 data = json.loads(response.data) assert len(data) == 2 - assert all(animal['species'] == 'Dog' for animal in data) - + assert all(animal["species"] == "Dog" for animal in data) + # Search by age range - response = client.get('/api/animals/search?min_age=3&max_age=4') + response = client.get("/api/animals/search?min_age=3&max_age=4") assert response.status_code == 200 data = json.loads(response.data) assert len(data) == 2 - assert all(3 <= animal['age'] <= 4 for animal in data) - + assert all(3 <= animal["age"] <= 4 for animal in data) + # Combined search - response = client.get('/api/animals/search?species=Cat&min_age=3') + response = client.get("/api/animals/search?species=Cat&min_age=3") assert response.status_code == 200 data = json.loads(response.data) assert len(data) == 1 - assert data[0]['name'] == 'Oliver' - assert data[0]['species'] == 'Cat' - assert data[0]['age'] == 4 + assert data[0]["name"] == "Oliver" + assert data[0]["species"] == "Cat" + assert data[0]["age"] == 4 diff --git a/backend/test_main.py b/backend/test_main.py index dd730da..3d19653 100644 --- a/backend/test_main.py +++ b/backend/test_main.py @@ -4,12 +4,13 @@ import os from main import app + @pytest.fixture def client(): # Configure app for testing - app.config['TESTING'] = True - app.config['WTF_CSRF_ENABLED'] = False - + app.config["TESTING"] = True + app.config["WTF_CSRF_ENABLED"] = False + # Use an in-memory database for testing with app.test_client() as test_client: # Set up test database @@ -18,13 +19,15 @@ def client(): # Clean up after test teardown_test_database() + def setup_test_database(): # Create an in-memory test database - conn = sqlite3.connect('test_animal_shelter.db') + conn = sqlite3.connect("test_animal_shelter.db") cursor = conn.cursor() - + # Create tables - cursor.execute(''' + cursor.execute( + """ CREATE TABLE IF NOT EXISTS animals ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -35,9 +38,11 @@ def setup_test_database(): image_path TEXT, adoption_status TEXT DEFAULT 'Available' ) - ''') - - cursor.execute(''' + """ + ) + + cursor.execute( + """ CREATE TABLE IF NOT EXISTS adopters ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, @@ -45,80 +50,95 @@ def setup_test_database(): email TEXT UNIQUE, join_date TEXT DEFAULT CURRENT_TIMESTAMP ) - ''') - + """ + ) + # Add test data cursor.execute( "INSERT INTO animals (name, species, breed, age, personality, image_path, adoption_status) VALUES (?, ?, ?, ?, ?, ?, ?)", - ('TestDog', 'Dog', 'Mixed', 2, 'Friendly', '/images/test.jpg', 'Available') + ("TestDog", + "Dog", + "Mixed", + 2, + "Friendly", + "/images/test.jpg", + "Available"), ) - + conn.commit() conn.close() - + # Temporarily change the database path in the app - app.config['DATABASE_PATH'] = 'test_animal_shelter.db' + app.config["DATABASE_PATH"] = "test_animal_shelter.db" + def teardown_test_database(): # Clean up test database - if os.path.exists('test_animal_shelter.db'): - os.remove('test_animal_shelter.db') + if os.path.exists("test_animal_shelter.db"): + os.remove("test_animal_shelter.db") + def test_register_user(client): # Test user registration - response = client.post('/api/register', - json={ - 'username': 'testuser', - 'password': 'Password123', - 'email': 'test@example.com' - }) + response = client.post( + "/api/register", + json={ + "username": "testuser", + "password": "Password123", + "email": "test@example.com", + }, + ) assert response.status_code == 201 - assert 'Registration successful' in response.get_json()['message'] + assert "Registration successful" in response.get_json()["message"] + def test_login_user(client): # First register a test user - client.post('/api/register', - json={ - 'username': 'testuser', - 'password': 'Password123', - 'email': 'test@example.com' - }) - + client.post( + "/api/register", + json={ + "username": "testuser", + "password": "Password123", + "email": "test@example.com", + }, + ) + # Test login with correct credentials - response = client.post('/api/login', - json={ - 'username': 'testuser', - 'password': 'Password123' - }) + response = client.post( + "/api/login", json={"username": "testuser", "password": "Password123"} + ) assert response.status_code == 200 - assert 'Login successful' in response.get_json()['message'] - + assert "Login successful" in response.get_json()["message"] + # Test login with incorrect password - response = client.post('/api/login', - json={ - 'username': 'testuser', - 'password': 'WrongPassword' - }) + response = client.post( + "/api/login", + json={ + "username": "testuser", + "password": "WrongPassword"}) assert response.status_code == 401 + def test_get_animals(client): # Test getting list of animals - response = client.get('/api/animals') + response = client.get("/api/animals") assert response.status_code == 200 animals = response.get_json() assert len(animals) > 0 - assert 'TestDog' in [animal['name'] for animal in animals] + assert "TestDog" in [animal["name"] for animal in animals] + def test_get_animal_details(client): # Test getting details of a specific animal - response = client.get('/api/animals/1') + response = client.get("/api/animals/1") assert response.status_code == 200 animal = response.get_json() - assert animal['name'] == 'TestDog' - assert animal['species'] == 'Dog' - assert animal['adoption_status'] == 'Available' + assert animal["name"] == "TestDog" + assert animal["species"] == "Dog" + assert animal["adoption_status"] == "Available" + def test_nonexistent_animal(client): # Test trying to get a non-existent animal - response = client.get('/api/animals/999') - assert response.status_code == 404 \ No newline at end of file + response = client.get("/api/animals/999") + assert response.status_code == 404 diff --git a/backend/tests/__pycache__/conftest.py b/backend/tests/__pycache__/conftest.py new file mode 100644 index 0000000..51b9778 --- /dev/null +++ b/backend/tests/__pycache__/conftest.py @@ -0,0 +1,13 @@ +import os +import pytest +from backend.main import init_db + + +@pytest.fixture(scope="session", autouse=True) +def clean_test_db(): + db_file = "animal_shelter.db" + + # Remove and reinitialize + if os.path.exists(db_file): + os.remove(db_file) + init_db() diff --git a/backend/tests/__pycache__/test_api.cpython-311-pytest-8.3.4.pyc b/backend/tests/__pycache__/test_api.cpython-311-pytest-8.3.4.pyc index db01062..57e1ca4 100644 Binary files a/backend/tests/__pycache__/test_api.cpython-311-pytest-8.3.4.pyc and b/backend/tests/__pycache__/test_api.cpython-311-pytest-8.3.4.pyc differ diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 851f2d5..e86176d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,33 +1,100 @@ -import requests +"""This module contains tests for the pet adoption API.""" + import sqlite3 import os +import requests BASE_URL = "http://127.0.0.1:5000" -DB_PATH = os.path.join(os.path.dirname(__file__), '../animal_shelter.db') +DB_PATH = os.path.join(os.path.dirname(__file__), "../animal_shelter.db") + def test_get_all_pets(): - response = requests.get(f"{BASE_URL}/api/pets") + """Test case to verify retrieving all pets.""" + response = requests.get(f"{BASE_URL}/api/pets", timeout=10) # Adding timeout assert response.status_code == 200 assert isinstance(response.json(), list) + def test_add_pet_boundary_age(): + """Test case to verify adding a pet with a boundary age.""" new_pet = { "name": "Oldy", "species": "Turtle", "breed": "Galapagos", "age": 150, "personality": "Wise elder", - "image_path": "/images/oldy.jpg" + "image_path": "/images/oldy.jpg", } - response = requests.post(f"{BASE_URL}/api/pets", json=new_pet) - assert response.status_code == 201 + response = requests.post(f"{BASE_URL}/api/pets", json=new_pet, timeout=10) # Adding timeout + + # Reject the pet + assert response.status_code == 400 + assert "Invalid age" in response.text - # Check if it's actually in the DB + # Pet will not be in the database conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row cursor = conn.cursor() - cursor.execute("SELECT * FROM animals WHERE name = ?", (new_pet['name'],)) - result = cursor.fetchone() + cursor.execute("SELECT * FROM animals WHERE name = ?", (new_pet["name"],)) + row = cursor.fetchone() conn.close() - - assert result is not None - assert result[4] == new_pet["age"] # age + + assert row is None + + +def test_add_pet_invalid_age(): + """Test case to verify adding a pet with an invalid high age.""" + pet = { + "name": "Overaged", + "species": "Turtle", + "breed": "Unknown", + "age": 1000, # Invalid high age + "personality": "Ancient wisdom", + "image_path": "/images/old.jpg", + } + response = requests.post(f"{BASE_URL}/api/pets", json=pet, timeout=10) # Adding timeout + # Expect rejection or handle accordingly + assert response.status_code in [400, 422] + + +def test_add_pet_missing_name(): + """Test case to verify adding a pet with a missing name field.""" + pet = { + # "name" is missing + "species": "Dog", + "breed": "Bulldog", + "age": 2, + "personality": "Playful", + "image_path": "/images/no_name.jpg", + } + response = requests.post(f"{BASE_URL}/api/pets", json=pet, timeout=10) # Adding timeout + assert response.status_code == 400 + assert "Missing required field: name" in response.text + + +def test_duplicate_pet_submission(): + """Test case to verify duplicate pet submission is blocked.""" + # First, removing any existing "Shadow" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute("DELETE FROM animals WHERE name = ?", ("Shadow",)) + conn.commit() + conn.close() + + pet = { + "name": "Shadow", + "species": "Cat", + "breed": "Black", + "age": 3, + "personality": "Sneaky", + "image_path": "/images/shadow.jpg", + } + + # First submit (should succeed) + response1 = requests.post(f"{BASE_URL}/api/pets", json=pet, timeout=10) # Adding timeout + assert response1.status_code == 201 + + # Second submit (should be blocked) + response2 = requests.post(f"{BASE_URL}/api/pets", json=pet, timeout=10) # Adding timeout + assert response2.status_code == 409 + assert "already exists" in response2.text \ No newline at end of file diff --git a/backend/utils.py b/backend/utils.py new file mode 100644 index 0000000..42204ef --- /dev/null +++ b/backend/utils.py @@ -0,0 +1,36 @@ +# utils.py +import sqlite3 + + +def create_tables(conn): + """Create tables for the database if they do not exist.""" + cursor = conn.cursor() + + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS animals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + species TEXT NOT NULL, + breed TEXT, + age INTEGER, + personality TEXT, + image_path TEXT, + adoption_status TEXT DEFAULT 'Available' + ) + """ + ) + + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS adopters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + email TEXT UNIQUE, + join_date TEXT DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + conn.commit() diff --git a/cse2102-spring25-Team27 b/cse2102-spring25-Team27 deleted file mode 160000 index 8294fba..0000000 --- a/cse2102-spring25-Team27 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8294fbabab995e3adb615aa724ff600da349a840 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..7f67638 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,77 @@ +# syntax=docker/dockerfile:1 + +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7 + +ARG NODE_VERSION=23.11.0 + +################################################################################ +# Use node image for base image for all stages. +FROM node:${NODE_VERSION}-alpine as base + +# Set working directory for all build stages. +WORKDIR /usr/src/app + + +################################################################################ +# Create a stage for installing production dependecies. +FROM base as deps + +# Download dependencies as a separate step to take advantage of Docker's caching. +# Leverage a cache mount to /root/.npm to speed up subsequent builds. +# Leverage bind mounts to package.json and package-lock.json to avoid having to copy them +# into this layer. +RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=package-lock.json,target=package-lock.json \ + --mount=type=cache,target=/root/.npm \ + npm ci + +################################################################################ +# Create a stage for building the application. +FROM deps as build + +# Download additional development dependencies before building, as some projects require +# "devDependencies" to be installed to build. If you don't need this, remove this step. +RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=package-lock.json,target=package-lock.json \ + --mount=type=cache,target=/root/.npm \ + npm ci + +# Copy the rest of the source files into the image. +COPY . . +# Run the build script. +RUN npm run build + +################################################################################ +# Create a new stage to run the application with minimal runtime dependencies +# where the necessary files are copied from the build stage. +FROM base as final + +# Use production node environment by default. +ENV NODE_ENV production + +# Copy files as before +COPY package.json . +COPY --from=deps /usr/src/app/node_modules ./node_modules +COPY --from=build /usr/src/app/dist ./dist +COPY --from=build /usr/src/app/public ./public +COPY --from=build /usr/src/app/src ./src +COPY --from=build /usr/src/app/index.html ./index.html +COPY --from=build /usr/src/app/vite.config.ts ./vite.config.ts +COPY --from=build /usr/src/app/tsconfig.json ./tsconfig.json +COPY --from=build /usr/src/app/tsconfig.node.json ./tsconfig.node.json +COPY --from=build /usr/src/app/tsconfig.app.json ./tsconfig.app.json + +# Fix permissions so node user can write +RUN chown -R node:node /usr/src/app + +USER node + +# Expose the port that the application listens on. +EXPOSE 5173 + +# Run the application. +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/README.Docker.md b/frontend/README.Docker.md new file mode 100644 index 0000000..8300f21 --- /dev/null +++ b/frontend/README.Docker.md @@ -0,0 +1,22 @@ +### Building and running your application + +When you're ready, start your application by running: +`docker compose up --build`. + +Your application will be available at http://localhost:5173. + +### Deploying your application to the cloud + +First, build your image, e.g.: `docker build -t myapp .`. +If your cloud uses a different CPU architecture than your development +machine (e.g., you are on a Mac M1 and your cloud provider is amd64), +you'll want to build the image for that platform, e.g.: +`docker build --platform=linux/amd64 -t myapp .`. + +Then, push it to your registry, e.g. `docker push myregistry.com/myapp`. + +Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/) +docs for more detail on building and pushing. + +### References +* [Docker's Node.js guide](https://docs.docker.com/language/nodejs/) \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index da98444..5ceb8b7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,3 +1,114 @@ +# Pet Adoption Portal - CSE2102 Group Project (Spring 2025) + +Group Members - Sean Dombrofski, Muskan Ghetiya, Ribka Sheikh, Prince Rusweka Rwabongoya + +--- + +## tech stack + +- **Backend**: Python, Flask, SQLite +- **Frontend**: React, TypeScript, Vite +- **Testing**: Pytest +- **CI/CD**: GitHub Actions + +--- + +## running project locally + +### backend (flask api) + +```bash +cd backend +python -m venv venv + +# Activate virtual environment +# Windows: +venv\Scripts\activate +# Mac/Linux: +source venv/bin/activate + +pip install -r requirements.txt +python main.py +``` + +> server runs on: `http://127.0.0.1:5000` + +--- + +### frontend (react + vite) + +```bash +cd frontend +npm install +npm run dev +``` + +> frontend runs on: `http://localhost:5173` + +--- + +## steel thread + +**steel thread**: our frontend connects to the backend to fetch adoptable pets: + +- Frontend makes a call to `GET http://127.0.0.1:5000/api/pets` +- Backend accesses SQLite DB and returns real-time pet data +- Data is dynamically rendered on the **Pet Profile** page + +--- + +## testing & CI + +GitHub Actions automatically runs: + +- Pylint on all backend files - the score must be greater or equal than 8.0 +- Pytest for backend API routes (`backend/tests/`) +- Builds frontend React app +- Uploads test reports as artifacts + +CI triggers on: + +- Every push to `main` or `develop` +- Every pull request targeting `main` or `develop` + +--- + +## local testing commands + +### Lint Backend: +```bash +cd backend +pylint ./*.py +``` + +### Run Backend Tests: +```bash +pytest backend/tests +``` + +> has database validation changes, API calls, and edge cases. + +--- + +## TA Instructions + +To test and run locally: + +1. to start the backend: + ```bash + cd backend + python main.py + ``` +2. to start the frontend: + ```bash + cd frontend + npm run dev + ``` +3. visting the app: [http://localhost:5173](http://localhost:5173) + + + + # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. diff --git a/frontend/compose.yaml b/frontend/compose.yaml new file mode 100644 index 0000000..7a24993 --- /dev/null +++ b/frontend/compose.yaml @@ -0,0 +1,62 @@ +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Docker Compose reference guide at +# https://docs.docker.com/go/compose-spec-reference/ + +# Here the instructions define your application as a service called "server". +# This service is built from the Dockerfile in the current directory. +# You can add other services your application may depend on here, such as a +# database or a cache. For examples, see the Awesome Compose repository: +# https://github.com/docker/awesome-compose +name: frontend + +services: + server: + build: + context: . + ports: + - 5173:5173 + volumes: + - ./src:/usr/src/app/src + develop: + watch: + - path: ./package.json + action: rebuild + - path: ./vite.config.ts + action: rebuild + - path: ./src + target: /usr/src/app/src + action: sync + +# The commented out section below is an example of how to define a PostgreSQL +# database that your application can use. `depends_on` tells Docker Compose to +# start the database before your application. The `db-data` volume persists the +# database data between container restarts. The `db-password` secret is used +# to set the database password. You must create `db/password.txt` and add +# a password of your choosing to it before running `docker-compose up`. +# depends_on: +# db: +# condition: service_healthy +# db: +# image: postgres +# restart: always +# user: postgres +# secrets: +# - db-password +# volumes: +# - db-data:/var/lib/postgresql/data +# environment: +# - POSTGRES_DB=example +# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password +# expose: +# - 5432 +# healthcheck: +# test: [ "CMD", "pg_isready" ] +# interval: 10s +# timeout: 5s +# retries: 5 +# volumes: +# db-data: +# secrets: +# db-password: +# file: db/password.txt + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..48b90c1 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://backend:5000/api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e3838d..621a252 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,12 +9,15 @@ "version": "0.0.0", "dependencies": { "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.5.2" }, "devDependencies": { "@eslint/js": "^9.22.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.22.0", "eslint-plugin-react-hooks": "^5.2.0", @@ -1386,6 +1389,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1413,6 +1423,29 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.31.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz", @@ -1861,6 +1894,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2822,6 +2864,15 @@ "react": "^19.1.0" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2832,6 +2883,45 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.2.tgz", + "integrity": "sha512-9Rw8r199klMnlGZ8VAsV/I8WrIF6IyJ90JQUdboupx1cdkgYqwnrYjH+I/nY/7cA1X5zia4mDJqH36npP7sxGQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0", + "turbo-stream": "2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.5.2.tgz", + "integrity": "sha512-yk1XW8Fj7gK7flpYBXF3yzd2NbX6P7Kxjvs2b5nu1M04rb5pg/Zc4fGdBNTeT4eDYL2bvzWNyKaIMJX/RKHTTg==", + "license": "MIT", + "dependencies": { + "react-router": "7.5.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2933,6 +3023,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3063,6 +3159,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/turbo-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", + "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", + "license": "ISC" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index d0c5a91..7a23b7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,12 +11,15 @@ }, "dependencies": { "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.5.2" }, "devDependencies": { "@eslint/js": "^9.22.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", + "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.22.0", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e0742c7..b743de7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,19 +1,24 @@ import { Routes, Route } from 'react-router-dom'; -import Home from "./pages/Home.tsx"; -import About from './pages/About'; import NavBar from './components/NavBar'; +import Home from './pages/Home'; +import Search from './pages/Search'; +import PetProfile from './pages/PetProfile'; +import Profile from './pages/Profile'; function App() { return ( <> - - } /> - } /> - +
+ + } /> + } /> + } /> + } /> + +
); } -export default App; - +export default App; \ No newline at end of file diff --git a/frontend/src/assets/bird.png b/frontend/src/assets/bird.png new file mode 100644 index 0000000..33d7db2 Binary files /dev/null and b/frontend/src/assets/bird.png differ diff --git a/frontend/src/assets/cat.png b/frontend/src/assets/cat.png new file mode 100644 index 0000000..a4e61ae Binary files /dev/null and b/frontend/src/assets/cat.png differ diff --git a/frontend/src/assets/dog2.png b/frontend/src/assets/dog2.png new file mode 100644 index 0000000..a405b79 Binary files /dev/null and b/frontend/src/assets/dog2.png differ diff --git a/frontend/src/assets/dogshake.png b/frontend/src/assets/dogshake.png new file mode 100644 index 0000000..6f0f261 Binary files /dev/null and b/frontend/src/assets/dogshake.png differ diff --git a/frontend/src/assets/jerry.png b/frontend/src/assets/jerry.png new file mode 100644 index 0000000..a573308 Binary files /dev/null and b/frontend/src/assets/jerry.png differ diff --git a/frontend/src/assets/max-cat.png b/frontend/src/assets/max-cat.png new file mode 100644 index 0000000..a4e61ae Binary files /dev/null and b/frontend/src/assets/max-cat.png differ diff --git a/frontend/src/assets/puppy.png b/frontend/src/assets/puppy.png new file mode 100644 index 0000000..0be0e4c Binary files /dev/null and b/frontend/src/assets/puppy.png differ diff --git a/frontend/src/assets/turtle.png b/frontend/src/assets/turtle.png new file mode 100644 index 0000000..a845e19 Binary files /dev/null and b/frontend/src/assets/turtle.png differ diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index eca984b..454b976 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -1,10 +1,19 @@ -import { Link } from 'react-router-dom'; +import { Link } from "react-router-dom"; +import { FaHome, FaSearch, FaUser } from "react-icons/fa"; export default function NavBar() { return ( -