diff --git a/README.md b/README.md index e2a98cb..da8d425 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ Simple Flask Todo App using SQLAlchemy and SQLite database. +Updated with full REST API feature. + +For styling, [semantic-ui](https://semantic-ui.com/) was used on the original version of the repository. +To lessen the workload of browsing into semantic-ui.com, I just made the styles inside the Base.html using CSS in this new repository. + + +### New Feature (Full REST API) +The new feature allows the Todo list to be used not just through the HTML interface, but also programmatically—via API calls. +This means other apps, JavaScript front-ends, or even mobile apps can now create, read, update, and delete todos. +It adds flexibility and modern integration potential, making the app much more powerful and scalable. + +I aldo added a Due Date feature for the to-do list. So that the user will be able to add the to-dos deadlines. -For styling [semantic-ui](https://semantic-ui.com/) is used. ### Setup Create project with virtual environment diff --git a/app.py b/app.py index 4b6ccca..ebcebf3 100644 --- a/app.py +++ b/app.py @@ -1,50 +1,154 @@ -from flask import Flask, render_template, request, redirect, url_for -from flask_sqlalchemy import SQLAlchemy - -app = Flask(__name__) - -# /// = relative path, //// = absolute path -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -db = SQLAlchemy(app) - - -class Todo(db.Model): - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(100)) - complete = db.Column(db.Boolean) - - -@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") - new_todo = Todo(title=title, complete=False) - db.session.add(new_todo) - db.session.commit() - return redirect(url_for("home")) - - -@app.route("/update/") -def update(todo_id): - todo = Todo.query.filter_by(id=todo_id).first() - todo.complete = not todo.complete - db.session.commit() - return redirect(url_for("home")) - - -@app.route("/delete/") -def delete(todo_id): - todo = Todo.query.filter_by(id=todo_id).first() - db.session.delete(todo) - db.session.commit() - return redirect(url_for("home")) - -if __name__ == "__main__": - db.create_all() - app.run(debug=True) +from flask import Flask, render_template, request, redirect, url_for, jsonify, flash +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime + +app = Flask(__name__) +app.secret_key = "your_secret_key_here" + +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.sqlite' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +db = SQLAlchemy(app) + +class Todo(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(100)) + complete = db.Column(db.Boolean) + due_date = db.Column(db.Date) + +@app.route("/") +def home(): + # ✅ Sort by due date, nulls (None) last + todo_list = Todo.query.order_by(Todo.due_date.asc().nullslast()).all() + return render_template("base.html", todo_list=todo_list) + +@app.route("/add", methods=["POST"]) +def add(): + title = request.form.get("title") + due_date_str = request.form.get("due_date") + + if not title or title.strip() == "": + flash("Todo title cannot be empty.", "warning") + return redirect(url_for("home")) + + due_date = None + if due_date_str: + try: + due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date() + except ValueError: + flash("Invalid due date format.", "warning") + return redirect(url_for("home")) + + new_todo = Todo(title=title.strip(), complete=False, due_date=due_date) + db.session.add(new_todo) + db.session.commit() + flash(f'Todo "{title}" added.', "success") + return redirect(url_for("home")) + +@app.route("/toggle/") +def toggle(todo_id): + todo = Todo.query.get_or_404(todo_id) + todo.complete = not todo.complete + db.session.commit() + flash(f'Todo "{todo.title}" toggled to {"done" if todo.complete else "not done"}.', "info") + return redirect(url_for("home")) + +@app.route("/delete/") +def delete(todo_id): + todo = Todo.query.filter_by(id=todo_id).first() + if todo: + db.session.delete(todo) + db.session.commit() + flash(f'Todo "{todo.title}" deleted.', "error") + else: + flash("Todo not found.", "error") + return redirect(url_for("home")) + +# ----------------------- +# REST API ENDPOINTS +# ----------------------- + +@app.route("/api/todos", methods=["GET"]) +def api_get_todos(): + todos = Todo.query.order_by(Todo.due_date.asc().nullslast()).all() + return jsonify([ + { + "id": t.id, + "title": t.title, + "complete": t.complete, + "due_date": t.due_date.strftime("%Y-%m-%d") if t.due_date else None + } for t in todos + ]), 200 + +@app.route("/api/todos/", methods=["GET"]) +def api_get_single_todo(todo_id): + todo = Todo.query.get_or_404(todo_id) + return jsonify({ + "id": todo.id, + "title": todo.title, + "complete": todo.complete, + "due_date": todo.due_date.strftime("%Y-%m-%d") if todo.due_date else None + }), 200 + +@app.route("/api/todos", methods=["POST"]) +def api_create_todo(): + data = request.get_json() + title = data.get("title") + due_date_str = data.get("due_date") + + if not title: + return jsonify({"error": "Title is required"}), 400 + + due_date = None + if due_date_str: + try: + due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date() + except ValueError: + return jsonify({"error": "Invalid due_date format. Use YYYY-MM-DD"}), 400 + + new_todo = Todo(title=title, complete=False, due_date=due_date) + db.session.add(new_todo) + db.session.commit() + return jsonify({"message": "Todo created", "id": new_todo.id}), 201 + +@app.route("/api/todos/", methods=["PUT"]) +def api_update_todo(todo_id): + todo = Todo.query.get_or_404(todo_id) + data = request.get_json() + todo.title = data.get("title", todo.title) + todo.complete = data.get("complete", todo.complete) + + due_date_str = data.get("due_date") + if due_date_str is not None: + try: + todo.due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date() + except ValueError: + return jsonify({"error": "Invalid due_date format. Use YYYY-MM-DD"}), 400 + + db.session.commit() + return jsonify({"message": "Todo updated"}), 200 + +@app.route("/api/todos/", methods=["DELETE"]) +def api_delete_todo(todo_id): + todo = Todo.query.get_or_404(todo_id) + db.session.delete(todo) + db.session.commit() + return jsonify({"message": "Todo deleted"}), 200 + +@app.route("/api/todos//done", methods=["PATCH"]) +def api_mark_done(todo_id): + todo = Todo.query.get_or_404(todo_id) + todo.complete = True + db.session.commit() + return jsonify({"message": f"Todo {todo_id} marked as done"}), 200 + +@app.route("/api/todos//toggle", methods=["PATCH"]) +def api_toggle_done(todo_id): + todo = Todo.query.get_or_404(todo_id) + todo.complete = not todo.complete + db.session.commit() + return jsonify({"message": f"Todo {todo_id} toggled to {'done' if todo.complete else 'not done'}"}), 200 + +if __name__ == "__main__": + with app.app_context(): + db.create_all() + app.run(debug=True) diff --git a/base.html b/base.html new file mode 100644 index 0000000..b022bc7 --- /dev/null +++ b/base.html @@ -0,0 +1,148 @@ + + + + + + To-Do App + + + + + + + +
+

To Do App

+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + + +
+ +
+ + {% for todo in todo_list %} +
+

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

+ + {% if not todo.complete %} + Not Complete + {% else %} + Completed + {% endif %} + + {% if not todo.complete %} + Mark Done + {% else %} + Mark Undone + {% endif %} + + + Delete + +
+ {% else %} +

No todos yet. Add one above!

+ {% endfor %} + +
+ + \ No newline at end of file diff --git a/bg.jpg b/bg.jpg new file mode 100644 index 0000000..fd9a2ec Binary files /dev/null and b/bg.jpg differ diff --git a/db.sqlite b/db.sqlite new file mode 100644 index 0000000..16cc344 Binary files /dev/null and b/db.sqlite differ diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..e40b9c2 --- /dev/null +++ b/init_db.py @@ -0,0 +1,6 @@ +# init_db.py +from app import app, db + +with app.app_context(): + db.create_all() + print("Database initialized successfully.") diff --git a/static/images/bg.jpg b/static/images/bg.jpg new file mode 100644 index 0000000..fd9a2ec Binary files /dev/null and b/static/images/bg.jpg differ diff --git a/templates/base.html b/templates/base.html index b40815c..452879f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,44 +1,142 @@ - - - - - - - Todo App - - - - - - -
-

To Do App

- -
-
- -
-
- -
- -
- - {% for todo in todo_list %} -
-

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

- - {% if todo.complete == False %} - Not Complete - {% else %} - Completed - {% endif %} - - Update - Delete -
- {% endfor %} -
- - + + + + + + To-Do App + + + + + + +
+

To Do App

+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
+
+ + +
+ +
+ + +
+ + {% for todo in todo_list %} +
+

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

+ + {% if todo.due_date %} +

Due: {{ todo.due_date.strftime("%Y-%m-%d") }}

+ {% endif %} + + {% if not todo.complete %} + Not Complete + Mark Done + {% else %} + Completed + Mark Undone + {% endif %} + + Delete +
+ {% else %} +

No todos yet. Add one above!

+ {% endfor %} +
+ \ No newline at end of file diff --git a/test.html b/test.html new file mode 100644 index 0000000..191dc6c --- /dev/null +++ b/test.html @@ -0,0 +1,14 @@ + + + + + Semantic UI Test + + + +
+ +
+ + + diff --git a/test_app.py b/test_app.py new file mode 100644 index 0000000..b6a3263 --- /dev/null +++ b/test_app.py @@ -0,0 +1,116 @@ +import pytest +from app import app, db, Todo + +@pytest.fixture +def client(): + app.config["TESTING"] = True + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" + app.config["WTF_CSRF_ENABLED"] = False + with app.test_client() as client: + with app.app_context(): + db.create_all() + yield client + +# Utility function +def create_todo(client, title="Test Todo"): + return client.post("/api/todos", json={"title": title}) + +# ---------- REST API Tests ---------- + +def test_api_create_todo_success(client): + response = create_todo(client) + assert response.status_code == 201 + +def test_api_create_todo_fail(client): + response = client.post("/api/todos", json={}) + assert response.status_code == 400 + +def test_api_get_all_todos(client): + create_todo(client, "Task 1") + response = client.get("/api/todos") + assert response.status_code == 200 + assert len(response.get_json()) >= 1 + +def test_api_get_single_todo(client): + todo_id = create_todo(client).get_json()["id"] + response = client.get(f"/api/todos/{todo_id}") + assert response.status_code == 200 + +def test_api_get_single_not_found(client): + response = client.get("/api/todos/999") + assert response.status_code == 404 + +def test_api_update_todo(client): + todo_id = create_todo(client).get_json()["id"] + response = client.put(f"/api/todos/{todo_id}", json={"title": "Updated", "complete": True}) + assert response.status_code == 200 + +def test_api_update_todo_not_found(client): + response = client.put("/api/todos/999", json={"title": "Fail"}) + assert response.status_code == 404 + +def test_api_delete_todo(client): + todo_id = create_todo(client).get_json()["id"] + response = client.delete(f"/api/todos/{todo_id}") + assert response.status_code == 200 + +def test_api_delete_todo_not_found(client): + response = client.delete("/api/todos/999") + assert response.status_code == 404 + +def test_api_mark_done(client): + todo_id = create_todo(client).get_json()["id"] + response = client.patch(f"/api/todos/{todo_id}/done") + assert response.status_code == 200 + +def test_api_mark_done_not_found(client): + response = client.patch("/api/todos/999/done") + assert response.status_code == 404 + +def test_api_toggle_done(client): + todo_id = create_todo(client).get_json()["id"] + response = client.patch(f"/api/todos/{todo_id}/toggle") + assert response.status_code == 200 + +def test_api_toggle_done_not_found(client): + response = client.patch("/api/todos/999/toggle") + assert response.status_code == 404 + +# ---------- HTML Route Tests ---------- + +def test_home_route(client): + response = client.get("/") + assert response.status_code == 200 + assert b"