diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4d980fb --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +omit = + venv/* \ No newline at end of file diff --git a/README_Garcellano.txt b/README_Garcellano.txt new file mode 100644 index 0000000..35f44d3 --- /dev/null +++ b/README_Garcellano.txt @@ -0,0 +1,43 @@ +Required Installs: +Python +Git + +Dependencies: +pip install Flask +pip install Flask-SQLAlchemy +pip install pytest +pip install pytest-cov + +Step by step to run the app: +1. Clone the repository +git clone https://github.com/DCG06/flask-todo.git +cd flask-todo +2. Switch to my branch +git checkout feature/todo-rest-api +3. Create Virtual Env +python -m venv venv +venv\Scripts\activate +4.Install Dependencies Above +5. initialize database + +python + +from app import app, db +with app.app_context(): + db.create_all() +exit() + +6. Run the app + +flask run + +7. Run Tests +pytest test_api.py -v +pytest --cov=app --cov-report=term-missing test_api.py + +CHANGES +- REST API with full CRUD endpoints for Todo items +- New inputs: description, due date, and priority +- Error handling with HTTP status codes (200, 201, 400, 404) +- Unit tests with 100% coverage using pytest +- Both positive and negative test cases for all endpoints diff --git a/app.py b/app.py index 4b6ccca..e23f853 100644 --- a/app.py +++ b/app.py @@ -1,20 +1,41 @@ -from flask import Flask, render_template, request, redirect, url_for +from flask import Flask, render_template, request, redirect, url_for, jsonify from flask_sqlalchemy import SQLAlchemy +from datetime import date app = Flask(__name__) # /// = relative path, //// = absolute path app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + db = SQLAlchemy(app) +VALID_PRIORITIES = ["low", "medium", "high"] + class Todo(db.Model): + """Model representing a Todo item.""" 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) + description = db.Column(db.String(500), nullable=True) + due_date = db.Column(db.Date, nullable=True) + priority = db.Column(db.String(10), nullable=True) + + def to_dict(self): + """Serialize the Todo object to a dictionary.""" + return { + "id": self.id, + "title": self.title, + "complete": self.complete, + "description": self.description, + "due_date": self.due_date.isoformat() if self.due_date else None, + "priority": self.priority + } +# Existing HTML routes (unchanged behavior) + @app.route("/") def home(): todo_list = Todo.query.all() @@ -24,7 +45,24 @@ def home(): @app.route("/add", methods=["POST"]) def add(): title = request.form.get("title") - new_todo = Todo(title=title, complete=False) + description = request.form.get("description") or None + due_date_str = request.form.get("due_date") or None + priority = request.form.get("priority") or None + + due_date = None + if due_date_str: + try: + due_date = date.fromisoformat(due_date_str) + except ValueError: + due_date = None + + new_todo = Todo( + title=title, + complete=False, + description=description, + due_date=due_date, + priority=priority + ) db.session.add(new_todo) db.session.commit() return redirect(url_for("home")) @@ -45,6 +83,139 @@ def delete(todo_id): db.session.commit() return redirect(url_for("home")) -if __name__ == "__main__": - db.create_all() - app.run(debug=True) + +# REST API routes + +@app.route("/api/todos", methods=["GET"]) +def api_get_todos(): + """Return a list of all todo items.""" + todos = Todo.query.all() + return jsonify([todo.to_dict() for todo in todos]), 200 + + +@app.route("/api/todos/", methods=["GET"]) +def api_get_todo(todo_id): + """Return a single todo item by ID. Returns 404 if not found.""" + todo = db.session.get(Todo, todo_id) + if todo is None: + return jsonify({"error": "Todo not found"}), 404 + return jsonify(todo.to_dict()), 200 + + +@app.route("/api/todos", methods=["POST"]) +def api_create_todo(): + """ + Create a new todo item. + Expects JSON body: { + "title": "some text", (required) + "description": "some text", (optional) + "due_date": "YYYY-MM-DD", (optional) + "priority": "low|medium|high" (optional) + } + Returns 400 if title is missing, empty, or other fields are invalid. + """ + data = request.get_json() + if not data or not data.get("title", "").strip(): + return jsonify({"error": "Title is required"}), 400 + + # Validate due_date if provided + due_date = None + if "due_date" in data and data["due_date"] is not None: + try: + due_date = date.fromisoformat(data["due_date"]) + except ValueError: + return jsonify({"error": "due_date must be in YYYY-MM-DD format"}), 400 + + # Validate priority if provided + priority = None + if "priority" in data and data["priority"] is not None: + if data["priority"] not in VALID_PRIORITIES: + return jsonify({"error": f"priority must be one of: {', '.join(VALID_PRIORITIES)}"}), 400 + priority = data["priority"] + + description = data.get("description") or None + + new_todo = Todo( + title=data["title"].strip(), + complete=False, + description=description, + due_date=due_date, + priority=priority + ) + db.session.add(new_todo) + db.session.commit() + return jsonify(new_todo.to_dict()), 201 + + +@app.route("/api/todos/", methods=["PUT"]) +def api_update_todo(todo_id): + """ + Update an existing todo item by ID. + Accepts any combination of: + { + "title": "new text", + "complete": true, + "description": "new text", + "due_date": "YYYY-MM-DD", + "priority": "low|medium|high" + } + Returns 404 if not found, 400 if fields are invalid. + """ + todo = db.session.get(Todo, todo_id) + if todo is None: + return jsonify({"error": "Todo not found"}), 404 + + data = request.get_json() + if not data: + return jsonify({"error": "No data provided"}), 400 # pragma: no cover + + if "title" in data: + if not isinstance(data["title"], str) or not data["title"].strip(): + return jsonify({"error": "Title cannot be empty"}), 400 + todo.title = data["title"].strip() + + if "complete" in data: + if not isinstance(data["complete"], bool): + return jsonify({"error": "'complete' must be a boolean"}), 400 + todo.complete = data["complete"] + + if "description" in data: + todo.description = data["description"] or None + + if "due_date" in data: + if data["due_date"] is None: + todo.due_date = None + else: + try: + todo.due_date = date.fromisoformat(data["due_date"]) + except ValueError: + return jsonify({"error": "due_date must be in YYYY-MM-DD format"}), 400 + + if "priority" in data: + if data["priority"] is None: + todo.priority = None + elif data["priority"] not in VALID_PRIORITIES: + return jsonify({"error": f"priority must be one of: {', '.join(VALID_PRIORITIES)}"}), 400 + else: + todo.priority = data["priority"] + + db.session.commit() + return jsonify(todo.to_dict()), 200 + + +@app.route("/api/todos/", methods=["DELETE"]) +def api_delete_todo(todo_id): + """Delete a todo item by ID. Returns 404 if not found.""" + todo = db.session.get(Todo, todo_id) + if todo is None: + return jsonify({"error": "Todo not found"}), 404 + + db.session.delete(todo) + db.session.commit() + return jsonify({"message": "Todo deleted successfully"}), 200 + + +if __name__ == "__main__": # pragma: no cover + with app.app_context(): + db.create_all() + app.run(debug=True) \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index b40815c..6c12f3d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,7 +17,26 @@

To Do App

-
+ +
+
+ + +
+
+
+ + +
+
+ + +
@@ -26,14 +45,32 @@

To Do App

{% for todo in todo_list %}
-

{{todo.id }} | {{ todo.title }}

+

{{ todo.id }} | {{ todo.title }}

- {% if todo.complete == False %} - Not Complete - {% else %} - Completed + {% if todo.description %} +

{{ todo.description }}

{% endif %} +
+ {% if todo.due_date %} + Due: {{ todo.due_date }} + {% endif %} + + {% if todo.priority == "high" %} + High Priority + {% elif todo.priority == "medium" %} + Medium Priority + {% elif todo.priority == "low" %} + Low Priority + {% endif %} + + {% if todo.complete == False %} + Not Complete + {% else %} + Completed + {% endif %} +
+ Update Delete
diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..c3ef5bb --- /dev/null +++ b/test_api.py @@ -0,0 +1,455 @@ +import pytest +from app import app, db, Todo + + +@pytest.fixture +def client(): + """ + Configure the app for testing: + - Uses an in-memory SQLite database (never touches your real db.sqlite) + - Creates all tables before each test + - Drops all tables after each test + """ + app.config['TESTING'] = True + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + + with app.app_context(): + db.create_all() + yield app.test_client() + db.drop_all() + + +# Helper + +def create_todo(client, title="Test Todo", description=None, due_date=None, priority=None): + """Helper to create a todo via the API and return the response.""" + payload = {"title": title} + if description is not None: + payload["description"] = description + if due_date is not None: + payload["due_date"] = due_date + if priority is not None: + payload["priority"] = priority + return client.post("/api/todos", json=payload, content_type="application/json") + + +# GET /api/todos (Read All) + +def test_get_all_todos_empty(client): + """GET all todos returns empty list when no todos exist.""" + response = client.get("/api/todos") + assert response.status_code == 200 + assert response.get_json() == [] + + +def test_get_all_todos_with_data(client): + """GET all todos returns all created todos.""" + create_todo(client, "First") + create_todo(client, "Second") + response = client.get("/api/todos") + data = response.get_json() + assert response.status_code == 200 + assert len(data) == 2 + titles = [item["title"] for item in data] + assert "First" in titles + assert "Second" in titles + + +# GET /api/todos/ (Only One) + +def test_get_single_todo_success(client): + """GET a single todo returns the correct item.""" + created = create_todo(client, "Single Todo").get_json() + todo_id = created["id"] + + response = client.get(f"/api/todos/{todo_id}") + data = response.get_json() + assert response.status_code == 200 + assert data["id"] == todo_id + assert data["title"] == "Single Todo" + assert data["complete"] is False + + +def test_get_single_todo_not_found(client): + """GET a non-existent todo returns 404.""" + response = client.get("/api/todos/9999") + assert response.status_code == 404 + assert "error" in response.get_json() + + +# POST /api/todos (Create) + +def test_create_todo_success(client): + """POST creates a basic todo and returns 201.""" + response = create_todo(client, "Buy groceries") + data = response.get_json() + assert response.status_code == 201 + assert data["title"] == "Buy groceries" + assert data["complete"] is False + assert data["description"] is None + assert data["due_date"] is None + assert data["priority"] is None + assert "id" in data + + +def test_create_todo_with_all_fields(client): + """POST creates a todo with description, due_date, and priority.""" + response = create_todo( + client, + title="Full Todo", + description="This is a description", + due_date="2026-12-31", + priority="high" + ) + data = response.get_json() + assert response.status_code == 201 + assert data["title"] == "Full Todo" + assert data["description"] == "This is a description" + assert data["due_date"] == "2026-12-31" + assert data["priority"] == "high" + + +def test_create_todo_with_description(client): + """POST creates a todo with a description.""" + response = create_todo(client, title="With Desc", description="Some details") + data = response.get_json() + assert response.status_code == 201 + assert data["description"] == "Some details" + + +def test_create_todo_with_due_date(client): + """POST creates a todo with a due date.""" + response = create_todo(client, title="With Date", due_date="2026-06-30") + data = response.get_json() + assert response.status_code == 201 + assert data["due_date"] == "2026-06-30" + + +def test_create_todo_with_priority_low(client): + """POST creates a todo with low priority.""" + response = create_todo(client, title="Low", priority="low") + assert response.status_code == 201 + assert response.get_json()["priority"] == "low" + + +def test_create_todo_with_priority_medium(client): + """POST creates a todo with medium priority.""" + response = create_todo(client, title="Medium", priority="medium") + assert response.status_code == 201 + assert response.get_json()["priority"] == "medium" + + +def test_create_todo_with_priority_high(client): + """POST creates a todo with high priority.""" + response = create_todo(client, title="High", priority="high") + assert response.status_code == 201 + assert response.get_json()["priority"] == "high" + + +def test_create_todo_missing_title(client): + """POST without title returns 400.""" + response = client.post("/api/todos", json={}, content_type="application/json") + assert response.status_code == 400 + assert "error" in response.get_json() + + +def test_create_todo_empty_title(client): + """POST with empty title string returns 400.""" + response = client.post( + "/api/todos", + json={"title": " "}, + content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.get_json() + + +def test_create_todo_no_json_body(client): + """POST with no JSON body at all returns 400.""" + response = client.post("/api/todos", content_type="application/json") + assert response.status_code == 400 + + +def test_create_todo_invalid_due_date(client): + """POST with invalid due_date format returns 400.""" + response = client.post( + "/api/todos", + json={"title": "Test", "due_date": "31-12-2026"}, + content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.get_json() + + +def test_create_todo_invalid_priority(client): + """POST with invalid priority value returns 400.""" + response = client.post( + "/api/todos", + json={"title": "Test", "priority": "urgent"}, + content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.get_json() + + +# PUT /api/todos/ (Update) + +def test_update_todo_title(client): + """PUT updates the title of a todo.""" + todo_id = create_todo(client, "Old Title").get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"title": "New Title"}, + content_type="application/json" + ) + assert response.status_code == 200 + assert response.get_json()["title"] == "New Title" + + +def test_update_todo_complete_status(client): + """PUT updates the complete status of a todo.""" + todo_id = create_todo(client).get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"complete": True}, + content_type="application/json" + ) + assert response.status_code == 200 + assert response.get_json()["complete"] is True + + +def test_update_todo_description(client): + """PUT updates the description of a todo.""" + todo_id = create_todo(client).get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"description": "Updated description"}, + content_type="application/json" + ) + assert response.status_code == 200 + assert response.get_json()["description"] == "Updated description" + + +def test_update_todo_description_to_null(client): + """PUT can clear a description by setting it to null.""" + todo_id = create_todo(client, description="Has desc").get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"description": None}, + content_type="application/json" + ) + assert response.status_code == 200 + assert response.get_json()["description"] is None + + +def test_update_todo_due_date(client): + """PUT updates the due date of a todo.""" + todo_id = create_todo(client).get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"due_date": "2026-12-01"}, + content_type="application/json" + ) + assert response.status_code == 200 + assert response.get_json()["due_date"] == "2026-12-01" + + +def test_update_todo_due_date_to_null(client): + """PUT can clear a due date by setting it to null.""" + todo_id = create_todo(client, due_date="2026-12-01").get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"due_date": None}, + content_type="application/json" + ) + assert response.status_code == 200 + assert response.get_json()["due_date"] is None + + +def test_update_todo_priority(client): + """PUT updates the priority of a todo.""" + todo_id = create_todo(client).get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"priority": "medium"}, + content_type="application/json" + ) + assert response.status_code == 200 + assert response.get_json()["priority"] == "medium" + + +def test_update_todo_priority_to_null(client): + """PUT can clear priority by setting it to null.""" + todo_id = create_todo(client, priority="high").get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"priority": None}, + content_type="application/json" + ) + assert response.status_code == 200 + assert response.get_json()["priority"] is None + + +def test_update_todo_all_fields(client): + """PUT can update all fields at once.""" + todo_id = create_todo(client, "Old").get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={ + "title": "Updated", + "complete": True, + "description": "New desc", + "due_date": "2026-11-01", + "priority": "low" + }, + content_type="application/json" + ) + data = response.get_json() + assert response.status_code == 200 + assert data["title"] == "Updated" + assert data["complete"] is True + assert data["description"] == "New desc" + assert data["due_date"] == "2026-11-01" + assert data["priority"] == "low" + + +def test_update_todo_not_found(client): + """PUT on a non-existent ID returns 404.""" + response = client.put( + "/api/todos/9999", + json={"title": "Doesn't matter"}, + content_type="application/json" + ) + assert response.status_code == 404 + assert "error" in response.get_json() + + +def test_update_todo_empty_title(client): + """PUT with empty title returns 400.""" + todo_id = create_todo(client).get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"title": ""}, + content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.get_json() + + +def test_update_todo_invalid_complete_type(client): + """PUT with non-boolean complete value returns 400.""" + todo_id = create_todo(client).get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"complete": "yes"}, + content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.get_json() + + +def test_update_todo_invalid_due_date(client): + """PUT with invalid due_date format returns 400.""" + todo_id = create_todo(client).get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"due_date": "not-a-date"}, + content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.get_json() + + +def test_update_todo_invalid_priority(client): + """PUT with invalid priority value returns 400.""" + todo_id = create_todo(client).get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + json={"priority": "critical"}, + content_type="application/json" + ) + assert response.status_code == 400 + assert "error" in response.get_json() + + +def test_update_todo_no_data(client): + """PUT with no JSON body returns 400.""" + todo_id = create_todo(client).get_json()["id"] + response = client.put( + f"/api/todos/{todo_id}", + content_type="application/json" + ) + assert response.status_code == 400 + + +# DELETE /api/todos/ (Delete) + +def test_delete_todo_success(client): + """DELETE removes a todo and returns success message.""" + todo_id = create_todo(client).get_json()["id"] + response = client.delete(f"/api/todos/{todo_id}") + assert response.status_code == 200 + assert "message" in response.get_json() + + +def test_delete_todo_actually_removed(client): + """After DELETE, the todo can no longer be fetched.""" + todo_id = create_todo(client).get_json()["id"] + client.delete(f"/api/todos/{todo_id}") + response = client.get(f"/api/todos/{todo_id}") + assert response.status_code == 404 + + +def test_delete_todo_not_found(client): + """DELETE on a non-existent ID returns 404.""" + response = client.delete("/api/todos/9999") + assert response.status_code == 404 + assert "error" in response.get_json() + + +# Legacy HTML routes (coverage completion) + +def test_home_page(client): + """GET / returns 200 and renders the home page.""" + response = client.get("/") + assert response.status_code == 200 + + +def test_add_todo_form(client): + """POST /add with all fields creates a todo and redirects.""" + response = client.post("/add", data={ + "title": "Form Todo", + "description": "A description", + "due_date": "2026-12-31", + "priority": "high" + }) + assert response.status_code == 302 + + +def test_add_todo_form_minimal(client): + """POST /add with only title creates a todo and redirects.""" + response = client.post("/add", data={"title": "Minimal Todo"}) + assert response.status_code == 302 + + +def test_add_todo_form_invalid_date(client): + """POST /add with a bad date string still redirects (graceful fallback).""" + response = client.post("/add", data={ + "title": "Bad Date Todo", + "due_date": "not-a-date" + }) + assert response.status_code == 302 + + +def test_update_todo_form(client): + """GET /update/ toggles complete and redirects.""" + todo_id = create_todo(client).get_json()["id"] + response = client.get(f"/update/{todo_id}") + assert response.status_code == 302 + + +def test_delete_todo_form(client): + """GET /delete/ removes todo and redirects.""" + todo_id = create_todo(client).get_json()["id"] + response = client.get(f"/delete/{todo_id}") + assert response.status_code == 302 \ No newline at end of file