# Session 10: WebApp - Final Project (Make Your Own Todo List) ## Overview Welcome to Session 10! In this session, we'll bring together everything we've learned so far to build a complete Todo List application from scratch. This project will integrate all the components we've covered: a Next.js frontend, Django Ninja backend, PostgreSQL database, and deployment to Vercel and Render. ## Learning Objectives - Apply the skills learned in previous sessions to build a real-world application - Create a functional Todo List with CRUD operations - Implement user authentication - Connect frontend and backend components - Apply good software development practices - Deploy the completed application ## Project Requirements Our Todo List application will have the following features: 1. **User Authentication**: - User registration and login - Protected routes for authenticated users 2. **Task Management**: - Create, read, update, and delete tasks - Mark tasks as complete/incomplete - Set task due dates - Categorize tasks - Set task priorities 3. **User Interface**: - Clean, responsive design using Tailwind CSS - Dark/light mode toggle - Filters for viewing tasks (all, active, completed) - Sort tasks by different criteria (due date, priority) 4. **Data Persistence**: - Store user and task data in PostgreSQL - Implement proper database relationships 5. **Security**: - Secure API endpoints - Form validation - Protection against common web vulnerabilities ## Project Walkthrough ### 1. Project Setup #### Backend Setup: ```bash # Create and activate virtual environment python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate # Install dependencies pip install django django-ninja django-cors-headers psycopg2-binary pyjwt gunicorn # Create a new Django project django-admin startproject todo_backend cd todo_backend # Create a new Django app python manage.py startapp tasks # Create requirements.txt pip freeze > requirements.txt ``` #### Frontend Setup: ```bash # Create a new Next.js app npx create-next-app@latest todo_frontend cd todo_frontend # Install dependencies npm install axios react-icons date-fns formik yup zustand npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p ``` ### 2. Backend Implementation #### Database Models: ```python # tasks/models.py from django.db import models from django.contrib.auth.models import User class Category(models.Model): name = models.CharField(max_length=100) color = models.CharField(max_length=7, default="#000000") # Hex color user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='categories') def __str__(self): return self.name class Meta: verbose_name_plural = "Categories" class Priority(models.Model): LEVELS = ( ('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent') ) level = models.CharField(max_length=10, choices=LEVELS, default='MEDIUM') def __str__(self): return self.level class Meta: verbose_name_plural = "Priorities" class Task(models.Model): title = models.CharField(max_length=200) description = models.TextField(blank=True) completed = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) due_date = models.DateTimeField(null=True, blank=True) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='tasks') category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='tasks') priority = models.ForeignKey(Priority, on_delete=models.SET_DEFAULT, default=2, related_name='tasks') def __str__(self): return self.title class Meta: ordering = ['due_date', 'priority', 'created_at'] indexes = [ models.Index(fields=['user', 'completed']), models.Index(fields=['due_date']), ] ``` #### API Endpoints: ```python # tasks/api.py from ninja import NinjaAPI, Schema from ninja.security import HttpBearer from django.contrib.auth.models import User from django.contrib.auth import authenticate from django.conf import settings from typing import List, Optional from datetime import datetime, timedelta import jwt from .models import Task, Category, Priority # Authentication class AuthBearer(HttpBearer): def authenticate(self, request, token): try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) user_id = payload.get("user_id") user = User.objects.get(id=user_id) request.user = user return user except: return None api = NinjaAPI(auth=AuthBearer()) # Schemas class TokenSchema(Schema): access_token: str token_type: str = "bearer" class LoginSchema(Schema): username: str password: str class RegisterSchema(Schema): username: str email: str password: str class TaskSchema(Schema): id: int title: str description: Optional[str] = None completed: bool due_date: Optional[datetime] = None category_id: Optional[int] = None priority_id: Optional[int] = None created_at: datetime class TaskCreateSchema(Schema): title: str description: Optional[str] = None due_date: Optional[datetime] = None category_id: Optional[int] = None priority_id: Optional[int] = None class CategorySchema(Schema): id: int name: str color: str class CategoryCreateSchema(Schema): name: str color: str = "#000000" class PrioritySchema(Schema): id: int level: str # Authentication endpoints @api.post("/token", auth=None, response=TokenSchema) def login(request, credentials: LoginSchema): user = authenticate(username=credentials.username, password=credentials.password) if user is None: return api.create_response(request, {"detail": "Invalid credentials"}, status=401) token_expiry = datetime.utcnow() + timedelta(days=1) access_token = jwt.encode( {"user_id": user.id, "exp": token_expiry}, settings.SECRET_KEY, algorithm="HS256" ) return {"access_token": access_token} @api.post("/register", auth=None, response=TokenSchema) def register(request, user_data: RegisterSchema): if User.objects.filter(username=user_data.username).exists(): return api.create_response(request, {"detail": "Username already exists"}, status=400) if User.objects.filter(email=user_data.email).exists(): return api.create_response(request, {"detail": "Email already exists"}, status=400) user = User.objects.create_user( username=user_data.username, email=user_data.email, password=user_data.password ) # Create default categories for the new user Category.objects.create(name="Work", color="#FF5733", user=user) Category.objects.create(name="Personal", color="#33FF57", user=user) Category.objects.create(name="Shopping", color="#3357FF", user=user) token_expiry = datetime.utcnow() + timedelta(days=1) access_token = jwt.encode( {"user_id": user.id, "exp": token_expiry}, settings.SECRET_KEY, algorithm="HS256" ) return {"access_token": access_token} # Task endpoints @api.get("/tasks", response=List[TaskSchema]) def list_tasks(request): return Task.objects.filter(user=request.user) @api.post("/tasks", response=TaskSchema) def create_task(request, payload: TaskCreateSchema): task_data = payload.dict() # Handle foreign keys if task_data.get('category_id'): try: category = Category.objects.get(id=task_data['category_id'], user=request.user) except Category.DoesNotExist: return api.create_response(request, {"detail": "Category not found"}, status=404) task_data['category'] = category del task_data['category_id'] if task_data.get('priority_id'): try: priority = Priority.objects.get(id=task_data['priority_id']) except Priority.DoesNotExist: return api.create_response(request, {"detail": "Priority not found"}, status=404) task_data['priority'] = priority del task_data['priority_id'] task = Task.objects.create( user=request.user, **task_data ) return task @api.get("/tasks/{task_id}", response=TaskSchema) def get_task(request, task_id: int): try: return Task.objects.get(id=task_id, user=request.user) except Task.DoesNotExist: return api.create_response(request, {"detail": "Task not found"}, status=404) @api.put("/tasks/{task_id}", response=TaskSchema) def update_task(request, task_id: int, payload: TaskCreateSchema): try: task = Task.objects.get(id=task_id, user=request.user) except Task.DoesNotExist: return api.create_response(request, {"detail": "Task not found"}, status=404) task_data = payload.dict() # Handle foreign keys if task_data.get('category_id'): try: category = Category.objects.get(id=task_data['category_id'], user=request.user) task.category = category except Category.DoesNotExist: return api.create_response(request, {"detail": "Category not found"}, status=404) del task_data['category_id'] if task_data.get('priority_id'): try: priority = Priority.objects.get(id=task_data['priority_id']) task.priority = priority except Priority.DoesNotExist: return api.create_response(request, {"detail": "Priority not found"}, status=404) del task_data['priority_id'] # Update other fields for key, value in task_data.items(): setattr(task, key, value) task.save() return task @api.patch("/tasks/{task_id}/complete", response=TaskSchema) def toggle_task_completion(request, task_id: int): try: task = Task.objects.get(id=task_id, user=request.user) except Task.DoesNotExist: return api.create_response(request, {"detail": "Task not found"}, status=404) task.completed = not task.completed task.save() return task @api.delete("/tasks/{task_id}") def delete_task(request, task_id: int): try: task = Task.objects.get(id=task_id, user=request.user) except Task.DoesNotExist: return api.create_response(request, {"detail": "Task not found"}, status=404) task.delete() return {"success": True} # Category endpoints @api.get("/categories", response=List[CategorySchema]) def list_categories(request): return Category.objects.filter(user=request.user) @api.post("/categories", response=CategorySchema) def create_category(request, payload: CategoryCreateSchema): category = Category.objects.create( user=request.user, **payload.dict() ) return category # Add additional endpoints for priorities, user details, etc. ``` ### 3. Frontend Implementation #### Configure Tailwind CSS: ```js // tailwind.config.js module.exports = { content: [ "./app/**/*.{js,ts,jsx,tsx}", "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], darkMode: 'class', } ``` #### Authentication State Management with Zustand: ```jsx // src/store/authStore.js import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import axios from 'axios'; const useAuthStore = create( persist( (set) => ({ token: null, user: null, isAuthenticated: false, login: async (username, password) => { try { const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/token`, { username, password }); const { access_token } = response.data; // Set up axios defaults with token axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`; // Update store set({ token: access_token, isAuthenticated: true, }); return { success: true }; } catch (error) { return { success: false, error: error.response?.data?.detail || 'Login failed' }; } }, register: async (username, email, password) => { try { const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/register`, { username, email, password }); const { access_token } = response.data; // Set up axios defaults with token axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`; // Update store set({ token: access_token, isAuthenticated: true, }); return { success: true }; } catch (error) { return { success: false, error: error.response?.data?.detail || 'Registration failed' }; } }, logout: () => { // Clear axios defaults delete axios.defaults.headers.common['Authorization']; // Update store set({ token: null, user: null, isAuthenticated: false, }); }, // Initialize axios with token from storage initializeAuth: () => { const state = useAuthStore.getState(); if (state.token) { axios.defaults.headers.common['Authorization'] = `Bearer ${state.token}`; } } }), { name: 'auth-storage', // name for localStorage } ) ); export default useAuthStore; ``` #### Task State Management: ```jsx // src/store/taskStore.js import { create } from 'zustand'; import axios from 'axios'; const useTaskStore = create((set, get) => ({ tasks: [], categories: [], priorities: [], isLoading: false, error: null, fetchTasks: async () => { set({ isLoading: true, error: null }); try { const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/tasks`); set({ tasks: response.data, isLoading: false }); } catch (error) { set({ error: error.response?.data?.detail || 'Failed to fetch tasks', isLoading: false }); } }, createTask: async (taskData) => { set({ isLoading: true, error: null }); try { const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/tasks`, taskData); set({ tasks: [...get().tasks, response.data], isLoading: false }); return { success: true, task: response.data }; } catch (error) { set({ error: error.response?.data?.detail || 'Failed to create task', isLoading: false }); return { success: false, error: error.response?.data?.detail || 'Failed to create task' }; } }, updateTask: async (taskId, taskData) => { set({ isLoading: true, error: null }); try { const response = await axios.put(`${process.env.NEXT_PUBLIC_API_URL}/tasks/${taskId}`, taskData); set({ tasks: get().tasks.map(task => task.id === taskId ? response.data : task ), isLoading: false }); return { success: true, task: response.data }; } catch (error) { set({ error: error.response?.data?.detail || 'Failed to update task', isLoading: false }); return { success: false, error: error.response?.data?.detail || 'Failed to update task' }; } }, toggleTaskCompletion: async (taskId) => { try { const response = await axios.patch(`${process.env.NEXT_PUBLIC_API_URL}/tasks/${taskId}/complete`); set({ tasks: get().tasks.map(task => task.id === taskId ? response.data : task ) }); return { success: true, task: response.data }; } catch (error) { return { success: false, error: error.response?.data?.detail || 'Failed to toggle task' }; } }, deleteTask: async (taskId) => { try { await axios.delete(`${process.env.NEXT_PUBLIC_API_URL}/tasks/${taskId}`); set({ tasks: get().tasks.filter(task => task.id !== taskId) }); return { success: true }; } catch (error) { return { success: false, error: error.response?.data?.detail || 'Failed to delete task' }; } }, fetchCategories: async () => { try { const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/categories`); set({ categories: response.data }); return { success: true, categories: response.data }; } catch (error) { return { success: false, error: error.response?.data?.detail || 'Failed to fetch categories' }; } }, // Add more methods as needed })); export default useTaskStore; ``` #### Login Page Component: ```jsx // src/app/login/page.js 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { Formik, Form, Field, ErrorMessage } from 'formik'; import * as Yup from 'yup'; import useAuthStore from '@/store/authStore'; const loginSchema = Yup.object().shape({ username: Yup.string().required('Username is required'), password: Yup.string().required('Password is required'), }); export default function LoginPage() { const router = useRouter(); const login = useAuthStore(state => state.login); const [loginError, setLoginError] = useState(''); const handleLogin = async (values, { setSubmitting }) => { setLoginError(''); const result = await login(values.username, values.password); if (result.success) { router.push('/tasks'); } else { setLoginError(result.error); } setSubmitting(false); }; return (

Login

Sign in to your account

{loginError && (
{loginError}
)} {({ isSubmitting }) => (
Don't have an account?{' '} Register
)}
); } ``` #### Tasks Page Component: ```jsx 'use client'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { format } from 'date-fns'; import useAuthStore from '@/store/authStore'; import useTaskStore from '@/store/taskStore'; import TaskItem from '@/components/TaskItem'; import TaskForm from '@/components/TaskForm'; import CategoryFilter from '@/components/CategoryFilter'; export default function TasksPage() { const router = useRouter(); const isAuthenticated = useAuthStore(state => state.isAuthenticated); const logout = useAuthStore(state => state.logout); const { tasks, fetchTasks, categories, fetchCategories, isLoading, error } = useTaskStore(); const [showTaskForm, setShowTaskForm] = useState(false); const [filter, setFilter] = useState('all'); const [categoryFilter, setCategoryFilter] = useState(null); useEffect(() => { if (!isAuthenticated) { router.push('/login'); return; } fetchTasks(); fetchCategories(); }, [isAuthenticated, router, fetchTasks, fetchCategories]); const handleLogout = () => { logout(); router.push('/login'); }; const toggleTaskForm = () => { setShowTaskForm(!showTaskForm); }; const filteredTasks = tasks.filter(task => { if (filter === 'active' && task.completed) return false; if (filter === 'completed' && !task.completed) return false; if (categoryFilter && task.category_id !== categoryFilter) return false; return true; }); return (

Todo List

Your Tasks

{filteredTasks.length} tasks ({tasks.filter(t => t.completed).length} completed)

{showTaskForm && setShowTaskForm(false)} />}
{isLoading ? (

Loading tasks...

) : error ? (

{error}

) : filteredTasks.length === 0 ? (

No tasks found

) : (
{filteredTasks.map(task => ( ))}
)}
); } ``` ### 4. Final Steps 1. **Testing the Application**: - Test all CRUD operations - Test authentication - Test filtering and sorting - Test responsive design 2. **Deployment**: - Deploy the backend to Render - Deploy the frontend to Vercel - Set up environment variables - Test the deployed application 3. **Additional Features (Optional)**: - User profile management - Task sharing - Email notifications - Dark/light theme toggle - Export tasks to CSV/PDF ## Practice Exercises 1. Complete the Todo List application by implementing all the features 2. Add a registration page 3. Implement category management (create, update, delete categories) 4. Add search functionality for tasks 5. Implement task sorting by different criteria 6. Add a user profile page 7. Implement data visualization for task completion statistics 8. Deploy the completed application ## Additional Resources - [Next.js Documentation](https://nextjs.org/docs) - [Django Ninja Documentation](https://django-ninja.rest-framework.com/) - [Tailwind CSS Documentation](https://tailwindcss.com/docs) - [Zustand Documentation](https://github.com/pmndrs/zustand) - [Formik Documentation](https://formik.org/docs/overview) - [Render Deployment Guides](https://render.com/docs) - [Vercel Deployment Guides](https://vercel.com/docs) ## Next Steps Congratulations on completing your Todo List application! Now, let's move on to Week 3: The MVP. In [Session 11: Connect to the Real World](Session-11-External-APIs.md), you'll learn how to integrate external APIs into your applications to add even more functionality.