diff --git a/README.md b/README.md index e2a98cb..97a4ee5 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,173 @@ -Simple Flask Todo App using SQLAlchemy and SQLite database. +# Flask Todo App with REST API -For styling [semantic-ui](https://semantic-ui.com/) is used. +This project enhances a simple Flask-based Todo web application by adding a full-featured REST API. The API enables external systems to create, read, update, and delete todo items programmatically, following RESTful principles. -### Setup -Create project with virtual environment +## ๐Ÿ“Œ Original Application -```console -$ mkdir myproject -$ cd myproject -$ python3 -m venv venv +The original application is a basic Todo list manager built with Flask and SQLite. It supports: + +- Viewing all tasks +- Adding new tasks +- Updating tasks (marking them as complete/incomplete) +- Deleting tasks + +All actions were handled through HTML forms in the web interface. + +## โœจ Enhancements Made + +This version introduces a REST API layer that exposes the core functionality through JSON-based HTTP endpoints. Key improvements include: + +- Added full CRUD API endpoints under `/api/todos` +- Returns proper HTTP status codes (200, 201, 400, 404, etc.) +- Includes input validation and error handling +- SQLite database for local storage +- Easy to test with Postman or similar tools +- Developed a dedicated test suite using `pytest` with 100% test coverage + +## ๐Ÿ“ Project Structure + +``` +๐Ÿ“ฆ flask-todo +โ”ฃ ๐Ÿ“œ app.py +โ”ฃ ๐Ÿ“œ app_withRESTAPI.py +โ”ฃ ๐Ÿ“œ templates/ +โ”ƒ โ”— ๐Ÿ“œ index.html +โ”ฃ ๐Ÿ“œ test_app.py +โ”— ๐Ÿ“œ README.md ``` -Activate it -```console -$ . venv/bin/activate +> All enhancements are done inside the `feature/rest-api` branch. + +--- + +## ๐Ÿ“‹ API Documentation + +### `GET /api/todos` + +Fetches all todo items. + +- **Response**: `200 OK` +```json +[ + { + "id": 1, + "title": "Buy milk", + "description": "2 bottles", + "done": false + } +] ``` -or on Windows -```console -venv\Scripts\activate +### `POST /api/todos` + +Creates a new todo item. + +- **Request JSON**: +```json +{ + "title": "Read book", + "description": "Chapter 4", + "done": false +} ``` +- **Response**: `201 Created` with new task JSON +- **Error**: `400 Bad Request` if title is missing -Install Flask -```console -$ pip install Flask -$ pip install Flask-SQLAlchemy +### `PUT /api/todos/` + +Updates a specific todo item. + +- **Request JSON**: +```json +{ + "title": "Finish report", + "done": true +} ``` +- **Response**: `200 OK` with updated task +- **Error**: `404 Not Found` if the task does not exist + +### `DELETE /api/todos/` + +Deletes a specific todo item. + +- **Response**: `200 OK` with confirmation message +- **Error**: `404 Not Found` if the task does not exist + +--- + +## ๐Ÿš€ How to Run the Project + +### 1. Clone the repository: -Set environment variables in terminal -```console -$ export FLASK_APP=app.py -$ export FLASK_ENV=development +```bash +git clone https://github.com/alliah2025/flask-todo.git +cd flask-todo ``` -or on Windows -```console -$ set FLASK_APP=app.py -$ set FLASK_ENV=development +### 2. Create a virtual environment and activate it: + +```bash +python -m venv venv +venv\Scripts\activate # On Windows +# or +source venv/bin/activate # On Unix/macOS +``` + +### 3. Install dependencies: + +```bash +pip install Flask Flask-SQLAlchemy ``` -Run the app -```console -$ flask run +### 4. Initialize the database: + +```python +# Run this in a Python shell or a separate script +from app_withRESTAPI import db, app +with app.app_context(): + db.create_all() + print("Database initialized.") +``` + +### 5. Start the Flask app: + +```bash +python app_withRESTAPI.py +``` + +- Web interface: [http://localhost:5000](http://localhost:5000) +- API endpoint: [http://localhost:5000/api/todos](http://localhost:5000/api/todos) + +You can test the API using Postman: + +- `GET /api/todos` โ€” List all todos +- `POST /api/todos` โ€” Create a new todo +- `GET /api/todos/` โ€” Get a specific todo +- `PUT /api/todos/` โ€” Update a specific todo +- `DELETE /api/todos/` โ€” Delete a specific todo + +--- + +## ๐Ÿงช Testing + +All API endpoints have been covered by unit tests using `pytest`. + +- Positive and negative test cases for all CRUD operations +- Uses an in-memory SQLite database for clean and repeatable test runs + +To run tests: + +```bash +pytest test_app.py --cov=. ``` + +--- + +## ๐ŸŽฅ Video Presentation +https://drive.google.com/drive/folders/10kQL25MgmE4RkZQBb37zw3szmufZ7SvA?usp=sharing + +## ๐Ÿ“Œ Repository Links + +- ๐Ÿ”— Original Repository: https://github.com/patrickloeber/flask-todo.git +- ๐Ÿ”— Forked with Enhancements: https://github.com/alliah2025/flask-todo/tree/feature/rest-api \ No newline at end of file diff --git a/app_withRESTAPI.py b/app_withRESTAPI.py new file mode 100644 index 0000000..8391347 --- /dev/null +++ b/app_withRESTAPI.py @@ -0,0 +1,79 @@ +from flask import Flask, request, jsonify +from flask_sqlalchemy import SQLAlchemy + +app = Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///todo.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + +class Task(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(100), nullable=False) + description = db.Column(db.String(200)) + done = db.Column(db.Boolean, default=False) + + def to_dict(self): + return { + 'id': self.id, + 'title': self.title, + 'description': self.description, + 'done': self.done + } + +@app.route('/api/todos', methods=['GET']) +def get_todos(): + todos = Task.query.all() + return jsonify([todo.to_dict() for todo in todos]), 200 + +@app.route('/api/todos/', methods=['GET']) +def get_task(id): + task = db.session.get(Task, id) # updated here + if not task: + return jsonify({'error': 'Task not found'}), 404 + return jsonify(task.to_dict()), 200 + +@app.route('/api/todos', methods=['POST']) +def create_todo(): + data = request.get_json() + if not data or 'title' not in data: + return jsonify({'error': 'Title is required'}), 400 + todo = Task( + title=data['title'], + description=data.get('description', ''), + done=data.get('done', False) + ) + db.session.add(todo) + db.session.commit() + return jsonify(todo.to_dict()), 201 + +@app.route('/api/todos/', methods=['PUT']) +def update_todo(id): + todo = db.session.get(Task, id) # updated here + if not todo: + return jsonify({'error': 'Task not found'}), 404 + data = request.get_json() + if 'title' in data: + todo.title = data['title'] + if 'description' in data: + todo.description = data['description'] + if 'done' in data: + todo.done = data['done'] + db.session.commit() + return jsonify(todo.to_dict()), 200 + +@app.route('/api/todos/', methods=['DELETE']) +def delete_todo(id): + todo = db.session.get(Task, id) # updated here + if not todo: + return jsonify({'error': 'Task not found'}), 404 + db.session.delete(todo) + db.session.commit() + return jsonify({'message': 'Todo deleted'}), 200 + +@app.route('/') +def home(): + return "Welcome to the Flask TODO API!" + +if __name__ == '__main__': + app.run(debug=True) + diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..806ae3f --- /dev/null +++ b/init_db.py @@ -0,0 +1,7 @@ +##needed to run the flask app +from app import app, db + +with app.app_context(): + db.create_all() + print("Database initialized.") + diff --git a/init_dbRESTAPI.py b/init_dbRESTAPI.py new file mode 100644 index 0000000..9a02115 --- /dev/null +++ b/init_dbRESTAPI.py @@ -0,0 +1,5 @@ +##needed to run the flask app +from app_withRESTAPI import db, app +with app.app_context(): + db.create_all() + print("Database initialized.") \ No newline at end of file diff --git a/test_app.py b/test_app.py new file mode 100644 index 0000000..9ae2640 --- /dev/null +++ b/test_app.py @@ -0,0 +1,95 @@ +import pytest +import json +from app_withRESTAPI import app, db, Task + +@pytest.fixture +def client(): + app.config['TESTING'] = True + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + with app.test_client() as client: + with app.app_context(): + db.create_all() + yield client + with app.app_context(): + db.drop_all() + +def test_create_todo_success(client): + res = client.post('/api/todos', json={'title': 'Test task'}) + assert res.status_code == 201 + data = res.get_json() + assert data['title'] == 'Test task' + assert data['done'] is False + +def test_create_todo_fail_no_title(client): + res = client.post('/api/todos', json={'description': 'No title here'}) + assert res.status_code == 400 + data = res.get_json() + assert 'error' in data + +def test_get_todos_empty(client): + res = client.get('/api/todos') + assert res.status_code == 200 + assert res.get_json() == [] + +def test_get_todo_not_found(client): + res = client.get('/api/todos/999') + assert res.status_code == 404 + data = res.get_json() + assert 'error' in data + +def test_update_todo_not_found(client): + res = client.put('/api/todos/999', json={'title': 'New title'}) + assert res.status_code == 404 + assert 'error' in res.get_json() + +def test_delete_todo_not_found(client): + res = client.delete('/api/todos/999') + assert res.status_code == 404 + data = res.get_json() + assert 'error' in data + +def test_crud_flow(client): + # Create + res = client.post('/api/todos', json={'title': 'First task'}) + assert res.status_code == 201 + todo = res.get_json() + todo_id = todo['id'] + + # Read + res = client.get(f'/api/todos/{todo_id}') + assert res.status_code == 200 + assert res.get_json()['title'] == 'First task' + + # Update + res = client.put(f'/api/todos/{todo_id}', json={'done': True}) + assert res.status_code == 200 + assert res.get_json()['done'] is True + + # Delete + res = client.delete(f'/api/todos/{todo_id}') + assert res.status_code == 200 + + # Confirm Deletion + res = client.get(f'/api/todos/{todo_id}') + assert res.status_code == 404 + +def test_full_update_todo(client): + # Create a todo first + res = client.post('/api/todos', json={'title': 'Temp title'}) + assert res.status_code == 201 + todo = res.get_json() + todo_id = todo['id'] + + # Update both title and description + res = client.put(f'/api/todos/{todo_id}', json={'title': 'Updated title', 'description': 'Updated desc'}) + assert res.status_code == 200 + data = res.get_json() + assert data['title'] == 'Updated title' + assert data['description'] == 'Updated desc' + + # Read updated todo + res = client.get(f'/api/todos/{todo_id}') + assert res.status_code == 200 + data = res.get_json() + assert data['title'] == 'Updated title' + assert data['description'] == 'Updated desc'