From 8692b9a8cb5bc4e6761c51719d82964bd605c22e Mon Sep 17 00:00:00 2001 From: Your Name <202480044@psu.palawan.edu.ph> Date: Fri, 29 May 2026 22:35:56 +0800 Subject: [PATCH 1/2] Add REST API for tasks with full CRUD, tests, and Swagger docs --- app.py | 302 ++++++++++++++++++++++++++++++++++++++++++++++++++-- test_api.py | 243 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 536 insertions(+), 9 deletions(-) create mode 100644 test_api.py diff --git a/app.py b/app.py index 4b6ccca..d03c869 100644 --- a/app.py +++ b/app.py @@ -1,26 +1,58 @@ -from flask import Flask, render_template, request, redirect, url_for +# ───────────────────────────────────────────── +# IMPORTS — bringing in the tools we need +# ───────────────────────────────────────────── + +from flask import Flask, render_template, request, redirect, url_for, jsonify from flask_sqlalchemy import SQLAlchemy +from flasgger import Swagger, swag_from + +# ───────────────────────────────────────────── +# APP SETUP +# ───────────────────────────────────────────── app = Flask(__name__) -# /// = relative path, //// = absolute path +# Database configuration app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +# Swagger configuration — sets up the docs page +app.config['SWAGGER'] = { + 'title': 'Flask Todo API', + 'uiversion': 3, + 'description': 'A REST API for managing todo tasks', + 'version': '1.0.0' +} + db = SQLAlchemy(app) +swagger = Swagger(app) +# ───────────────────────────────────────────── +# DATABASE MODEL (the "Todo" table blueprint) +# ───────────────────────────────────────────── class Todo(db.Model): id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(100)) - complete = db.Column(db.Boolean) + title = db.Column(db.String(100), nullable=False) + complete = db.Column(db.Boolean, default=False) + def to_dict(self): + """Convert a Todo object to a Python dictionary (for JSON responses)""" + return { + 'id': self.id, + 'title': self.title, + 'complete': self.complete + } + +# ───────────────────────────────────────────── +# ORIGINAL WEB ROUTES (keep these — don't delete) +# ───────────────────────────────────────────── @app.route("/") def home(): todo_list = Todo.query.all() return render_template("base.html", todo_list=todo_list) - @app.route("/add", methods=["POST"]) def add(): title = request.form.get("title") @@ -29,7 +61,6 @@ def add(): db.session.commit() return redirect(url_for("home")) - @app.route("/update/") def update(todo_id): todo = Todo.query.filter_by(id=todo_id).first() @@ -37,7 +68,6 @@ def update(todo_id): db.session.commit() return redirect(url_for("home")) - @app.route("/delete/") def delete(todo_id): todo = Todo.query.filter_by(id=todo_id).first() @@ -45,6 +75,260 @@ def delete(todo_id): db.session.commit() return redirect(url_for("home")) +# ───────────────────────────────────────────── +# REST API ROUTES (your new feature!) +# ───────────────────────────────────────────── + +# ── READ ALL ────────────────────────────────── +@app.route("/api/tasks", methods=["GET"]) +def get_all_tasks(): + """ + Get all tasks + --- + tags: + - Tasks + responses: + 200: + description: A list of all tasks + schema: + type: array + items: + properties: + id: + type: integer + example: 1 + title: + type: string + example: Buy groceries + complete: + type: boolean + example: false + """ + tasks = Todo.query.all() + return jsonify([task.to_dict() for task in tasks]), 200 + + +# ── READ ONE ────────────────────────────────── +@app.route("/api/tasks/", methods=["GET"]) +def get_task(task_id): + """ + Get a single task by ID + --- + tags: + - Tasks + parameters: + - name: task_id + in: path + type: integer + required: true + description: The ID of the task + responses: + 200: + description: The task was found + schema: + properties: + id: + type: integer + example: 1 + title: + type: string + example: Buy groceries + complete: + type: boolean + example: false + 404: + description: Task not found + schema: + properties: + error: + type: string + example: Task not found + """ + task = Todo.query.get(task_id) + if task is None: + return jsonify({'error': 'Task not found'}), 404 + return jsonify(task.to_dict()), 200 + + +# ── CREATE ──────────────────────────────────── +@app.route("/api/tasks", methods=["POST"]) +def create_task(): + """ + Create a new task + --- + tags: + - Tasks + parameters: + - in: body + name: body + required: true + schema: + required: + - title + properties: + title: + type: string + example: Buy groceries + complete: + type: boolean + example: false + responses: + 201: + description: Task created successfully + schema: + properties: + id: + type: integer + example: 1 + title: + type: string + example: Buy groceries + complete: + type: boolean + example: false + 400: + description: Missing required field + schema: + properties: + error: + type: string + example: Title is required + """ + data = request.get_json() + + # Validate: data must exist and title must be present and not empty + if not data or 'title' not in data or not data['title'].strip(): + return jsonify({'error': 'Title is required'}), 400 + + new_task = Todo( + title=data['title'].strip(), + complete=data.get('complete', False) # defaults to False if not provided + ) + db.session.add(new_task) + db.session.commit() + + return jsonify(new_task.to_dict()), 201 + + +# ── UPDATE ──────────────────────────────────── +@app.route("/api/tasks/", methods=["PUT"]) +def update_task(task_id): + """ + Update an existing task + --- + tags: + - Tasks + parameters: + - name: task_id + in: path + type: integer + required: true + description: The ID of the task to update + - in: body + name: body + required: true + schema: + properties: + title: + type: string + example: Updated task title + complete: + type: boolean + example: true + responses: + 200: + description: Task updated successfully + schema: + properties: + id: + type: integer + example: 1 + title: + type: string + example: Updated task title + complete: + type: boolean + example: true + 400: + description: No valid fields provided + schema: + properties: + error: + type: string + example: No valid fields provided + 404: + description: Task not found + schema: + properties: + error: + type: string + example: Task not found + """ + task = Todo.query.get(task_id) + if task is None: + return jsonify({'error': 'Task not found'}), 404 + + data = request.get_json() + if not data: + return jsonify({'error': 'No valid fields provided'}), 400 + + # Only update the fields that were actually sent + if 'title' in data: + if not data['title'].strip(): + return jsonify({'error': 'Title cannot be empty'}), 400 + task.title = data['title'].strip() + + if 'complete' in data: + task.complete = data['complete'] + + db.session.commit() + return jsonify(task.to_dict()), 200 + + +# ── DELETE ──────────────────────────────────── +@app.route("/api/tasks/", methods=["DELETE"]) +def delete_task(task_id): + """ + Delete a task + --- + tags: + - Tasks + parameters: + - name: task_id + in: path + type: integer + required: true + description: The ID of the task to delete + responses: + 200: + description: Task deleted successfully + schema: + properties: + message: + type: string + example: Task deleted successfully + 404: + description: Task not found + schema: + properties: + error: + type: string + example: Task not found + """ + task = Todo.query.get(task_id) + if task is None: + return jsonify({'error': 'Task not found'}), 404 + + db.session.delete(task) + db.session.commit() + return jsonify({'message': 'Task deleted successfully'}), 200 + + +# ───────────────────────────────────────────── +# RUN THE APP +# ───────────────────────────────────────────── + if __name__ == "__main__": - db.create_all() - app.run(debug=True) + with app.app_context(): + db.create_all() + app.run(debug=True) \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..1309679 --- /dev/null +++ b/test_api.py @@ -0,0 +1,243 @@ +# ───────────────────────────────────────────── +# test_api.py — Tests for the Flask Todo REST API +# ───────────────────────────────────────────── + +import pytest +from app import app, db, Todo + +# ───────────────────────────────────────────── +# SETUP — runs before and after each test +# ───────────────────────────────────────────── + +@pytest.fixture +def client(): + """ + This is a pytest fixture. Think of it as a "setup function." + It runs before each test to give us a fresh, clean app and database. + """ + # Tell Flask to use a separate in-memory database for testing + # (so we don't mess up the real database) + app.config['TESTING'] = True + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + + with app.app_context(): + db.create_all() # Create fresh tables + yield app.test_client() # Give the test a "fake browser" to send requests + db.drop_all() # Delete everything after the test is done + + +# ───────────────────────────────────────────── +# HELPER FUNCTION +# ───────────────────────────────────────────── + +def create_task(client, title="Test Task", complete=False): + """Helper that creates a task and returns the response. + Avoids repeating the same code in every test.""" + return client.post('/api/tasks', + json={'title': title, 'complete': complete}, + content_type='application/json') + + +# ═══════════════════════════════════════════════ +# CREATE TESTS (POST /api/tasks) +# ═══════════════════════════════════════════════ + +def test_create_task_success(client): + """POSITIVE: Create a task with valid data — should succeed""" + response = create_task(client, title="Buy groceries") + data = response.get_json() + + # Check that the HTTP status code is 201 (Created) + assert response.status_code == 201 + + # Check that the response contains the right data + assert data['title'] == 'Buy groceries' + assert data['complete'] == False + assert 'id' in data # An ID should have been assigned + + +def test_create_task_with_complete_true(client): + """POSITIVE: Create a task that is already marked as complete""" + response = create_task(client, title="Done task", complete=True) + data = response.get_json() + + assert response.status_code == 201 + assert data['complete'] == True + + +def test_create_task_missing_title(client): + """NEGATIVE: Try to create a task without a title — should fail""" + response = client.post('/api/tasks', + json={'complete': False}, + content_type='application/json') + data = response.get_json() + + assert response.status_code == 400 + assert 'error' in data + assert data['error'] == 'Title is required' + + +def test_create_task_empty_title(client): + """NEGATIVE: Try to create a task with an empty title string""" + response = client.post('/api/tasks', + json={'title': ' '}, # Only spaces + content_type='application/json') + data = response.get_json() + + assert response.status_code == 400 + assert data['error'] == 'Title is required' + + +def test_create_task_no_body(client): + """NEGATIVE: Send a POST with no body at all""" + response = client.post('/api/tasks', + content_type='application/json') + data = response.get_json() + + assert response.status_code == 400 + + +# ═══════════════════════════════════════════════ +# READ ALL TESTS (GET /api/tasks) +# ═══════════════════════════════════════════════ + +def test_get_all_tasks_empty(client): + """POSITIVE: Get all tasks when database is empty""" + response = client.get('/api/tasks') + data = response.get_json() + + assert response.status_code == 200 + assert data == [] # Should be an empty list + + +def test_get_all_tasks_with_data(client): + """POSITIVE: Get all tasks after creating some""" + create_task(client, title="Task 1") + create_task(client, title="Task 2") + + response = client.get('/api/tasks') + data = response.get_json() + + assert response.status_code == 200 + assert len(data) == 2 + assert data[0]['title'] == 'Task 1' + assert data[1]['title'] == 'Task 2' + + +# ═══════════════════════════════════════════════ +# READ ONE TESTS (GET /api/tasks/) +# ═══════════════════════════════════════════════ + +def test_get_task_success(client): + """POSITIVE: Get a task that exists""" + create_response = create_task(client, title="Single Task") + task_id = create_response.get_json()['id'] + + response = client.get(f'/api/tasks/{task_id}') + data = response.get_json() + + assert response.status_code == 200 + assert data['title'] == 'Single Task' + assert data['id'] == task_id + + +def test_get_task_not_found(client): + """NEGATIVE: Try to get a task that doesn't exist""" + response = client.get('/api/tasks/9999') + data = response.get_json() + + assert response.status_code == 404 + assert data['error'] == 'Task not found' + + +# ═══════════════════════════════════════════════ +# UPDATE TESTS (PUT /api/tasks/) +# ═══════════════════════════════════════════════ + +def test_update_task_title(client): + """POSITIVE: Update the title of an existing task""" + task_id = create_task(client, title="Old Title").get_json()['id'] + + response = client.put(f'/api/tasks/{task_id}', + json={'title': 'New Title'}, + content_type='application/json') + data = response.get_json() + + assert response.status_code == 200 + assert data['title'] == 'New Title' + + +def test_update_task_complete_status(client): + """POSITIVE: Mark a task as complete""" + task_id = create_task(client, title="Finish homework").get_json()['id'] + + response = client.put(f'/api/tasks/{task_id}', + json={'complete': True}, + content_type='application/json') + data = response.get_json() + + assert response.status_code == 200 + assert data['complete'] == True + + +def test_update_task_not_found(client): + """NEGATIVE: Try to update a task that doesn't exist""" + response = client.put('/api/tasks/9999', + json={'title': 'Ghost Task'}, + content_type='application/json') + data = response.get_json() + + assert response.status_code == 404 + assert data['error'] == 'Task not found' + + +def test_update_task_empty_body(client): + """NEGATIVE: Try to update with no body""" + task_id = create_task(client).get_json()['id'] + + response = client.put(f'/api/tasks/{task_id}', + content_type='application/json') + data = response.get_json() + + assert response.status_code == 400 + + +def test_update_task_empty_title(client): + """NEGATIVE: Try to update title with empty string""" + task_id = create_task(client, title="Valid Task").get_json()['id'] + + response = client.put(f'/api/tasks/{task_id}', + json={'title': ''}, + content_type='application/json') + data = response.get_json() + + assert response.status_code == 400 + assert data['error'] == 'Title cannot be empty' + + +# ═══════════════════════════════════════════════ +# DELETE TESTS (DELETE /api/tasks/) +# ═══════════════════════════════════════════════ + +def test_delete_task_success(client): + """POSITIVE: Delete a task that exists""" + task_id = create_task(client, title="To be deleted").get_json()['id'] + + response = client.delete(f'/api/tasks/{task_id}') + data = response.get_json() + + assert response.status_code == 200 + assert data['message'] == 'Task deleted successfully' + + # Verify the task is really gone + get_response = client.get(f'/api/tasks/{task_id}') + assert get_response.status_code == 404 + + +def test_delete_task_not_found(client): + """NEGATIVE: Try to delete a task that doesn't exist""" + response = client.delete('/api/tasks/9999') + data = response.get_json() + + assert response.status_code == 404 + assert data['error'] == 'Task not found' \ No newline at end of file From 1555b5947936a0a4af9e62efb92441ed2cd8eff6 Mon Sep 17 00:00:00 2001 From: Your Name <202480044@psu.palawan.edu.ph> Date: Sat, 30 May 2026 08:27:41 +0800 Subject: [PATCH 2/2] Add README documentation --- README.md | 111 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e2a98cb..557872c 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,98 @@ -Simple Flask Todo App using SQLAlchemy and SQLite database. +# Flask Todo REST API -For styling [semantic-ui](https://semantic-ui.com/) is used. +> Extended version of [patrickloeber/flask-todo](https://github.com/patrickloeber/flask-todo) with a full REST API for task management. -### Setup -Create project with virtual environment +## Original Project -```console -$ mkdir myproject -$ cd myproject -$ python3 -m venv venv +This project is built on top of the [Simple Flask Todo App](https://github.com/patrickloeber/flask-todo) by Patrick Loeber. The original app provides a web interface for managing todo tasks. + +## New Feature: REST API for Tasks + +I added a complete REST API that allows external applications to manage tasks programmatically using JSON. This extends the original app's purpose by making task data accessible to any client (mobile apps, other servers, tools like Postman). + +### API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/tasks` | Get all tasks | +| GET | `/api/tasks/` | Get a specific task | +| POST | `/api/tasks` | Create a new task | +| PUT | `/api/tasks/` | Update a task | +| DELETE | `/api/tasks/` | Delete a task | + +## Setup Instructions + +### Prerequisites +- Python 3.8 or higher +- Git + +### Installation + +1. Clone the repository: +```bash +git clone https://github.com/YOUR_USERNAME/flask-todo.git +cd flask-todo +``` + +2. Create and activate a virtual environment: +```bash +python -m venv venv +venv\Scripts\activate # Windows ``` -Activate it -```console -$ . venv/bin/activate +3. Install dependencies: +```bash +pip install flask flask-sqlalchemy pytest pytest-cov flasgger ``` -or on Windows -```console -venv\Scripts\activate +4. Run the application: +```bash +python app.py ``` -Install Flask -```console -$ pip install Flask -$ pip install Flask-SQLAlchemy +5. Open your browser: + - Web app: http://localhost:5000/ + - API docs (Swagger): http://localhost:5000/apidocs/ + +## Running Tests + +```bash +pytest test_api.py -v +``` + +### With Coverage Report + +```bash +pytest test_api.py --cov=app --cov-report=term-missing -v ``` -Set environment variables in terminal -```console -$ export FLASK_APP=app.py -$ export FLASK_ENV=development +## API Usage Examples + +### Create a Task +```bash +curl -X POST http://localhost:5000/api/tasks \ + -H "Content-Type: application/json" \ + -d '{"title": "Buy groceries", "complete": false}' +``` + +### Get All Tasks +```bash +curl http://localhost:5000/api/tasks ``` -or on Windows -```console -$ set FLASK_APP=app.py -$ set FLASK_ENV=development +### Update a Task +```bash +curl -X PUT http://localhost:5000/api/tasks/1 \ + -H "Content-Type: application/json" \ + -d '{"complete": true}' ``` -Run the app -```console -$ flask run +### Delete a Task +```bash +curl -X DELETE http://localhost:5000/api/tasks/1 ``` + +## Branch Information + +- `master` — Original project by Patrick Loeber +- `feature/tasks-api` — My REST API additions \ No newline at end of file