diff --git a/app/.env b/.env similarity index 100% rename from app/.env rename to .env diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml new file mode 100644 index 0000000..58cb317 --- /dev/null +++ b/.github/workflows/docker-ci.yml @@ -0,0 +1,27 @@ +name: Build and Push Docker Image + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/restful-api:${{ github.sha }} diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..3798d5c --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,24 @@ +name: Python CI + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Check out repository code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' # Specify the Python version you are using + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: pytest tests/unit/test_student.py diff --git a/Dockerfile b/Dockerfile index 8b950f7..33d001a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.11 # Set the working directory -WORKDIR /app +WORKDIR / # Copy the requirements and the application code COPY requirements.txt requirements.txt @@ -15,4 +15,4 @@ COPY . . EXPOSE 8888 # Set the entrypoint to run the application -CMD ["python", "app/app.py"] \ No newline at end of file +CMD ["python", "app.py"] \ No newline at end of file diff --git a/_script/add_init_student.py b/_script/add_init_student.py new file mode 100644 index 0000000..2d169ce --- /dev/null +++ b/_script/add_init_student.py @@ -0,0 +1,67 @@ +import os +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import ( + Column, + Integer, + String, +) + +class Base(DeclarativeBase): + def to_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} + +class Student(Base): + __tablename__ = 'student' + + id = Column(Integer, primary_key=True, unique=True, nullable=False) + name = Column(String(100), nullable=False) + age = Column(Integer, nullable=False) + major = Column(String(100), nullable=False) + +# 載入環境變數 +load_dotenv() +DATABASE_HOST=os.getenv('DATABASE_HOST', 'postgres') +DATABASE_PORT=os.getenv('DATABASE_PORT', '5432') +DATABASE_PASS=os.getenv('DATABASE_PASS', 'postgres') +DATABASE_USER=os.getenv('DATABASE_USER', 'postgres') +DATABASE_NAME=os.getenv('DATABASE_NAME', 'postgres') + +# 建立連線 URL +DATABASE_URL = f'postgresql://{DATABASE_USER}:{DATABASE_PASS}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}' + +# 創建資料庫引擎 +engine = create_engine(DATABASE_URL) + +# 創建 session +Session = sessionmaker(bind=engine) +session = Session() + +# 插入四個學生資料 +def insert_students(): + try: + # 創建四個學生實例 + student1 = Student(name="Alice", age=20, major="Computer Science") + student2 = Student(name="Bob", age=22, major="Mathematics") + student3 = Student(name="Charlie", age=21, major="Physics") + student4 = Student(name="David", age=23, major="Engineering") + + # 將學生物件加入 session + session.add_all([student1, student2, student3, student4]) + + # 提交事務到資料庫 + session.commit() + + print("Students inserted successfully!") + + except Exception as e: + session.rollback() + print(f"An error occurred: {e}") + finally: + session.close() + +if __name__ == "__main__": + Base.metadata.create_all(engine) + insert_students() \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..ca27649 --- /dev/null +++ b/app.py @@ -0,0 +1,102 @@ +from flask import Flask, jsonify, request +from sqlalchemy.orm import scoped_session + +from orm.models import Student +from orm.database import Session, init_db + +app = Flask(__name__) + +@app.route('/api/student', methods=['GET']) +def get_student(): + session = scoped_session(Session) + try: + student_id = request.args.get("id", type=int) + + if student_id: + student = session.get(Student, student_id) + if not student: + return jsonify({"error": f"Student with ID {student_id} was not found"}), 404 + return jsonify(student.to_dict()), 200 + else: + students = session.query(Student).all() + if not students: + return jsonify({"error": "No students found"}), 404 + return jsonify([student.to_dict() for student in students]), 200 + except Exception as e: + session.rollback() + return jsonify({"error": str(e)}), 500 + finally: + session.remove() + + +# Endpoint to add a student +@app.route('/api/student', methods=['POST']) +def add_student(): + session = scoped_session(Session) + try: + student_data = request.get_json() + name = student_data.get('name') + age = student_data.get('age') + major = student_data.get('major') + + if not all([name, age, major]): + return jsonify({"error": "Missing required fields"}), 400 + + new_student = Student(name=name, age=age, major=major) + session.add(new_student) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + try: + session.commit() + except Exception as e: + session.rollback() + return jsonify({"error": str(e)}), 500 + finally: + session.remove() + + return jsonify({"message": "Student added successfully"}), 201 + + +@app.route('/api/student/', methods=['DELETE']) +def delete_student(id): + session = scoped_session(Session) + try: + student = session.get(Student, id) + if student: + session.delete(student) + session.commit() + return jsonify({"message": "Student deleted successfully"}), 200 + else: + return jsonify({"error": "Student not found"}), 404 + except Exception as e: + session.rollback() + return jsonify({"error": str(e)}), 500 + finally: + session.remove() + +@app.route('/api/student/', methods=['PUT']) +def update_student(id): + session = scoped_session(Session) + try: + student = session.get(Student, id) + if not student: + return jsonify({"error": "Student not found"}), 404 + + student_data = request.get_json() + student.name = student_data.get('name', student.name) + student.age = student_data.get('age', student.age) + student.major = student_data.get('major', student.major) + + session.commit() + return jsonify({"message": "Student updated successfully"}), 200 + except Exception as e: + session.rollback() + return jsonify({"error": str(e)}), 500 + finally: + session.remove() + + +if __name__ == '__main__': + init_db() + app.run(host='0.0.0.0', port=8787) \ No newline at end of file diff --git a/app/app.py b/app/app.py deleted file mode 100644 index e1e1f7d..0000000 --- a/app/app.py +++ /dev/null @@ -1,82 +0,0 @@ -from flask import Flask, jsonify, request -from flask_sqlalchemy import SQLAlchemy -import os -from dotenv import load_dotenv - -app = Flask(__name__) - -# Database configuration from environment variable -load_dotenv() -DATABASE_HOST=os.getenv('DATABASE_HOST') -DATABASE_PORT=os.getenv('DATABASE_PORT') -DATABASE_URL = f'{DATABASE_HOST}:{DATABASE_PORT}' -app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://postgres:postgres@localhost:5432/postgres' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -# Initialize the SQLAlchemy ORM -db = SQLAlchemy(app) - -# Define the Student model -class Student(db.Model): - __tablename__ = 'students' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), nullable=False) - age = db.Column(db.Integer, nullable=False) - major = db.Column(db.String(100), nullable=False) - - def to_dict(self): - return { - 'id': self.id, - 'name': self.name, - 'age': self.age, - 'major': self.major - } - -# Endpoint to get all students -@app.route('/api/student', methods=['GET']) -def get_student(): - try: - students = Student.query.all() - if students: - student_list = [student.to_dict() for student in students] - return jsonify(student_list), 200 - else: - return "No students found", 404 - except Exception as e: - return str(e), 500 - -# Endpoint to add a student -@app.route('/api/student', methods=['POST']) -def add_student(): - try: - student_data = request.get_json() - name = student_data.get('name') - age = student_data.get('age') - major = student_data.get('major') - if not all([name, age, major]): - return "Missing required fields", 400 - - new_student = Student(name=name, age=age, major=major) - db.session.add(new_student) - db.session.commit() - return "Student added successfully", 201 - - except Exception as e: - return str(e), 500 - -if __name__ == '__main__': - with app.app_context(): - db.create_all() - - # Add initial data if the table is empty - if Student.query.count() == 0: - initial_students = [ - Student(name="Alice", age=20, major="Physics"), - Student(name="Bob", age=21, major="Mathematics"), - Student(name="Charlie", age=22, major="Computer Science"), - Student(name="Diana", age=23, major="Biology"), - ] - db.session.bulk_save_objects(initial_students) - db.session.commit() - - app.run(host='0.0.0.0', port=8888) diff --git a/docker-compose.yml b/docker-compose.yml index 5defc31..a88d6a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,28 +6,42 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: postgres + ports: - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data + # volumes: + # - postgres_data:/var/lib/postgresql/data restart: always healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s retries: 5 + networks: + - app-network app: build: context: . container_name: flask_app environment: - DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/mydatabase" + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + DATABASE_PASS: postgres + DATABASE_USER: postgres + DATABASE_NAME: postgres ports: - "8888:8888" depends_on: postgres: condition: service_healthy restart: always + networks: + - app-network + +networks: + app-network: + driver: bridge + -volumes: - postgres_data: +# volumes: +# postgres_data: \ No newline at end of file diff --git a/orm/__init__.py b/orm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orm/database.py b/orm/database.py new file mode 100644 index 0000000..021611e --- /dev/null +++ b/orm/database.py @@ -0,0 +1,21 @@ +import os + +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from orm.models import Base + +load_dotenv() +DATABASE_HOST=os.getenv('DATABASE_HOST', 'postgres') +DATABASE_PORT=os.getenv('DATABASE_PORT', '5432') +DATABASE_PASS=os.getenv('DATABASE_PASS', 'postgres') +DATABASE_USER=os.getenv('DATABASE_USER', 'postgres') +DATABASE_NAME=os.getenv('DATABASE_NAME', 'postgres') + +DATABASE_URL = f'postgresql://{DATABASE_USER}:{DATABASE_PASS}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}' +engine = create_engine(DATABASE_URL) # Connect to database +Session = sessionmaker(bind=engine) + +def init_db(): + Base.metadata.create_all(engine) \ No newline at end of file diff --git a/orm/models.py b/orm/models.py new file mode 100644 index 0000000..6ced222 --- /dev/null +++ b/orm/models.py @@ -0,0 +1,18 @@ +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import ( + Column, + Integer, + String, +) + +class Base(DeclarativeBase): + def to_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} + +class Student(Base): + __tablename__ = 'student' + + id = Column(Integer, primary_key=True, unique=True, nullable=False) + name = Column(String(100), nullable=False) + age = Column(Integer, nullable=False) + major = Column(String(100), nullable=False) \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..03f586d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 233048e..e605573 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/src/models/routes/student_routes.py b/src/models/routes/student_routes.py new file mode 100644 index 0000000..c527303 --- /dev/null +++ b/src/models/routes/student_routes.py @@ -0,0 +1,50 @@ +from flask import Blueprint, request, jsonify +from src.models.student import db, Student + +student_bp = Blueprint('student', __name__, url_prefix='/api/student') + +@student_bp.route('', methods=['GET']) +def get_students(): + try: + students = Student.query.all() + if not students: + return "No students found", 404 + return jsonify([s.to_dict() for s in students]), 200 + except Exception as e: + return str(e), 500 + +@student_bp.route('', methods=['POST']) +def add_student(): + try: + data = request.get_json() + name, age, major = data.get('name'), data.get('age'), data.get('major') + if not all([name, age, major]): + return "Missing required fields", 400 + + student = Student(name=name, age=age, major=major) + db.session.add(student) + db.session.commit() + return "Student added successfully", 201 + except Exception as e: + return str(e), 500 + +@student_bp.route('/', methods=['DELETE']) +def delete_student(id): + student = Student.query.get(id) + if not student: + return "Student not found", 404 + db.session.delete(student) + db.session.commit() + return "Student deleted successfully", 200 + +@student_bp.route('/', methods=['PUT']) +def update_student(id): + student = Student.query.get(id) + if not student: + return "Student not found", 404 + data = request.get_json() + student.name = data.get('name', student.name) + student.age = data.get('age', student.age) + student.major = data.get('major', student.major) + db.session.commit() + return "Student updated successfully", 200 diff --git a/src/models/student.py b/src/models/student.py new file mode 100644 index 0000000..a8f4d7e --- /dev/null +++ b/src/models/student.py @@ -0,0 +1,18 @@ +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import ( + Column, + Integer, + String, +) + +class Base(DeclarativeBase): + def to_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} + +class Student(Base): + __tablename__ = 'student' + + id = Column(Integer, primary_key=True, unique=True, nullable=False) + name = Column(String(100), nullable=False) + age = Column(Integer, nullable=False) + major = Column(String(100), nullable=False) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index d229102..0095c47 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -1,29 +1,60 @@ import requests import pytest +from dotenv import load_dotenv +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import DeclarativeBase +from orm.models import Student + + +BASE_URL = "http://127.0.0.1:8787/api/student" + +load_dotenv() +DATABASE_HOST=os.getenv('DATABASE_HOST', 'postgres') +DATABASE_PORT=os.getenv('DATABASE_PORT', '5432') +DATABASE_PASS=os.getenv('DATABASE_PASS', 'postgres') +DATABASE_USER=os.getenv('DATABASE_USER', 'postgres') +DATABASE_NAME=os.getenv('DATABASE_NAME', 'postgres') + +# 建立連線 URL +DATABASE_URL = f'postgresql://{DATABASE_USER}:{DATABASE_PASS}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}' + +# 創建資料庫引擎 +engine = create_engine(DATABASE_URL) + +# 創建 session +Session = sessionmaker(bind=engine) +session = Session() -BASE_URL = "http://127.0.0.1:8888/api/student" @pytest.fixture(scope="function", autouse=True) def reset_db(): """Reset the database before each test by deleting all students.""" - response = requests.get(BASE_URL) - if response.status_code == 200: - students = response.json() - for student in students: - requests.delete(f"{BASE_URL}/{student['id']}") + try: + # 直接刪除所有學生資料 + deleted = session.query(Student).delete() + session.commit() + print(f"{deleted} student(s) deleted successfully!") + + except Exception as e: + session.rollback() + print(f"An error occurred while deleting students: {e}") + finally: + session.close() def test_get_empty_students(): """Test GET when the database is empty.""" response = requests.get(BASE_URL) assert response.status_code == 404 - assert response.text == "No students found" + assert response.json() == {'error': 'No students found'} def test_add_student(): """Test adding a student using POST.""" data = {"name": "John Doe", "age": 25, "major": "Physics"} response = requests.post(BASE_URL, json=data) assert response.status_code == 201 - assert response.text == "Student added successfully" + assert response.json() == {"message": "Student added successfully"} def test_get_students(): """Test GET after adding students.""" @@ -42,7 +73,7 @@ def test_delete_student(): delete_response = requests.delete(f"{BASE_URL}/{student_id}") assert delete_response.status_code == 200 - assert delete_response.text == "Student deleted successfully" + assert delete_response.json() == {"message": "Student deleted successfully"} # Verify deletion get_response = requests.get(BASE_URL) @@ -57,8 +88,7 @@ def test_update_student(): updated_data = {"name": "Jane Doe", "age": 30, "major": "Math"} put_response = requests.put(f"{BASE_URL}/{student_id}", json=updated_data) assert put_response.status_code == 200 - assert put_response.text == "Student updated successfully" - + assert put_response.json() == {"message": "Student updated successfully"} # Verify update get_response = requests.get(BASE_URL) student = get_response.json()[0] diff --git a/tests/unit/test_student.py b/tests/unit/test_student.py new file mode 100644 index 0000000..76559dd --- /dev/null +++ b/tests/unit/test_student.py @@ -0,0 +1,13 @@ +import pytest +from src.models.student import Student + + +def test_student_to_dict(): + """Test converting a Student instance to a dictionary.""" + student = Student(name="Bob", age=23, major="Computer Science") + student_dict = student.to_dict() + + assert student_dict.get("name") == "Bob" + assert student_dict.get("age") == 23 + assert student_dict.get("major") == "Computer Science" + assert "id" in student_dict # ID should be present, even if None before commit \ No newline at end of file