-
Notifications
You must be signed in to change notification settings - Fork 0
Session 10 WebApp Final Project
Maximiliano Militzer edited this page Apr 7, 2025
·
1 revision
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.
- 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
Our Todo List application will have the following features:
-
User Authentication:
- User registration and login
- Protected routes for authenticated users
-
Task Management:
- Create, read, update, and delete tasks
- Mark tasks as complete/incomplete
- Set task due dates
- Categorize tasks
- Set task priorities
-
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)
-
Data Persistence:
- Store user and task data in PostgreSQL
- Implement proper database relationships
-
Security:
- Secure API endpoints
- Form validation
- Protection against common web vulnerabilities
# 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# 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# 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']),
]# 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.// 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',
}// 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;// 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;// 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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8 p-8 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Login</h1>
<p className="mt-2 text-gray-600 dark:text-gray-300">Sign in to your account</p>
</div>
{loginError && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<span className="block sm:inline">{loginError}</span>
</div>
)}
<Formik
initialValues={{ username: '', password: '' }}
validationSchema={loginSchema}
onSubmit={handleLogin}
>
{({ isSubmitting }) => (
<Form className="mt-8 space-y-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Username
</label>
<Field
id="username"
name="username"
type="text"
autoComplete="username"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<ErrorMessage name="username" component="div" className="text-red-500 text-sm mt-1" />
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Password
</label>
<Field
id="password"
name="password"
type="password"
autoComplete="current-password"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<ErrorMessage name="password" component="div" className="text-red-500 text-sm mt-1" />
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</div>
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
Don't have an account?{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
Register
</Link>
</div>
</Form>
)}
</Formik>
</div>
</div>
);
}'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 (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900">
<header className="bg-white dark:bg-gray-800 shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex justify-between items-center">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Todo List</h1>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm text-white bg-red-600 rounded hover:bg-red-700"
>
Logout
</button>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg divide-y divide-gray-200 dark:divide-gray-700">
<div className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6">
<div className="mb-4 sm:mb-0">
<h2 className="text-lg font-medium text-gray-900 dark:text-white">Your Tasks</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{filteredTasks.length} tasks ({tasks.filter(t => t.completed).length} completed)
</p>
</div>
<div className="flex space-x-2">
<button
onClick={toggleTaskForm}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700"
>
{showTaskForm ? 'Cancel' : 'Add Task'}
</button>
</div>
</div>
{showTaskForm && <TaskForm onComplete={() => setShowTaskForm(false)} />}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4 space-y-2 sm:space-y-0">
<div className="flex space-x-2">
<button
className={`px-3 py-1 text-sm rounded ${filter === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
onClick={() => setFilter('all')}
>
All
</button>
<button
className={`px-3 py-1 text-sm rounded ${filter === 'active' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
onClick={() => setFilter('active')}
>
Active
</button>
<button
className={`px-3 py-1 text-sm rounded ${filter === 'completed' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}`}
onClick={() => setFilter('completed')}
>
Completed
</button>
</div>
<CategoryFilter
categories={categories}
selectedCategory={categoryFilter}
onChange={setCategoryFilter}
/>
</div>
{isLoading ? (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">Loading tasks...</p>
</div>
) : error ? (
<div className="text-center py-8">
<p className="text-red-500">{error}</p>
<button
onClick={fetchTasks}
className="mt-2 px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700"
>
Retry
</button>
</div>
) : filteredTasks.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500 dark:text-gray-400">No tasks found</p>
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredTasks.map(task => (
<TaskItem
key={task.id}
task={task}
categories={categories}
/>
))}
</div>
)}
</div>
</div>
</main>
</div>
);
}-
Testing the Application:
- Test all CRUD operations
- Test authentication
- Test filtering and sorting
- Test responsive design
-
Deployment:
- Deploy the backend to Render
- Deploy the frontend to Vercel
- Set up environment variables
- Test the deployed application
-
Additional Features (Optional):
- User profile management
- Task sharing
- Email notifications
- Dark/light theme toggle
- Export tasks to CSV/PDF
- Complete the Todo List application by implementing all the features
- Add a registration page
- Implement category management (create, update, delete categories)
- Add search functionality for tasks
- Implement task sorting by different criteria
- Add a user profile page
- Implement data visualization for task completion statistics
- Deploy the completed application
- Next.js Documentation
- Django Ninja Documentation
- Tailwind CSS Documentation
- Zustand Documentation
- Formik Documentation
- Render Deployment Guides
- Vercel Deployment Guides
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, you'll learn how to integrate external APIs into your applications to add even more functionality.