diff --git a/main.py b/main.py
index f39531e..5809e85 100644
--- a/main.py
+++ b/main.py
@@ -1,16 +1,19 @@
-from fastapi import FastAPI, Request, Form
-from fastapi.responses import HTMLResponse
+from fastapi import FastAPI, Request, Form, HTTPException
+from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
import csv
import uuid
+import copy
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
+last_action = {"type": None, "data": None}
+
# Helper functions for CSV operations
def read_todos():
try:
@@ -19,6 +22,8 @@ def read_todos():
return [{"id": row[0], "task": row[1], "done": row[2]} for row in reader if len(row) >= 3]
except FileNotFoundError:
return []
+ except csv.Error:
+ return [] # Return an empty list if there's an error reading the CSV
def write_todos(todos):
with open("todos.csv", "w", newline="") as f:
@@ -27,43 +32,103 @@ def write_todos(todos):
writer.writerow([todo["id"], todo["task"], todo["done"]])
def initialize_todos_file():
- try:
- with open("todos.csv", "x") as f:
- pass # Create the file if it doesn't exist
- except FileExistsError:
- pass # File already exists, do nothing
+ # Create an empty file, overwriting any existing content
+ with open("todos.csv", "w", newline="") as f:
+ pass # This creates an empty file without writing anything
+
+# Add this new function to update last_action
+def update_last_action(action_type, data):
+ global last_action
+ last_action = {"type": action_type, "data": data}
+ print(f"Last action updated: {last_action}") # Debug log
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
todos = read_todos()
return templates.TemplateResponse(request, "index.html", {"todos": todos})
+# Update the add_todo function
@app.post("/add")
async def add_todo(task: str = Form(...), done: bool = Form(False)):
todos = read_todos()
- new_todo = {"id": str(uuid.uuid4()), "task": task, "done": done}
+ new_todo = {"id": str(uuid.uuid4()), "task": task, "done": str(done).lower()}
todos.append(new_todo)
write_todos(todos)
+ update_last_action("add", new_todo)
return RedirectResponse(url="/", status_code=303)
+# Update the update_todo function
@app.post("/update/{todo_id}")
-async def update_todo(todo_id: str, done: bool = Form(...)):
+def update_todo(todo_id: str, done: bool = Form(...)):
todos = read_todos()
- for todo in todos:
- if todo["id"] == todo_id:
- todo["done"] = str(done).lower() # Convert to lowercase string
- break
+ todo = next((todo for todo in todos if todo["id"] == todo_id), None)
+ if todo is None:
+ raise HTTPException(status_code=404, detail="Todo not found")
+ old_todo = copy.deepcopy(todo)
+ todo["done"] = str(done).lower()
write_todos(todos)
+ update_last_action("update", old_todo)
return RedirectResponse(url="/", status_code=303)
+# Update the delete_todo function
@app.post("/delete/{todo_id}")
-async def delete_todo(todo_id: str):
+def delete_todo(todo_id: str):
todos = read_todos()
- todos = [todo for todo in todos if todo["id"] != todo_id]
+ todo = next((todo for todo in todos if todo["id"] == todo_id), None)
+ if todo is None:
+ raise HTTPException(status_code=404, detail="Todo not found")
+ todos.remove(todo)
write_todos(todos)
+ update_last_action("delete", todo)
return RedirectResponse(url="/", status_code=303)
+# Update the clear_todos function
+@app.post("/clear")
+async def clear_todos():
+ old_todos = read_todos()
+ initialize_todos_file()
+ update_last_action("clear", old_todos)
+ return JSONResponse(content={"status": "success", "todos": []}, status_code=200)
+
+# Update the undo_last_action function
+@app.post("/undo")
+async def undo_last_action():
+ global last_action
+ todos = read_todos()
+
+ print(f"Undo called. Last action: {last_action}") # Debug log
+
+ if last_action["type"] is None or last_action["data"] is None:
+ print("No action to undo") # Debug log
+ return JSONResponse(content={"status": "no_action", "todos": todos}, status_code=200)
+
+ if last_action["type"] == "add":
+ print(f"Undoing add action: {last_action['data']}") # Debug log
+ todos = [todo for todo in todos if todo["id"] != last_action["data"]["id"]]
+ elif last_action["type"] == "update":
+ print(f"Undoing update action: {last_action['data']}") # Debug log
+ for todo in todos:
+ if todo["id"] == last_action["data"]["id"]:
+ todo.update(last_action["data"])
+ break
+ elif last_action["type"] == "delete":
+ print(f"Undoing delete action: {last_action['data']}") # Debug log
+ todos.append(last_action["data"])
+ elif last_action["type"] == "clear":
+ print(f"Undoing clear action: {last_action['data']}") # Debug log
+ todos = last_action["data"]
+
+ write_todos(todos)
+ print(f"Todos after undo: {todos}") # Debug log
+ last_action = {"type": None, "data": None}
+ return JSONResponse(content={"status": "success", "todos": todos}, status_code=200)
+
+# Add this new route to check the last action
+@app.get("/last-action")
+async def get_last_action():
+ return JSONResponse(content={"last_action": last_action}, status_code=200)
+
if __name__ == "__main__":
import uvicorn
- initialize_todos_file()
+ initialize_todos_file() # This will ensure an empty file at startup
uvicorn.run(app, host="0.0.0.0", port=8000)
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 513e30b..49e0b5c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,4 +3,5 @@ uvicorn
jinja2
python-multipart
pytest
+pytest-cov
httpx
\ No newline at end of file
diff --git a/static/script.js b/static/script.js
index a6a4c4d..1c89223 100644
--- a/static/script.js
+++ b/static/script.js
@@ -3,22 +3,73 @@ document.addEventListener('DOMContentLoaded', () => {
const todoInput = document.getElementById('todo-input');
const todoList = document.getElementById('todo-list');
const darkModeToggle = document.getElementById('dark-mode-toggle');
+ const clearTodosButton = document.getElementById('clear-todos');
+ const undoButton = document.getElementById('undo-button');
// Function to fetch and render todos
- const renderTodos = async () => {
+ const renderTodos = async (animateLastItem = false) => {
const response = await fetch('/');
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newTodoList = doc.getElementById('todo-list');
- todoList.innerHTML = newTodoList.innerHTML;
-
- // Add 'new-item' class to the last item for animation
- if (todoList.lastElementChild) {
- todoList.lastElementChild.classList.add('new-item');
- setTimeout(() => todoList.lastElementChild.classList.remove('new-item'), 500);
+ if (newTodoList && newTodoList.children.length > 0) {
+ // Create a document fragment to build the new list
+ const fragment = document.createDocumentFragment();
+ Array.from(newTodoList.children).forEach(newItem => {
+ const existingItem = todoList.querySelector(`[data-id="${newItem.dataset.id}"]`);
+ if (existingItem) {
+ // Update existing item
+ existingItem.innerHTML = newItem.innerHTML;
+ // Update checkbox state and 'done' class
+ const checkbox = existingItem.querySelector('.todo-checkbox');
+ if (checkbox) {
+ checkbox.checked = newItem.querySelector('.todo-checkbox').checked;
+ existingItem.classList.toggle('done', checkbox.checked);
+ }
+ fragment.appendChild(existingItem);
+ } else {
+ // Add new item
+ const newItemClone = newItem.cloneNode(true);
+ // Set 'done' class based on checkbox state
+ const checkbox = newItemClone.querySelector('.todo-checkbox');
+ if (checkbox) {
+ newItemClone.classList.toggle('done', checkbox.checked);
+ }
+ fragment.appendChild(newItemClone);
+ }
+ });
+
+ // Replace the entire list content
+ todoList.innerHTML = '';
+ todoList.appendChild(fragment);
+
+ // Animate the last item if specified
+ if (animateLastItem && todoList.lastElementChild) {
+ todoList.lastElementChild.classList.add('new-item');
+ setTimeout(() => todoList.lastElementChild.classList.remove('new-item'), 500);
+ }
+ } else {
+ todoList.innerHTML = '
No todos yet. Add a new one!';
}
+
+ // Reattach event listeners for checkboxes
+ attachCheckboxListeners();
+ };
+
+ // Function to attach event listeners to checkboxes
+ const attachCheckboxListeners = () => {
+ document.querySelectorAll('.todo-checkbox').forEach(checkbox => {
+ checkbox.addEventListener('change', (event) => {
+ const listItem = event.target.closest('li');
+ if (event.target.checked) {
+ listItem.classList.add('done');
+ } else {
+ listItem.classList.remove('done');
+ }
+ });
+ });
};
// Function to update todo status
@@ -41,7 +92,8 @@ document.addEventListener('DOMContentLoaded', () => {
body: `task=${encodeURIComponent(task)}&done=false`
});
todoInput.value = '';
- renderTodos();
+ renderTodos(true); // Animate the last item when adding a new todo
+ await updateUndoButtonState();
}
});
@@ -58,6 +110,7 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
todoItem.remove();
}, 500);
+ await updateUndoButtonState();
}
});
@@ -70,6 +123,7 @@ document.addEventListener('DOMContentLoaded', () => {
todoItem.classList.toggle('done', isDone);
await updateTodoStatus(todoId, isDone);
+ await updateUndoButtonState();
}
});
@@ -86,18 +140,96 @@ document.addEventListener('DOMContentLoaded', () => {
darkModeToggle.textContent = '☀️';
}
- // Initial render
- renderTodos();
+ // Clear all todos
+ clearTodosButton.addEventListener('click', async () => {
+ try {
+ const response = await fetch('/clear', { method: 'POST' });
+ if (response.ok) {
+ const result = await response.json();
+ if (result.status === 'success') {
+ console.log('Todos cleared successfully');
+ todoList.innerHTML = ''; // Clear the list in the DOM
+ await renderTodos(); // Re-render the todo list from the server
+ await updateUndoButtonState();
+ } else {
+ console.error('Failed to clear todos');
+ }
+ } else {
+ console.error('Failed to clear todos');
+ }
+ } catch (error) {
+ console.error('Error clearing todos:', error);
+ }
+ });
- // Add event listener for todo checkboxes
- document.querySelectorAll('.todo-checkbox').forEach(checkbox => {
- checkbox.addEventListener('change', (event) => {
- const listItem = event.target.closest('li');
- if (event.target.checked) {
- listItem.classList.add('done');
+ // Undo last action
+ undoButton.addEventListener('click', async () => {
+ console.log('Undo button clicked');
+ try {
+ const response = await fetch('/undo', { method: 'POST' });
+ console.log('Undo response status:', response.status);
+ if (response.ok) {
+ const result = await response.json();
+ console.log('Undo response:', result);
+ if (result.status === 'no_action') {
+ console.log('No action to undo');
+ undoButton.disabled = true;
+ } else if (result.status === 'success') {
+ console.log('Last action undone successfully');
+ console.log('Updated todos:', result.todos);
+ await renderTodos(false); // Re-render todos without animation
+ undoButton.disabled = false; // Enable the button after a successful undo
+ } else {
+ console.error('Failed to undo last action');
+ }
} else {
- listItem.classList.remove('done');
+ console.error('Failed to undo last action, status:', response.status);
}
- });
+ } catch (error) {
+ console.error('Error undoing last action:', error);
+ }
+ });
+
+ // Add this function to check if there are actions to undo
+ async function checkUndoAvailability() {
+ try {
+ const response = await fetch('/last-action');
+ if (response.ok) {
+ const result = await response.json();
+ undoButton.disabled = result.last_action.type === null;
+ }
+ } catch (error) {
+ console.error('Error checking undo availability:', error);
+ }
+ }
+
+ // Call this function after each action that modifies the todo list
+ async function updateUndoButtonState() {
+ await checkUndoAvailability();
+ }
+
+ // Call this function on initial load
+ document.addEventListener('DOMContentLoaded', async () => {
+ // ... (rest of the code)
+ await updateUndoButtonState();
});
+
+ // Helper function to create a todo list item
+ function createTodoElement(todo) {
+ const li = document.createElement('li');
+ li.dataset.id = todo.id;
+ li.innerHTML = `
+
+ ${todo.task}
+
+ `;
+ li.classList.toggle('done', todo.done === 'true');
+ return li;
+ }
+
+ // Initial render
+ renderTodos(false); // Don't animate on initial render
+
+ // Add event listener for todo checkboxes
+ attachCheckboxListeners();
});
\ No newline at end of file
diff --git a/templates/index.html b/templates/index.html
index d93a2ab..9180018 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -14,10 +14,12 @@ My TODOs
+
+
{% for todo in todos %}
-
-
+
{{ todo.task }}
diff --git a/tests/test_main.py b/tests/test_main.py
index d368936..d87a1eb 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,5 +1,6 @@
import sys
import os
+import json
# Add the parent directory to the Python path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -20,6 +21,10 @@ def test_add_todo():
assert response.status_code == 200
assert "Test task" in response.text
+def test_add_empty_todo():
+ response = client.post("/add", data={"task": ""})
+ assert response.status_code == 422 # Unprocessable Entity
+
def test_update_todo():
# First, add a todo
client.post("/add", data={"task": "Update test task"})
@@ -36,6 +41,10 @@ def test_update_todo():
updated_todo = next((todo for todo in response.context["todos"] if todo["id"] == todo_id), None)
assert updated_todo["done"] == "true"
+def test_update_nonexistent_todo():
+ response = client.post("/update/nonexistent-id", data={"done": "true"})
+ assert response.status_code == 404
+
def test_delete_todo():
# First, add a todo
client.post("/add", data={"task": "Delete test task"})
@@ -49,4 +58,91 @@ def test_delete_todo():
# Verify the deletion
response = client.get("/")
- assert not any(todo["id"] == todo_id for todo in response.context["todos"])
\ No newline at end of file
+ assert not any(todo["id"] == todo_id for todo in response.context["todos"])
+
+def test_delete_nonexistent_todo():
+ response = client.post("/delete/nonexistent-id")
+ assert response.status_code == 404
+
+def test_get_todos():
+ # Clear existing todos
+ response = client.get("/")
+ todos = response.context["todos"]
+ for todo in todos:
+ client.post(f"/delete/{todo['id']}")
+
+ # Add some test todos
+ tasks = ["Task 1", "Task 2", "Task 3"]
+ for task in tasks:
+ client.post("/add", data={"task": task})
+
+ # Get todos
+ response = client.get("/")
+ assert response.status_code == 200
+ todos = response.context["todos"]
+ assert len(todos) == len(tasks)
+ for i, todo in enumerate(todos):
+ assert todo["task"] == tasks[i]
+
+def test_static_files():
+ css_response = client.get("/static/styles.css")
+ assert css_response.status_code == 200
+ assert "text/css" in css_response.headers["content-type"]
+
+ js_response = client.get("/static/script.js")
+ assert js_response.status_code == 200
+ # Update this line to accept both MIME types
+ assert any(mime_type in js_response.headers["content-type"] for mime_type in ["application/javascript", "text/javascript", "text/javascript; charset=utf-8"])
+
+def test_clear_todos():
+ # Add some test todos
+ tasks = ["Task 1", "Task 2", "Task 3"]
+ for task in tasks:
+ client.post("/add", data={"task": task})
+
+ # Clear todos
+ response = client.post("/clear")
+ assert response.status_code == 200
+ result = response.json()
+ assert result["status"] == "success"
+ assert len(result["todos"]) == 0
+
+ # Verify that todos are cleared
+ response = client.get("/")
+ assert response.status_code == 200
+ todos = response.context["todos"]
+ assert len(todos) == 0
+
+def test_undo_action():
+ # Clear todos
+ client.post("/clear")
+
+ # Add a todo
+ client.post("/add", data={"task": "Test undo"})
+
+ # Undo the add action
+ response = client.post("/undo")
+ assert response.status_code == 200
+ result = response.json()
+ assert result["status"] == "success"
+ assert len(result["todos"]) == 0
+
+ # Add two todos
+ client.post("/add", data={"task": "Task 1"})
+ client.post("/add", data={"task": "Task 2"})
+
+ # Delete the second todo
+ response = client.get("/")
+ todos = response.context["todos"]
+ todo_id = todos[-1]["id"]
+ client.post(f"/delete/{todo_id}")
+
+ # Undo the delete action
+ response = client.post("/undo")
+ assert response.status_code == 200
+ result = response.json()
+ assert result["status"] == "success"
+ assert len(result["todos"]) == 2
+ assert result["todos"][-1]["task"] == "Task 2"
+
+ response = client.post("/clear")
\ No newline at end of file