Claude Code - Backend Implementation Specialist Claude Sonnet 4.5 commited on
Commit ·
1941764
1
Parent(s): 3948ca6
Add complete FastAPI Todo application with Docker support
Browse files- Add FastAPI backend with JWT authentication
- Add task and subtask management APIs
- Add password reset functionality with email support
- Add Docker and docker-compose configuration
- Add Alembic migrations for database schema
- Add comprehensive API documentation
- Configure for Hugging Face Spaces deployment on port 7860
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +72 -0
- .env.example +44 -0
- .gitignore +12 -0
- DOCKER_SETUP.md +178 -0
- Dockerfile +25 -0
- README.md +48 -5
- alembic.ini +114 -0
- alembic/env.py +85 -0
- alembic/script.py.mako +24 -0
- alembic/versions/001_initial_schema.py +52 -0
- alembic/versions/002_add_password_reset_tokens.py +40 -0
- alembic/versions/a6878af5b66f_add_category_and_due_date_to_tasks.py +30 -0
- api/index.py +19 -0
- api/test.py +45 -0
- docker-compose.yml +55 -0
- init_db.py +9 -0
- migrate_db.py +36 -0
- requirements.txt +13 -0
- src/__pycache__/database.cpython-314.pyc +0 -0
- src/__pycache__/main.cpython-314.pyc +0 -0
- src/api/__init__.py +1 -0
- src/api/__pycache__/__init__.cpython-314.pyc +0 -0
- src/api/__pycache__/auth.cpython-314.pyc +0 -0
- src/api/__pycache__/password_reset.cpython-314.pyc +0 -0
- src/api/__pycache__/subtasks.cpython-314.pyc +0 -0
- src/api/__pycache__/tasks.cpython-314.pyc +0 -0
- src/api/ai.py +228 -0
- src/api/auth.py +155 -0
- src/api/password_reset.py +233 -0
- src/api/subtasks.py +230 -0
- src/api/tasks.py +278 -0
- src/database.py +59 -0
- src/main.py +68 -0
- src/main_minimal.py +21 -0
- src/middleware/__pycache__/jwt_auth.cpython-314.pyc +0 -0
- src/middleware/jwt_auth.py +86 -0
- src/models/__init__.py +6 -0
- src/models/__pycache__/__init__.cpython-314.pyc +0 -0
- src/models/__pycache__/password_reset.cpython-314.pyc +0 -0
- src/models/__pycache__/subtask.cpython-314.pyc +0 -0
- src/models/__pycache__/task.cpython-314.pyc +0 -0
- src/models/__pycache__/user.cpython-314.pyc +0 -0
- src/models/password_reset.py +30 -0
- src/models/subtask.py +32 -0
- src/models/task.py +49 -0
- src/models/user.py +27 -0
- src/services/__init__.py +1 -0
- src/services/__pycache__/__init__.cpython-314.pyc +0 -0
- src/services/__pycache__/auth.cpython-314.pyc +0 -0
- src/services/__pycache__/email.cpython-314.pyc +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
env/
|
| 8 |
+
venv/
|
| 9 |
+
ENV/
|
| 10 |
+
build/
|
| 11 |
+
develop-eggs/
|
| 12 |
+
dist/
|
| 13 |
+
downloads/
|
| 14 |
+
eggs/
|
| 15 |
+
.eggs/
|
| 16 |
+
lib/
|
| 17 |
+
lib64/
|
| 18 |
+
parts/
|
| 19 |
+
sdist/
|
| 20 |
+
var/
|
| 21 |
+
wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
+
|
| 26 |
+
# Environment variables
|
| 27 |
+
.env
|
| 28 |
+
.env.local
|
| 29 |
+
|
| 30 |
+
# Database
|
| 31 |
+
*.db
|
| 32 |
+
*.sqlite
|
| 33 |
+
*.sqlite3
|
| 34 |
+
|
| 35 |
+
# IDE
|
| 36 |
+
.vscode/
|
| 37 |
+
.idea/
|
| 38 |
+
*.swp
|
| 39 |
+
*.swo
|
| 40 |
+
*~
|
| 41 |
+
|
| 42 |
+
# Git
|
| 43 |
+
.git/
|
| 44 |
+
.gitignore
|
| 45 |
+
|
| 46 |
+
# Testing
|
| 47 |
+
.pytest_cache/
|
| 48 |
+
.coverage
|
| 49 |
+
htmlcov/
|
| 50 |
+
.tox/
|
| 51 |
+
|
| 52 |
+
# Documentation
|
| 53 |
+
*.md
|
| 54 |
+
docs/
|
| 55 |
+
|
| 56 |
+
# Logs
|
| 57 |
+
*.log
|
| 58 |
+
|
| 59 |
+
# OS
|
| 60 |
+
.DS_Store
|
| 61 |
+
Thumbs.db
|
| 62 |
+
|
| 63 |
+
# Alembic
|
| 64 |
+
alembic/versions/*.pyc
|
| 65 |
+
|
| 66 |
+
# Vercel
|
| 67 |
+
.vercel/
|
| 68 |
+
vercel.json
|
| 69 |
+
|
| 70 |
+
# Test files
|
| 71 |
+
test_*.py
|
| 72 |
+
*_test.py
|
.env.example
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Environment Variables
|
| 2 |
+
# Copy this file to .env and fill in your values
|
| 3 |
+
|
| 4 |
+
# Database Configuration
|
| 5 |
+
DATABASE_URL=sqlite:///./todo.db
|
| 6 |
+
# For production, use PostgreSQL:
|
| 7 |
+
# DATABASE_URL=postgresql://user:password@host:5432/database
|
| 8 |
+
|
| 9 |
+
# JWT Configuration
|
| 10 |
+
JWT_SECRET_KEY=your-super-secret-key-change-this-min-32-characters-long
|
| 11 |
+
JWT_ALGORITHM=HS256
|
| 12 |
+
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
| 13 |
+
|
| 14 |
+
# CORS Configuration
|
| 15 |
+
CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002
|
| 16 |
+
# For production, add your frontend URL:
|
| 17 |
+
# CORS_ORIGINS=https://your-frontend-url.com,http://localhost:3000
|
| 18 |
+
|
| 19 |
+
# Gmail SMTP Configuration (for password reset emails)
|
| 20 |
+
# To get app-specific password:
|
| 21 |
+
# 1. Enable 2-Factor Authentication on your Gmail account
|
| 22 |
+
# 2. Go to Google Account → Security → 2-Step Verification → App passwords
|
| 23 |
+
# 3. Select "Mail" and "Other (Custom name)"
|
| 24 |
+
# 4. Copy the 16-character password
|
| 25 |
+
SMTP_HOST=smtp.gmail.com
|
| 26 |
+
SMTP_PORT=587
|
| 27 |
+
SMTP_USERNAME=your_email@gmail.com
|
| 28 |
+
SMTP_PASSWORD=your_app_specific_password_here
|
| 29 |
+
SMTP_USE_TLS=true
|
| 30 |
+
EMAIL_FROM=your_email@gmail.com
|
| 31 |
+
EMAIL_FROM_NAME=Todo Application
|
| 32 |
+
|
| 33 |
+
# Frontend URL (for password reset links)
|
| 34 |
+
FRONTEND_URL=http://localhost:3000
|
| 35 |
+
# For production:
|
| 36 |
+
# FRONTEND_URL=https://your-frontend-url.com
|
| 37 |
+
|
| 38 |
+
# Password Reset Configuration
|
| 39 |
+
PASSWORD_RESET_TOKEN_EXPIRY_MINUTES=15
|
| 40 |
+
PASSWORD_RESET_MAX_REQUESTS_PER_HOUR=3
|
| 41 |
+
|
| 42 |
+
# Cohere AI API Configuration
|
| 43 |
+
# Get your API key from: https://dashboard.cohere.com/api-keys
|
| 44 |
+
COHERE_API_KEY=your-cohere-api-key-here
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.vercel
|
| 2 |
+
.env*.local
|
| 3 |
+
|
| 4 |
+
# Test data and tokens
|
| 5 |
+
*.token.json
|
| 6 |
+
*_token.json
|
| 7 |
+
token.txt
|
| 8 |
+
*_test.json
|
| 9 |
+
*_response.json
|
| 10 |
+
signin_*.json
|
| 11 |
+
signup_*.json
|
| 12 |
+
fresh_token.json
|
DOCKER_SETUP.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker Setup Guide
|
| 2 |
+
|
| 3 |
+
## Quick Start
|
| 4 |
+
|
| 5 |
+
### 1. Build and Run with Docker Compose (Recommended)
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# Start all services (API + PostgreSQL)
|
| 9 |
+
docker-compose up -d
|
| 10 |
+
|
| 11 |
+
# View logs
|
| 12 |
+
docker-compose logs -f
|
| 13 |
+
|
| 14 |
+
# Stop services
|
| 15 |
+
docker-compose down
|
| 16 |
+
|
| 17 |
+
# Stop and remove volumes (clears database)
|
| 18 |
+
docker-compose down -v
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
### 2. Build and Run Dockerfile Only
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
# Build the image
|
| 25 |
+
docker build -t todo-api .
|
| 26 |
+
|
| 27 |
+
# Run the container
|
| 28 |
+
docker run -p 8000:8000 --env-file .env todo-api
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Configuration
|
| 32 |
+
|
| 33 |
+
### Environment Variables
|
| 34 |
+
|
| 35 |
+
Create a `.env` file in the project root with your configuration:
|
| 36 |
+
|
| 37 |
+
```env
|
| 38 |
+
# Required for docker-compose
|
| 39 |
+
JWT_SECRET_KEY=your-super-secret-key-min-32-chars
|
| 40 |
+
SMTP_USERNAME=your_email@gmail.com
|
| 41 |
+
SMTP_PASSWORD=your_app_password
|
| 42 |
+
EMAIL_FROM=your_email@gmail.com
|
| 43 |
+
COHERE_API_KEY=your-cohere-key
|
| 44 |
+
|
| 45 |
+
# Optional overrides
|
| 46 |
+
FRONTEND_URL=http://localhost:3000
|
| 47 |
+
EMAIL_FROM_NAME=Todo Application
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
## Accessing the Application
|
| 51 |
+
|
| 52 |
+
Once running:
|
| 53 |
+
- API: http://localhost:8000
|
| 54 |
+
- API Docs: http://localhost:8000/docs
|
| 55 |
+
- Health Check: http://localhost:8000/health
|
| 56 |
+
- PostgreSQL: localhost:5432
|
| 57 |
+
|
| 58 |
+
## Database Management
|
| 59 |
+
|
| 60 |
+
### Run Migrations
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
# Access the API container
|
| 64 |
+
docker-compose exec api bash
|
| 65 |
+
|
| 66 |
+
# Run migrations (if using Alembic)
|
| 67 |
+
alembic upgrade head
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### Access PostgreSQL
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
# Connect to database
|
| 74 |
+
docker-compose exec db psql -U todouser -d tododb
|
| 75 |
+
|
| 76 |
+
# Backup database
|
| 77 |
+
docker-compose exec db pg_dump -U todouser tododb > backup.sql
|
| 78 |
+
|
| 79 |
+
# Restore database
|
| 80 |
+
docker-compose exec -T db psql -U todouser tododb < backup.sql
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
## Development Workflow
|
| 84 |
+
|
| 85 |
+
### Hot Reload (Development)
|
| 86 |
+
|
| 87 |
+
The docker-compose.yml mounts `./src` as a volume, so code changes are reflected immediately.
|
| 88 |
+
|
| 89 |
+
### View Logs
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
# All services
|
| 93 |
+
docker-compose logs -f
|
| 94 |
+
|
| 95 |
+
# Specific service
|
| 96 |
+
docker-compose logs -f api
|
| 97 |
+
docker-compose logs -f db
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### Rebuild After Dependency Changes
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
# Rebuild and restart
|
| 104 |
+
docker-compose up -d --build
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
## Production Deployment
|
| 108 |
+
|
| 109 |
+
### Build Production Image
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
docker build -t todo-api:production .
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
### Security Considerations
|
| 116 |
+
|
| 117 |
+
1. Change default PostgreSQL credentials in docker-compose.yml
|
| 118 |
+
2. Use strong JWT_SECRET_KEY
|
| 119 |
+
3. Enable HTTPS in production
|
| 120 |
+
4. Use secrets management (Docker Secrets, Kubernetes Secrets)
|
| 121 |
+
5. Regular security updates: `docker-compose pull && docker-compose up -d`
|
| 122 |
+
|
| 123 |
+
### Deploy to Cloud
|
| 124 |
+
|
| 125 |
+
**Docker Hub:**
|
| 126 |
+
```bash
|
| 127 |
+
docker tag todo-api:production yourusername/todo-api:latest
|
| 128 |
+
docker push yourusername/todo-api:latest
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
**AWS ECS, Google Cloud Run, Azure Container Instances:**
|
| 132 |
+
- Use the Dockerfile to build and deploy
|
| 133 |
+
- Set environment variables in the cloud platform
|
| 134 |
+
- Use managed PostgreSQL services (RDS, Cloud SQL, Azure Database)
|
| 135 |
+
|
| 136 |
+
## Troubleshooting
|
| 137 |
+
|
| 138 |
+
### Container won't start
|
| 139 |
+
```bash
|
| 140 |
+
# Check logs
|
| 141 |
+
docker-compose logs api
|
| 142 |
+
|
| 143 |
+
# Check if port is already in use
|
| 144 |
+
netstat -ano | findstr :8000 # Windows
|
| 145 |
+
lsof -i :8000 # Linux/Mac
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
### Database connection issues
|
| 149 |
+
```bash
|
| 150 |
+
# Verify database is healthy
|
| 151 |
+
docker-compose ps
|
| 152 |
+
|
| 153 |
+
# Test connection
|
| 154 |
+
docker-compose exec api python -c "from src.database import engine; print(engine.url)"
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
### Reset everything
|
| 158 |
+
```bash
|
| 159 |
+
# Stop and remove all containers, networks, and volumes
|
| 160 |
+
docker-compose down -v
|
| 161 |
+
docker-compose up -d --build
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
## Image Size Optimization
|
| 165 |
+
|
| 166 |
+
Current Dockerfile uses multi-stage builds for smaller image size:
|
| 167 |
+
- Builder stage: ~1GB (includes build tools)
|
| 168 |
+
- Final stage: ~200-300MB (runtime only)
|
| 169 |
+
|
| 170 |
+
## Health Checks
|
| 171 |
+
|
| 172 |
+
The Dockerfile includes a health check that pings `/health` endpoint every 30 seconds.
|
| 173 |
+
|
| 174 |
+
Check container health:
|
| 175 |
+
```bash
|
| 176 |
+
docker ps
|
| 177 |
+
# Look for "healthy" status
|
| 178 |
+
```
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy requirements first for better caching
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
|
| 10 |
+
# Install Python dependencies
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
# Copy application code
|
| 14 |
+
COPY src ./src
|
| 15 |
+
COPY api ./api
|
| 16 |
+
COPY alembic ./alembic
|
| 17 |
+
COPY alembic.ini .
|
| 18 |
+
COPY init_db.py .
|
| 19 |
+
COPY .env.example .env
|
| 20 |
+
|
| 21 |
+
# Expose port
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
# Run the application
|
| 25 |
+
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,53 @@
|
|
| 1 |
---
|
| 2 |
-
title: Todo Web
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Todo Web Application
|
| 3 |
+
emoji: ✅
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Todo Web Application
|
| 13 |
+
|
| 14 |
+
A full-featured Todo application with JWT authentication, task management, and AI-powered features.
|
| 15 |
+
|
| 16 |
+
## Features
|
| 17 |
+
|
| 18 |
+
- 🔐 User authentication with JWT
|
| 19 |
+
- ✅ Create, read, update, delete tasks
|
| 20 |
+
- 📝 Subtasks support
|
| 21 |
+
- 🔄 Task status management
|
| 22 |
+
- 🎨 Modern REST API
|
| 23 |
+
- 📧 Password reset functionality
|
| 24 |
+
|
| 25 |
+
## API Documentation
|
| 26 |
+
|
| 27 |
+
Once deployed, visit `/docs` for interactive API documentation.
|
| 28 |
+
|
| 29 |
+
## Tech Stack
|
| 30 |
+
|
| 31 |
+
- FastAPI
|
| 32 |
+
- SQLModel
|
| 33 |
+
- SQLite
|
| 34 |
+
- JWT Authentication
|
| 35 |
+
- Python 3.11
|
| 36 |
+
|
| 37 |
+
## Endpoints
|
| 38 |
+
|
| 39 |
+
- `GET /` - API information
|
| 40 |
+
- `GET /health` - Health check
|
| 41 |
+
- `GET /docs` - API documentation
|
| 42 |
+
- `POST /api/auth/signup` - User registration
|
| 43 |
+
- `POST /api/auth/login` - User login
|
| 44 |
+
- `GET /api/tasks` - Get all tasks
|
| 45 |
+
- `POST /api/tasks` - Create new task
|
| 46 |
+
- And more...
|
| 47 |
+
|
| 48 |
+
## Local Development
|
| 49 |
+
|
| 50 |
+
```bash
|
| 51 |
+
pip install -r requirements.txt
|
| 52 |
+
uvicorn src.main:app --reload
|
| 53 |
+
```
|
alembic.ini
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts
|
| 5 |
+
script_location = alembic
|
| 6 |
+
|
| 7 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 8 |
+
# Uncomment the line below if you want the files to be prepended with date and time
|
| 9 |
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
| 10 |
+
|
| 11 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 12 |
+
# defaults to the current working directory.
|
| 13 |
+
prepend_sys_path = .
|
| 14 |
+
|
| 15 |
+
# timezone to use when rendering the date within the migration file
|
| 16 |
+
# as well as the filename.
|
| 17 |
+
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
| 18 |
+
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
| 19 |
+
# string value is passed to ZoneInfo()
|
| 20 |
+
# leave blank for localtime
|
| 21 |
+
# timezone =
|
| 22 |
+
|
| 23 |
+
# max length of characters to apply to the
|
| 24 |
+
# "slug" field
|
| 25 |
+
# truncate_slug_length = 40
|
| 26 |
+
|
| 27 |
+
# set to 'true' to run the environment during
|
| 28 |
+
# the 'revision' command, regardless of autogenerate
|
| 29 |
+
# revision_environment = false
|
| 30 |
+
|
| 31 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 32 |
+
# a source .py file to be detected as revisions in the
|
| 33 |
+
# versions/ directory
|
| 34 |
+
# sourceless = false
|
| 35 |
+
|
| 36 |
+
# version location specification; This defaults
|
| 37 |
+
# to alembic/versions. When using multiple version
|
| 38 |
+
# directories, initial revisions must be specified with --version-path.
|
| 39 |
+
# The path separator used here should be the separator specified by "version_path_separator" below.
|
| 40 |
+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
| 41 |
+
|
| 42 |
+
# version path separator; As mentioned above, this is the character used to split
|
| 43 |
+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
| 44 |
+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
| 45 |
+
# Valid values for version_path_separator are:
|
| 46 |
+
#
|
| 47 |
+
# version_path_separator = :
|
| 48 |
+
# version_path_separator = ;
|
| 49 |
+
# version_path_separator = space
|
| 50 |
+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
| 51 |
+
|
| 52 |
+
# set to 'true' to search source files recursively
|
| 53 |
+
# in each "version_locations" directory
|
| 54 |
+
# new in Alembic version 1.10
|
| 55 |
+
# recursive_version_locations = false
|
| 56 |
+
|
| 57 |
+
# the output encoding used when revision files
|
| 58 |
+
# are written from script.py.mako
|
| 59 |
+
# output_encoding = utf-8
|
| 60 |
+
|
| 61 |
+
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/todo_db
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
[post_write_hooks]
|
| 65 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 66 |
+
# on newly generated revision scripts. See the documentation for further
|
| 67 |
+
# detail and examples
|
| 68 |
+
|
| 69 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 70 |
+
# hooks = black
|
| 71 |
+
# black.type = console_scripts
|
| 72 |
+
# black.entrypoint = black
|
| 73 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 74 |
+
|
| 75 |
+
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
| 76 |
+
# hooks = ruff
|
| 77 |
+
# ruff.type = exec
|
| 78 |
+
# ruff.executable = %(here)s/.venv/bin/ruff
|
| 79 |
+
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
| 80 |
+
|
| 81 |
+
# Logging configuration
|
| 82 |
+
[loggers]
|
| 83 |
+
keys = root,sqlalchemy,alembic
|
| 84 |
+
|
| 85 |
+
[handlers]
|
| 86 |
+
keys = console
|
| 87 |
+
|
| 88 |
+
[formatters]
|
| 89 |
+
keys = generic
|
| 90 |
+
|
| 91 |
+
[logger_root]
|
| 92 |
+
level = WARN
|
| 93 |
+
handlers = console
|
| 94 |
+
qualname =
|
| 95 |
+
|
| 96 |
+
[logger_sqlalchemy]
|
| 97 |
+
level = WARN
|
| 98 |
+
handlers =
|
| 99 |
+
qualname = sqlalchemy.engine
|
| 100 |
+
|
| 101 |
+
[logger_alembic]
|
| 102 |
+
level = INFO
|
| 103 |
+
handlers =
|
| 104 |
+
qualname = alembic
|
| 105 |
+
|
| 106 |
+
[handler_console]
|
| 107 |
+
class = StreamHandler
|
| 108 |
+
args = (sys.stderr,)
|
| 109 |
+
level = NOTSET
|
| 110 |
+
formatter = generic
|
| 111 |
+
|
| 112 |
+
[formatter_generic]
|
| 113 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 114 |
+
datefmt = %H:%M:%S
|
alembic/env.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import engine_from_config
|
| 4 |
+
from sqlalchemy import pool
|
| 5 |
+
|
| 6 |
+
from alembic import context
|
| 7 |
+
|
| 8 |
+
# Import your models here
|
| 9 |
+
import sys
|
| 10 |
+
import os
|
| 11 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 12 |
+
|
| 13 |
+
from src.models.user import User
|
| 14 |
+
from src.models.task import Task
|
| 15 |
+
from sqlmodel import SQLModel
|
| 16 |
+
|
| 17 |
+
# this is the Alembic Config object, which provides
|
| 18 |
+
# access to the values within the .ini file in use.
|
| 19 |
+
config = context.config
|
| 20 |
+
|
| 21 |
+
# Interpret the config file for Python logging.
|
| 22 |
+
# This line sets up loggers basically.
|
| 23 |
+
if config.config_file_name is not None:
|
| 24 |
+
fileConfig(config.config_file_name)
|
| 25 |
+
|
| 26 |
+
# add your model's MetaData object here
|
| 27 |
+
# for 'autogenerate' support
|
| 28 |
+
target_metadata = SQLModel.metadata
|
| 29 |
+
|
| 30 |
+
# other values from the config, defined by the needs of env.py,
|
| 31 |
+
# can be acquired:
|
| 32 |
+
# my_important_option = config.get_main_option("my_important_option")
|
| 33 |
+
# ... etc.
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def run_migrations_offline() -> None:
|
| 37 |
+
"""Run migrations in 'offline' mode.
|
| 38 |
+
|
| 39 |
+
This configures the context with just a URL
|
| 40 |
+
and not an Engine, though an Engine is acceptable
|
| 41 |
+
here as well. By skipping the Engine creation
|
| 42 |
+
we don't even need a DBAPI to be available.
|
| 43 |
+
|
| 44 |
+
Calls to context.execute() here emit the given string to the
|
| 45 |
+
script output.
|
| 46 |
+
|
| 47 |
+
"""
|
| 48 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 49 |
+
context.configure(
|
| 50 |
+
url=url,
|
| 51 |
+
target_metadata=target_metadata,
|
| 52 |
+
literal_binds=True,
|
| 53 |
+
dialect_opts={"paramstyle": "named"},
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
with context.begin_transaction():
|
| 57 |
+
context.run_migrations()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def run_migrations_online() -> None:
|
| 61 |
+
"""Run migrations in 'online' mode.
|
| 62 |
+
|
| 63 |
+
In this scenario we need to create an Engine
|
| 64 |
+
and associate a connection with the context.
|
| 65 |
+
|
| 66 |
+
"""
|
| 67 |
+
connectable = engine_from_config(
|
| 68 |
+
config.get_section(config.config_ini_section, {}),
|
| 69 |
+
prefix="sqlalchemy.",
|
| 70 |
+
poolclass=pool.NullPool,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
with connectable.connect() as connection:
|
| 74 |
+
context.configure(
|
| 75 |
+
connection=connection, target_metadata=target_metadata
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
with context.begin_transaction():
|
| 79 |
+
context.run_migrations()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if context.is_offline_mode():
|
| 83 |
+
run_migrations_offline()
|
| 84 |
+
else:
|
| 85 |
+
run_migrations_online()
|
alembic/script.py.mako
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
${imports if imports else ""}
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = ${repr(up_revision)}
|
| 14 |
+
down_revision = ${repr(down_revision)}
|
| 15 |
+
branch_labels = ${repr(branch_labels)}
|
| 16 |
+
depends_on = ${repr(depends_on)}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
${upgrades if upgrades else "pass"}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def downgrade() -> None:
|
| 24 |
+
${downgrades if downgrades else "pass"}
|
alembic/versions/001_initial_schema.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Initial schema
|
| 2 |
+
|
| 3 |
+
Revision ID: 001
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2026-02-05
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
from sqlalchemy.dialects import postgresql
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = '001'
|
| 14 |
+
down_revision = None
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
# Create users table
|
| 21 |
+
op.create_table(
|
| 22 |
+
'users',
|
| 23 |
+
sa.Column('id', sa.Integer(), nullable=False),
|
| 24 |
+
sa.Column('email', sa.String(length=255), nullable=False),
|
| 25 |
+
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
| 26 |
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 27 |
+
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
| 28 |
+
sa.PrimaryKeyConstraint('id')
|
| 29 |
+
)
|
| 30 |
+
op.create_index('idx_users_email', 'users', ['email'], unique=True)
|
| 31 |
+
|
| 32 |
+
# Create tasks table
|
| 33 |
+
op.create_table(
|
| 34 |
+
'tasks',
|
| 35 |
+
sa.Column('id', sa.Integer(), nullable=False),
|
| 36 |
+
sa.Column('user_id', sa.Integer(), nullable=False),
|
| 37 |
+
sa.Column('title', sa.String(length=500), nullable=False),
|
| 38 |
+
sa.Column('description', sa.Text(), nullable=True),
|
| 39 |
+
sa.Column('completed', sa.Boolean(), nullable=False, server_default='false'),
|
| 40 |
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 41 |
+
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
| 42 |
+
sa.PrimaryKeyConstraint('id'),
|
| 43 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE')
|
| 44 |
+
)
|
| 45 |
+
op.create_index('idx_tasks_user_id', 'tasks', ['user_id'], unique=False)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def downgrade() -> None:
|
| 49 |
+
op.drop_index('idx_tasks_user_id', table_name='tasks')
|
| 50 |
+
op.drop_table('tasks')
|
| 51 |
+
op.drop_index('idx_users_email', table_name='users')
|
| 52 |
+
op.drop_table('users')
|
alembic/versions/002_add_password_reset_tokens.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add password reset tokens table
|
| 2 |
+
|
| 3 |
+
Revision ID: 002
|
| 4 |
+
Revises: a6878af5b66f
|
| 5 |
+
Create Date: 2026-02-07
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
# revision identifiers, used by Alembic.
|
| 12 |
+
revision = '002'
|
| 13 |
+
down_revision = 'a6878af5b66f'
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade() -> None:
|
| 19 |
+
# Create password_reset_tokens table
|
| 20 |
+
op.create_table(
|
| 21 |
+
'password_reset_tokens',
|
| 22 |
+
sa.Column('id', sa.Integer(), nullable=False),
|
| 23 |
+
sa.Column('user_id', sa.Integer(), nullable=False),
|
| 24 |
+
sa.Column('token', sa.String(length=255), nullable=False),
|
| 25 |
+
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
| 26 |
+
sa.Column('used', sa.Boolean(), nullable=False, server_default='false'),
|
| 27 |
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
| 28 |
+
sa.PrimaryKeyConstraint('id'),
|
| 29 |
+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE')
|
| 30 |
+
)
|
| 31 |
+
op.create_index('idx_password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'], unique=False)
|
| 32 |
+
op.create_index('idx_password_reset_tokens_token', 'password_reset_tokens', ['token'], unique=True)
|
| 33 |
+
op.create_index('idx_password_reset_tokens_expires_at', 'password_reset_tokens', ['expires_at'], unique=False)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def downgrade() -> None:
|
| 37 |
+
op.drop_index('idx_password_reset_tokens_expires_at', table_name='password_reset_tokens')
|
| 38 |
+
op.drop_index('idx_password_reset_tokens_token', table_name='password_reset_tokens')
|
| 39 |
+
op.drop_index('idx_password_reset_tokens_user_id', table_name='password_reset_tokens')
|
| 40 |
+
op.drop_table('password_reset_tokens')
|
alembic/versions/a6878af5b66f_add_category_and_due_date_to_tasks.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""add_category_and_due_date_to_tasks
|
| 2 |
+
|
| 3 |
+
Revision ID: a6878af5b66f
|
| 4 |
+
Revises: 001
|
| 5 |
+
Create Date: 2026-02-05 14:23:11.577860
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = 'a6878af5b66f'
|
| 14 |
+
down_revision = '001'
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
# Add category column
|
| 21 |
+
op.add_column('tasks', sa.Column('category', sa.String(length=50), nullable=True))
|
| 22 |
+
|
| 23 |
+
# Add due_date column
|
| 24 |
+
op.add_column('tasks', sa.Column('due_date', sa.DateTime(), nullable=True))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def downgrade() -> None:
|
| 28 |
+
# Remove columns in reverse order
|
| 29 |
+
op.drop_column('tasks', 'due_date')
|
| 30 |
+
op.drop_column('tasks', 'category')
|
api/index.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vercel Serverless Function for FastAPI
|
| 3 |
+
Vercel natively supports ASGI apps - just export the app directly
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# Add parent directory to path for imports
|
| 9 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 10 |
+
|
| 11 |
+
from src.main import app
|
| 12 |
+
|
| 13 |
+
# Vercel will automatically detect and handle the ASGI app
|
| 14 |
+
# No need for Mangum or any wrapper
|
| 15 |
+
|
| 16 |
+
# For local testing
|
| 17 |
+
if __name__ == "__main__":
|
| 18 |
+
import uvicorn
|
| 19 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
api/test.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Minimal test endpoint for Vercel deployment debugging
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
app = FastAPI(title="Todo API - Minimal Test")
|
| 9 |
+
|
| 10 |
+
# CORS
|
| 11 |
+
app.add_middleware(
|
| 12 |
+
CORSMiddleware,
|
| 13 |
+
allow_origins=["*"],
|
| 14 |
+
allow_credentials=True,
|
| 15 |
+
allow_methods=["*"],
|
| 16 |
+
allow_headers=["*"],
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
@app.get("/")
|
| 20 |
+
async def root():
|
| 21 |
+
return {
|
| 22 |
+
"status": "ok",
|
| 23 |
+
"message": "Minimal FastAPI working on Vercel",
|
| 24 |
+
"environment": {
|
| 25 |
+
"VERCEL": os.getenv("VERCEL", "not set"),
|
| 26 |
+
"VERCEL_ENV": os.getenv("VERCEL_ENV", "not set"),
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@app.get("/health")
|
| 31 |
+
async def health():
|
| 32 |
+
return {"status": "healthy"}
|
| 33 |
+
|
| 34 |
+
@app.get("/test-db")
|
| 35 |
+
async def test_db():
|
| 36 |
+
"""Test database connection"""
|
| 37 |
+
try:
|
| 38 |
+
from src.database import engine
|
| 39 |
+
from sqlmodel import text
|
| 40 |
+
|
| 41 |
+
with engine.connect() as conn:
|
| 42 |
+
result = conn.execute(text("SELECT 1"))
|
| 43 |
+
return {"status": "ok", "database": "connected"}
|
| 44 |
+
except Exception as e:
|
| 45 |
+
return {"status": "error", "message": str(e)}
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# PostgreSQL Database
|
| 5 |
+
db:
|
| 6 |
+
image: postgres:15-alpine
|
| 7 |
+
container_name: todo-db
|
| 8 |
+
environment:
|
| 9 |
+
POSTGRES_USER: todouser
|
| 10 |
+
POSTGRES_PASSWORD: todopassword
|
| 11 |
+
POSTGRES_DB: tododb
|
| 12 |
+
volumes:
|
| 13 |
+
- postgres_data:/var/lib/postgresql/data
|
| 14 |
+
ports:
|
| 15 |
+
- "5432:5432"
|
| 16 |
+
healthcheck:
|
| 17 |
+
test: ["CMD-SHELL", "pg_isready -U todouser"]
|
| 18 |
+
interval: 10s
|
| 19 |
+
timeout: 5s
|
| 20 |
+
retries: 5
|
| 21 |
+
|
| 22 |
+
# FastAPI Backend
|
| 23 |
+
api:
|
| 24 |
+
build:
|
| 25 |
+
context: .
|
| 26 |
+
dockerfile: Dockerfile
|
| 27 |
+
container_name: todo-api
|
| 28 |
+
environment:
|
| 29 |
+
DATABASE_URL: postgresql://todouser:todopassword@db:5432/tododb
|
| 30 |
+
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-your-super-secret-key-change-this-min-32-characters-long}
|
| 31 |
+
JWT_ALGORITHM: HS256
|
| 32 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
| 33 |
+
CORS_ORIGINS: http://localhost:3000,http://localhost:3001
|
| 34 |
+
SMTP_HOST: ${SMTP_HOST:-smtp.gmail.com}
|
| 35 |
+
SMTP_PORT: ${SMTP_PORT:-587}
|
| 36 |
+
SMTP_USERNAME: ${SMTP_USERNAME}
|
| 37 |
+
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
| 38 |
+
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
|
| 39 |
+
EMAIL_FROM: ${EMAIL_FROM}
|
| 40 |
+
EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-Todo Application}
|
| 41 |
+
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000}
|
| 42 |
+
PASSWORD_RESET_TOKEN_EXPIRY_MINUTES: 15
|
| 43 |
+
PASSWORD_RESET_MAX_REQUESTS_PER_HOUR: 3
|
| 44 |
+
COHERE_API_KEY: ${COHERE_API_KEY}
|
| 45 |
+
ports:
|
| 46 |
+
- "8000:8000"
|
| 47 |
+
depends_on:
|
| 48 |
+
db:
|
| 49 |
+
condition: service_healthy
|
| 50 |
+
volumes:
|
| 51 |
+
- ./src:/app/src
|
| 52 |
+
restart: unless-stopped
|
| 53 |
+
|
| 54 |
+
volumes:
|
| 55 |
+
postgres_data:
|
init_db.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Initialize database tables for the Todo application.
|
| 3 |
+
"""
|
| 4 |
+
from src.database import create_db_and_tables
|
| 5 |
+
|
| 6 |
+
if __name__ == "__main__":
|
| 7 |
+
print("Creating database tables...")
|
| 8 |
+
create_db_and_tables()
|
| 9 |
+
print("Database tables created successfully!")
|
migrate_db.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simple migration script to add category and due_date columns to tasks table.
|
| 3 |
+
"""
|
| 4 |
+
import sqlite3
|
| 5 |
+
|
| 6 |
+
# Connect to database
|
| 7 |
+
conn = sqlite3.connect('todo.db')
|
| 8 |
+
cursor = conn.cursor()
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
# Check if columns exist
|
| 12 |
+
cursor.execute("PRAGMA table_info(tasks)")
|
| 13 |
+
columns = [col[1] for col in cursor.fetchall()]
|
| 14 |
+
|
| 15 |
+
# Add category column if it doesn't exist
|
| 16 |
+
if 'category' not in columns:
|
| 17 |
+
cursor.execute("ALTER TABLE tasks ADD COLUMN category VARCHAR(50)")
|
| 18 |
+
print("Added 'category' column")
|
| 19 |
+
else:
|
| 20 |
+
print("'category' column already exists")
|
| 21 |
+
|
| 22 |
+
# Add due_date column if it doesn't exist
|
| 23 |
+
if 'due_date' not in columns:
|
| 24 |
+
cursor.execute("ALTER TABLE tasks ADD COLUMN due_date DATETIME")
|
| 25 |
+
print("Added 'due_date' column")
|
| 26 |
+
else:
|
| 27 |
+
print("'due_date' column already exists")
|
| 28 |
+
|
| 29 |
+
conn.commit()
|
| 30 |
+
print("\nDatabase migration completed successfully!")
|
| 31 |
+
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"Error: {e}")
|
| 34 |
+
conn.rollback()
|
| 35 |
+
finally:
|
| 36 |
+
conn.close()
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
sqlmodel
|
| 3 |
+
python-jose
|
| 4 |
+
passlib
|
| 5 |
+
bcrypt
|
| 6 |
+
python-multipart
|
| 7 |
+
uvicorn
|
| 8 |
+
psycopg2-binary
|
| 9 |
+
pydantic
|
| 10 |
+
pydantic-settings
|
| 11 |
+
python-dotenv
|
| 12 |
+
mangum
|
| 13 |
+
email-validator
|
src/__pycache__/database.cpython-314.pyc
ADDED
|
Binary file (2.03 kB). View file
|
|
|
src/__pycache__/main.cpython-314.pyc
ADDED
|
Binary file (2.81 kB). View file
|
|
|
src/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API module
|
src/api/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (206 Bytes). View file
|
|
|
src/api/__pycache__/auth.cpython-314.pyc
ADDED
|
Binary file (6.72 kB). View file
|
|
|
src/api/__pycache__/password_reset.cpython-314.pyc
ADDED
|
Binary file (8.97 kB). View file
|
|
|
src/api/__pycache__/subtasks.cpython-314.pyc
ADDED
|
Binary file (9.82 kB). View file
|
|
|
src/api/__pycache__/tasks.cpython-314.pyc
ADDED
|
Binary file (13.3 kB). View file
|
|
|
src/api/ai.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI-powered task management endpoints using Cohere.
|
| 3 |
+
|
| 4 |
+
This module provides REST API endpoints for AI features:
|
| 5 |
+
- Task suggestions
|
| 6 |
+
- Smart auto-completion
|
| 7 |
+
- Task categorization
|
| 8 |
+
- Description enhancement
|
| 9 |
+
- Complexity analysis
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
from typing import List, Dict, Optional
|
| 15 |
+
from src.services.cohere_ai import cohere_service
|
| 16 |
+
from src.middleware.jwt_auth import get_current_user
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Request/Response Models
|
| 22 |
+
class TaskSuggestionRequest(BaseModel):
|
| 23 |
+
context: str = Field(..., description="Context to generate suggestions from")
|
| 24 |
+
count: int = Field(default=5, ge=1, le=10, description="Number of suggestions")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TaskSuggestionResponse(BaseModel):
|
| 28 |
+
suggestions: List[str]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class EnhanceDescriptionRequest(BaseModel):
|
| 32 |
+
title: str = Field(..., description="Task title")
|
| 33 |
+
description: str = Field(default="", description="Current description")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class EnhanceDescriptionResponse(BaseModel):
|
| 37 |
+
enhanced_description: str
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class CategorizeTaskRequest(BaseModel):
|
| 41 |
+
title: str = Field(..., description="Task title")
|
| 42 |
+
description: str = Field(default="", description="Task description")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class CategorizeTaskResponse(BaseModel):
|
| 46 |
+
category: str
|
| 47 |
+
priority: str
|
| 48 |
+
tags: List[str]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class AutoCompleteRequest(BaseModel):
|
| 52 |
+
partial_title: str = Field(..., description="Partial task title")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class AutoCompleteResponse(BaseModel):
|
| 56 |
+
completions: List[str]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class AnalyzeComplexityRequest(BaseModel):
|
| 60 |
+
title: str = Field(..., description="Task title")
|
| 61 |
+
description: str = Field(default="", description="Task description")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class AnalyzeComplexityResponse(BaseModel):
|
| 65 |
+
complexity: str
|
| 66 |
+
estimated_time: str
|
| 67 |
+
needs_subtasks: bool
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# Endpoints
|
| 71 |
+
@router.post("/suggestions", response_model=TaskSuggestionResponse)
|
| 72 |
+
async def generate_task_suggestions(
|
| 73 |
+
request: TaskSuggestionRequest,
|
| 74 |
+
current_user: dict = Depends(get_current_user)
|
| 75 |
+
):
|
| 76 |
+
"""
|
| 77 |
+
Generate AI-powered task suggestions based on context.
|
| 78 |
+
|
| 79 |
+
Requires authentication.
|
| 80 |
+
"""
|
| 81 |
+
try:
|
| 82 |
+
suggestions = cohere_service.generate_task_suggestions(
|
| 83 |
+
context=request.context,
|
| 84 |
+
count=request.count
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
if not suggestions:
|
| 88 |
+
raise HTTPException(
|
| 89 |
+
status_code=500,
|
| 90 |
+
detail="Failed to generate suggestions. Please try again."
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
return TaskSuggestionResponse(suggestions=suggestions)
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=500,
|
| 98 |
+
detail=f"Error generating suggestions: {str(e)}"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.post("/enhance-description", response_model=EnhanceDescriptionResponse)
|
| 103 |
+
async def enhance_task_description(
|
| 104 |
+
request: EnhanceDescriptionRequest,
|
| 105 |
+
current_user: dict = Depends(get_current_user)
|
| 106 |
+
):
|
| 107 |
+
"""
|
| 108 |
+
Enhance a task description with AI to make it more clear and actionable.
|
| 109 |
+
|
| 110 |
+
Requires authentication.
|
| 111 |
+
"""
|
| 112 |
+
try:
|
| 113 |
+
enhanced = cohere_service.enhance_task_description(
|
| 114 |
+
title=request.title,
|
| 115 |
+
description=request.description
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
return EnhanceDescriptionResponse(enhanced_description=enhanced)
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
raise HTTPException(
|
| 122 |
+
status_code=500,
|
| 123 |
+
detail=f"Error enhancing description: {str(e)}"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@router.post("/categorize", response_model=CategorizeTaskResponse)
|
| 128 |
+
async def categorize_task(
|
| 129 |
+
request: CategorizeTaskRequest,
|
| 130 |
+
current_user: dict = Depends(get_current_user)
|
| 131 |
+
):
|
| 132 |
+
"""
|
| 133 |
+
Categorize a task and suggest priority level using AI.
|
| 134 |
+
|
| 135 |
+
Requires authentication.
|
| 136 |
+
"""
|
| 137 |
+
try:
|
| 138 |
+
result = cohere_service.categorize_task(
|
| 139 |
+
title=request.title,
|
| 140 |
+
description=request.description
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
return CategorizeTaskResponse(**result)
|
| 144 |
+
|
| 145 |
+
except Exception as e:
|
| 146 |
+
raise HTTPException(
|
| 147 |
+
status_code=500,
|
| 148 |
+
detail=f"Error categorizing task: {str(e)}"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@router.post("/autocomplete", response_model=AutoCompleteResponse)
|
| 153 |
+
async def autocomplete_task(
|
| 154 |
+
request: AutoCompleteRequest,
|
| 155 |
+
current_user: dict = Depends(get_current_user)
|
| 156 |
+
):
|
| 157 |
+
"""
|
| 158 |
+
Provide smart auto-completion suggestions for task titles.
|
| 159 |
+
|
| 160 |
+
Requires authentication.
|
| 161 |
+
"""
|
| 162 |
+
try:
|
| 163 |
+
completions = cohere_service.smart_complete_task(
|
| 164 |
+
partial_title=request.partial_title
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
return AutoCompleteResponse(completions=completions)
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
raise HTTPException(
|
| 171 |
+
status_code=500,
|
| 172 |
+
detail=f"Error generating completions: {str(e)}"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.post("/analyze-complexity", response_model=AnalyzeComplexityResponse)
|
| 177 |
+
async def analyze_task_complexity(
|
| 178 |
+
request: AnalyzeComplexityRequest,
|
| 179 |
+
current_user: dict = Depends(get_current_user)
|
| 180 |
+
):
|
| 181 |
+
"""
|
| 182 |
+
Analyze task complexity and provide time estimates using AI.
|
| 183 |
+
|
| 184 |
+
Requires authentication.
|
| 185 |
+
"""
|
| 186 |
+
try:
|
| 187 |
+
result = cohere_service.analyze_task_complexity(
|
| 188 |
+
title=request.title,
|
| 189 |
+
description=request.description
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
return AnalyzeComplexityResponse(**result)
|
| 193 |
+
|
| 194 |
+
except Exception as e:
|
| 195 |
+
raise HTTPException(
|
| 196 |
+
status_code=500,
|
| 197 |
+
detail=f"Error analyzing complexity: {str(e)}"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@router.get("/health")
|
| 202 |
+
async def ai_health_check():
|
| 203 |
+
"""
|
| 204 |
+
Check if AI service is properly configured.
|
| 205 |
+
|
| 206 |
+
Does not require authentication.
|
| 207 |
+
"""
|
| 208 |
+
try:
|
| 209 |
+
import os
|
| 210 |
+
api_key = os.getenv("COHERE_API_KEY")
|
| 211 |
+
|
| 212 |
+
if not api_key:
|
| 213 |
+
return {
|
| 214 |
+
"status": "error",
|
| 215 |
+
"message": "COHERE_API_KEY not configured"
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
"status": "healthy",
|
| 220 |
+
"message": "AI service is configured and ready",
|
| 221 |
+
"provider": "Cohere"
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
except Exception as e:
|
| 225 |
+
return {
|
| 226 |
+
"status": "error",
|
| 227 |
+
"message": str(e)
|
| 228 |
+
}
|
src/api/auth.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication API endpoints for user signup and signin.
|
| 3 |
+
|
| 4 |
+
This module provides:
|
| 5 |
+
- POST /api/auth/signup - Create new user account
|
| 6 |
+
- POST /api/auth/signin - Authenticate existing user
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 10 |
+
from sqlmodel import Session, select
|
| 11 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 12 |
+
|
| 13 |
+
from ..models.user import User
|
| 14 |
+
from ..services.auth import hash_password, verify_password, create_access_token
|
| 15 |
+
from ..database import get_session
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Request/Response Models
|
| 21 |
+
class SignUpRequest(BaseModel):
|
| 22 |
+
"""Request model for user signup."""
|
| 23 |
+
email: EmailStr = Field(..., description="User email address")
|
| 24 |
+
password: str = Field(..., min_length=8, description="User password (minimum 8 characters)")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class SignInRequest(BaseModel):
|
| 28 |
+
"""Request model for user signin."""
|
| 29 |
+
email: EmailStr = Field(..., description="User email address")
|
| 30 |
+
password: str = Field(..., description="User password")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class UserResponse(BaseModel):
|
| 34 |
+
"""User data response model."""
|
| 35 |
+
id: int
|
| 36 |
+
email: str
|
| 37 |
+
created_at: str
|
| 38 |
+
updated_at: str
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class AuthResponse(BaseModel):
|
| 42 |
+
"""Authentication response with token and user data."""
|
| 43 |
+
token: str
|
| 44 |
+
user: UserResponse
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.post("/signup", response_model=AuthResponse, status_code=201)
|
| 48 |
+
async def signup(
|
| 49 |
+
request: SignUpRequest,
|
| 50 |
+
session: Session = Depends(get_session)
|
| 51 |
+
) -> AuthResponse:
|
| 52 |
+
"""
|
| 53 |
+
Create a new user account.
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
request: Signup request with email and password
|
| 57 |
+
session: Database session
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
AuthResponse with JWT token and user data
|
| 61 |
+
|
| 62 |
+
Raises:
|
| 63 |
+
HTTPException 400: If email already exists
|
| 64 |
+
HTTPException 422: If validation fails
|
| 65 |
+
"""
|
| 66 |
+
# Check if email already exists
|
| 67 |
+
statement = select(User).where(User.email == request.email)
|
| 68 |
+
existing_user = session.exec(statement).first()
|
| 69 |
+
|
| 70 |
+
if existing_user:
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=400,
|
| 73 |
+
detail="Email already registered"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Hash password
|
| 77 |
+
hashed_password = hash_password(request.password)
|
| 78 |
+
|
| 79 |
+
# Create new user
|
| 80 |
+
new_user = User(
|
| 81 |
+
email=request.email,
|
| 82 |
+
hashed_password=hashed_password
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
session.add(new_user)
|
| 86 |
+
session.commit()
|
| 87 |
+
session.refresh(new_user)
|
| 88 |
+
|
| 89 |
+
# Create JWT token
|
| 90 |
+
token = create_access_token(
|
| 91 |
+
data={
|
| 92 |
+
"user_id": new_user.id,
|
| 93 |
+
"email": new_user.email
|
| 94 |
+
}
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Return response
|
| 98 |
+
return AuthResponse(
|
| 99 |
+
token=token,
|
| 100 |
+
user=UserResponse(
|
| 101 |
+
id=new_user.id,
|
| 102 |
+
email=new_user.email,
|
| 103 |
+
created_at=new_user.created_at.isoformat(),
|
| 104 |
+
updated_at=new_user.updated_at.isoformat()
|
| 105 |
+
)
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.post("/signin", response_model=AuthResponse)
|
| 110 |
+
async def signin(
|
| 111 |
+
request: SignInRequest,
|
| 112 |
+
session: Session = Depends(get_session)
|
| 113 |
+
) -> AuthResponse:
|
| 114 |
+
"""
|
| 115 |
+
Authenticate an existing user.
|
| 116 |
+
|
| 117 |
+
Args:
|
| 118 |
+
request: Signin request with email and password
|
| 119 |
+
session: Database session
|
| 120 |
+
|
| 121 |
+
Returns:
|
| 122 |
+
AuthResponse with JWT token and user data
|
| 123 |
+
|
| 124 |
+
Raises:
|
| 125 |
+
HTTPException 401: If credentials are invalid
|
| 126 |
+
"""
|
| 127 |
+
# Find user by email
|
| 128 |
+
statement = select(User).where(User.email == request.email)
|
| 129 |
+
user = session.exec(statement).first()
|
| 130 |
+
|
| 131 |
+
# Verify user exists and password is correct
|
| 132 |
+
if not user or not verify_password(request.password, user.hashed_password):
|
| 133 |
+
raise HTTPException(
|
| 134 |
+
status_code=401,
|
| 135 |
+
detail="Invalid email or password"
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# Create JWT token
|
| 139 |
+
token = create_access_token(
|
| 140 |
+
data={
|
| 141 |
+
"user_id": user.id,
|
| 142 |
+
"email": user.email
|
| 143 |
+
}
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# Return response
|
| 147 |
+
return AuthResponse(
|
| 148 |
+
token=token,
|
| 149 |
+
user=UserResponse(
|
| 150 |
+
id=user.id,
|
| 151 |
+
email=user.email,
|
| 152 |
+
created_at=user.created_at.isoformat(),
|
| 153 |
+
updated_at=user.updated_at.isoformat()
|
| 154 |
+
)
|
| 155 |
+
)
|
src/api/password_reset.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Password Reset API endpoints for secure password recovery.
|
| 3 |
+
|
| 4 |
+
This module provides:
|
| 5 |
+
- POST /api/auth/forgot-password - Request password reset email
|
| 6 |
+
- GET /api/auth/reset-password/{token} - Verify reset token validity
|
| 7 |
+
- POST /api/auth/reset-password - Reset password with token
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 11 |
+
from sqlmodel import Session
|
| 12 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from ..models.user import User
|
| 16 |
+
from ..services.auth import hash_password
|
| 17 |
+
from ..services.password_reset import (
|
| 18 |
+
create_reset_token,
|
| 19 |
+
validate_reset_token,
|
| 20 |
+
invalidate_token,
|
| 21 |
+
check_rate_limit,
|
| 22 |
+
validate_password_strength,
|
| 23 |
+
get_user_by_email
|
| 24 |
+
)
|
| 25 |
+
from ..services.email import send_password_reset_email
|
| 26 |
+
from ..database import get_session
|
| 27 |
+
|
| 28 |
+
router = APIRouter()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Request/Response Models
|
| 32 |
+
class ForgotPasswordRequest(BaseModel):
|
| 33 |
+
"""Request model for forgot password."""
|
| 34 |
+
email: EmailStr = Field(..., description="User email address")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ForgotPasswordResponse(BaseModel):
|
| 38 |
+
"""Response model for forgot password request."""
|
| 39 |
+
message: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class TokenValidationResponse(BaseModel):
|
| 43 |
+
"""Response model for token validation."""
|
| 44 |
+
valid: bool
|
| 45 |
+
email: Optional[str] = None
|
| 46 |
+
error: Optional[str] = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class ResetPasswordRequest(BaseModel):
|
| 50 |
+
"""Request model for password reset."""
|
| 51 |
+
token: str = Field(..., description="Password reset token")
|
| 52 |
+
new_password: str = Field(..., min_length=8, description="New password (minimum 8 characters)")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class ResetPasswordResponse(BaseModel):
|
| 56 |
+
"""Response model for password reset."""
|
| 57 |
+
message: str
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.post("/forgot-password", response_model=ForgotPasswordResponse)
|
| 61 |
+
async def forgot_password(
|
| 62 |
+
request: ForgotPasswordRequest,
|
| 63 |
+
session: Session = Depends(get_session)
|
| 64 |
+
) -> ForgotPasswordResponse:
|
| 65 |
+
"""
|
| 66 |
+
Request a password reset email.
|
| 67 |
+
|
| 68 |
+
Security features:
|
| 69 |
+
- No user enumeration (same response for existing/non-existing emails)
|
| 70 |
+
- Rate limiting (3 requests per hour per user)
|
| 71 |
+
- Cryptographically secure tokens
|
| 72 |
+
- 15-minute token expiry
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
request: Forgot password request with email
|
| 76 |
+
session: Database session
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
Generic success message (no user enumeration)
|
| 80 |
+
|
| 81 |
+
Raises:
|
| 82 |
+
HTTPException 400: If email format is invalid
|
| 83 |
+
HTTPException 429: If rate limit exceeded
|
| 84 |
+
"""
|
| 85 |
+
# Find user by email
|
| 86 |
+
user = get_user_by_email(session, request.email)
|
| 87 |
+
|
| 88 |
+
# Always return same message to prevent user enumeration
|
| 89 |
+
generic_message = "If an account exists with this email, you will receive a password reset link shortly."
|
| 90 |
+
|
| 91 |
+
# If user doesn't exist, return generic message (no enumeration)
|
| 92 |
+
if not user:
|
| 93 |
+
return ForgotPasswordResponse(message=generic_message)
|
| 94 |
+
|
| 95 |
+
# Check rate limit
|
| 96 |
+
if not check_rate_limit(session, user.id):
|
| 97 |
+
raise HTTPException(
|
| 98 |
+
status_code=429,
|
| 99 |
+
detail="Too many password reset requests. Please try again later."
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Create reset token
|
| 103 |
+
token = create_reset_token(session, user.id)
|
| 104 |
+
|
| 105 |
+
# Send reset email
|
| 106 |
+
email_sent = send_password_reset_email(user.email, token)
|
| 107 |
+
|
| 108 |
+
if not email_sent:
|
| 109 |
+
# Log error but don't expose to user
|
| 110 |
+
print(f"Failed to send password reset email to {user.email}")
|
| 111 |
+
|
| 112 |
+
# Always return generic message
|
| 113 |
+
return ForgotPasswordResponse(message=generic_message)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@router.get("/reset-password/{token}", response_model=TokenValidationResponse)
|
| 117 |
+
async def verify_reset_token(
|
| 118 |
+
token: str,
|
| 119 |
+
session: Session = Depends(get_session)
|
| 120 |
+
) -> TokenValidationResponse:
|
| 121 |
+
"""
|
| 122 |
+
Verify if a password reset token is valid.
|
| 123 |
+
|
| 124 |
+
Checks:
|
| 125 |
+
- Token exists
|
| 126 |
+
- Token has not expired (15 minutes)
|
| 127 |
+
- Token has not been used
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
token: Password reset token to verify
|
| 131 |
+
session: Database session
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
TokenValidationResponse with validity status and user email
|
| 135 |
+
|
| 136 |
+
Example:
|
| 137 |
+
GET /api/auth/reset-password/abc123def456
|
| 138 |
+
"""
|
| 139 |
+
# Validate token
|
| 140 |
+
token_record = validate_reset_token(session, token)
|
| 141 |
+
|
| 142 |
+
if not token_record:
|
| 143 |
+
return TokenValidationResponse(
|
| 144 |
+
valid=False,
|
| 145 |
+
error="Invalid or expired reset token"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# Get user email
|
| 149 |
+
user = session.get(User, token_record.user_id)
|
| 150 |
+
|
| 151 |
+
if not user:
|
| 152 |
+
return TokenValidationResponse(
|
| 153 |
+
valid=False,
|
| 154 |
+
error="User not found"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
return TokenValidationResponse(
|
| 158 |
+
valid=True,
|
| 159 |
+
email=user.email
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.post("/reset-password", response_model=ResetPasswordResponse)
|
| 164 |
+
async def reset_password(
|
| 165 |
+
request: ResetPasswordRequest,
|
| 166 |
+
session: Session = Depends(get_session)
|
| 167 |
+
) -> ResetPasswordResponse:
|
| 168 |
+
"""
|
| 169 |
+
Reset user password with a valid token.
|
| 170 |
+
|
| 171 |
+
Security features:
|
| 172 |
+
- Token validation (expiry, usage)
|
| 173 |
+
- Password strength validation
|
| 174 |
+
- One-time use tokens
|
| 175 |
+
- Automatic token invalidation
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
request: Reset password request with token and new password
|
| 179 |
+
session: Database session
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
Success message
|
| 183 |
+
|
| 184 |
+
Raises:
|
| 185 |
+
HTTPException 400: If token is invalid or password is weak
|
| 186 |
+
HTTPException 422: If validation fails
|
| 187 |
+
"""
|
| 188 |
+
# Validate token
|
| 189 |
+
token_record = validate_reset_token(session, request.token)
|
| 190 |
+
|
| 191 |
+
if not token_record:
|
| 192 |
+
raise HTTPException(
|
| 193 |
+
status_code=400,
|
| 194 |
+
detail="Invalid or expired reset token"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Validate password strength
|
| 198 |
+
password_validation = validate_password_strength(request.new_password)
|
| 199 |
+
|
| 200 |
+
if not password_validation["valid"]:
|
| 201 |
+
raise HTTPException(
|
| 202 |
+
status_code=400,
|
| 203 |
+
detail={
|
| 204 |
+
"message": "Password does not meet strength requirements",
|
| 205 |
+
"errors": password_validation["errors"]
|
| 206 |
+
}
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
# Get user
|
| 210 |
+
user = session.get(User, token_record.user_id)
|
| 211 |
+
|
| 212 |
+
if not user:
|
| 213 |
+
raise HTTPException(
|
| 214 |
+
status_code=400,
|
| 215 |
+
detail="User not found"
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# Hash new password
|
| 219 |
+
hashed_password = hash_password(request.new_password)
|
| 220 |
+
|
| 221 |
+
# Update user password
|
| 222 |
+
user.hashed_password = hashed_password
|
| 223 |
+
session.add(user)
|
| 224 |
+
|
| 225 |
+
# Invalidate token (mark as used)
|
| 226 |
+
invalidate_token(session, request.token)
|
| 227 |
+
|
| 228 |
+
# Commit changes
|
| 229 |
+
session.commit()
|
| 230 |
+
|
| 231 |
+
return ResetPasswordResponse(
|
| 232 |
+
message="Password successfully reset. You can now sign in with your new password."
|
| 233 |
+
)
|
src/api/subtasks.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Subtasks API endpoints for CRUD operations on subtasks.
|
| 3 |
+
|
| 4 |
+
This module provides:
|
| 5 |
+
- GET /api/tasks/{task_id}/subtasks - List all subtasks for a task
|
| 6 |
+
- POST /api/tasks/{task_id}/subtasks - Create new subtask
|
| 7 |
+
- PUT /api/subtasks/{subtask_id} - Update existing subtask
|
| 8 |
+
- DELETE /api/subtasks/{subtask_id} - Delete subtask
|
| 9 |
+
|
| 10 |
+
All endpoints require JWT authentication and enforce user isolation.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 14 |
+
from sqlmodel import Session
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
from typing import Optional, List
|
| 17 |
+
|
| 18 |
+
from ..models.subtask import Subtask
|
| 19 |
+
from ..services import subtasks as subtask_service
|
| 20 |
+
from ..middleware.jwt_auth import get_current_user_id
|
| 21 |
+
from ..database import get_session
|
| 22 |
+
|
| 23 |
+
router = APIRouter()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Request/Response Models
|
| 27 |
+
class CreateSubtaskRequest(BaseModel):
|
| 28 |
+
"""Request model for creating a subtask."""
|
| 29 |
+
title: str = Field(..., min_length=1, max_length=500, description="Subtask title")
|
| 30 |
+
order: Optional[int] = Field(0, description="Order position")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class UpdateSubtaskRequest(BaseModel):
|
| 34 |
+
"""Request model for updating a subtask."""
|
| 35 |
+
title: Optional[str] = Field(None, min_length=1, max_length=500, description="Subtask title")
|
| 36 |
+
completed: Optional[bool] = Field(None, description="Subtask completion status")
|
| 37 |
+
order: Optional[int] = Field(None, description="Order position")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class SubtaskResponse(BaseModel):
|
| 41 |
+
"""Subtask data response model."""
|
| 42 |
+
id: int
|
| 43 |
+
task_id: int
|
| 44 |
+
title: str
|
| 45 |
+
completed: bool
|
| 46 |
+
order: int
|
| 47 |
+
created_at: str
|
| 48 |
+
updated_at: str
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class SubtaskListResponse(BaseModel):
|
| 52 |
+
"""Response model for subtask list."""
|
| 53 |
+
subtasks: List[SubtaskResponse]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.get("/tasks/{task_id}/subtasks", response_model=SubtaskListResponse)
|
| 57 |
+
async def list_subtasks(
|
| 58 |
+
task_id: int,
|
| 59 |
+
user_id: int = Depends(get_current_user_id),
|
| 60 |
+
session: Session = Depends(get_session)
|
| 61 |
+
) -> SubtaskListResponse:
|
| 62 |
+
"""
|
| 63 |
+
Get all subtasks for a task.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
task_id: Task ID
|
| 67 |
+
user_id: Current user ID from JWT token
|
| 68 |
+
session: Database session
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
SubtaskListResponse with array of subtasks
|
| 72 |
+
|
| 73 |
+
Raises:
|
| 74 |
+
HTTPException 401: If JWT token is invalid
|
| 75 |
+
HTTPException 404: If task not found or doesn't belong to user
|
| 76 |
+
"""
|
| 77 |
+
# Get subtasks
|
| 78 |
+
subtasks = subtask_service.get_task_subtasks(session, task_id, user_id)
|
| 79 |
+
|
| 80 |
+
# Convert to response format
|
| 81 |
+
subtask_responses = [
|
| 82 |
+
SubtaskResponse(
|
| 83 |
+
id=subtask.id,
|
| 84 |
+
task_id=subtask.task_id,
|
| 85 |
+
title=subtask.title,
|
| 86 |
+
completed=subtask.completed,
|
| 87 |
+
order=subtask.order,
|
| 88 |
+
created_at=subtask.created_at.isoformat(),
|
| 89 |
+
updated_at=subtask.updated_at.isoformat()
|
| 90 |
+
)
|
| 91 |
+
for subtask in subtasks
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
return SubtaskListResponse(subtasks=subtask_responses)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.post("/tasks/{task_id}/subtasks", response_model=SubtaskResponse, status_code=status.HTTP_201_CREATED)
|
| 98 |
+
async def create_subtask(
|
| 99 |
+
task_id: int,
|
| 100 |
+
request: CreateSubtaskRequest,
|
| 101 |
+
user_id: int = Depends(get_current_user_id),
|
| 102 |
+
session: Session = Depends(get_session)
|
| 103 |
+
) -> SubtaskResponse:
|
| 104 |
+
"""
|
| 105 |
+
Create a new subtask for a task.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
task_id: Task ID
|
| 109 |
+
request: Subtask creation request
|
| 110 |
+
user_id: Current user ID from JWT token
|
| 111 |
+
session: Database session
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
SubtaskResponse with created subtask data
|
| 115 |
+
|
| 116 |
+
Raises:
|
| 117 |
+
HTTPException 401: If JWT token is invalid
|
| 118 |
+
HTTPException 404: If task not found or doesn't belong to user
|
| 119 |
+
"""
|
| 120 |
+
# Create subtask
|
| 121 |
+
subtask = subtask_service.create_subtask(
|
| 122 |
+
session=session,
|
| 123 |
+
task_id=task_id,
|
| 124 |
+
user_id=user_id,
|
| 125 |
+
title=request.title,
|
| 126 |
+
order=request.order or 0
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
if not subtask:
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 132 |
+
detail="Task not found"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Return response
|
| 136 |
+
return SubtaskResponse(
|
| 137 |
+
id=subtask.id,
|
| 138 |
+
task_id=subtask.task_id,
|
| 139 |
+
title=subtask.title,
|
| 140 |
+
completed=subtask.completed,
|
| 141 |
+
order=subtask.order,
|
| 142 |
+
created_at=subtask.created_at.isoformat(),
|
| 143 |
+
updated_at=subtask.updated_at.isoformat()
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@router.put("/subtasks/{subtask_id}", response_model=SubtaskResponse)
|
| 148 |
+
async def update_subtask(
|
| 149 |
+
subtask_id: int,
|
| 150 |
+
request: UpdateSubtaskRequest,
|
| 151 |
+
user_id: int = Depends(get_current_user_id),
|
| 152 |
+
session: Session = Depends(get_session)
|
| 153 |
+
) -> SubtaskResponse:
|
| 154 |
+
"""
|
| 155 |
+
Update an existing subtask.
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
subtask_id: ID of the subtask to update
|
| 159 |
+
request: Subtask update request
|
| 160 |
+
user_id: Current user ID from JWT token
|
| 161 |
+
session: Database session
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
SubtaskResponse with updated subtask data
|
| 165 |
+
|
| 166 |
+
Raises:
|
| 167 |
+
HTTPException 401: If JWT token is invalid
|
| 168 |
+
HTTPException 404: If subtask not found or doesn't belong to user
|
| 169 |
+
"""
|
| 170 |
+
# Update subtask
|
| 171 |
+
subtask = subtask_service.update_subtask(
|
| 172 |
+
session=session,
|
| 173 |
+
subtask_id=subtask_id,
|
| 174 |
+
user_id=user_id,
|
| 175 |
+
title=request.title,
|
| 176 |
+
completed=request.completed,
|
| 177 |
+
order=request.order
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
if not subtask:
|
| 181 |
+
raise HTTPException(
|
| 182 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 183 |
+
detail="Subtask not found"
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# Return response
|
| 187 |
+
return SubtaskResponse(
|
| 188 |
+
id=subtask.id,
|
| 189 |
+
task_id=subtask.task_id,
|
| 190 |
+
title=subtask.title,
|
| 191 |
+
completed=subtask.completed,
|
| 192 |
+
order=subtask.order,
|
| 193 |
+
created_at=subtask.created_at.isoformat(),
|
| 194 |
+
updated_at=subtask.updated_at.isoformat()
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@router.delete("/subtasks/{subtask_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 199 |
+
async def delete_subtask(
|
| 200 |
+
subtask_id: int,
|
| 201 |
+
user_id: int = Depends(get_current_user_id),
|
| 202 |
+
session: Session = Depends(get_session)
|
| 203 |
+
) -> None:
|
| 204 |
+
"""
|
| 205 |
+
Delete a subtask.
|
| 206 |
+
|
| 207 |
+
Args:
|
| 208 |
+
subtask_id: ID of the subtask to delete
|
| 209 |
+
user_id: Current user ID from JWT token
|
| 210 |
+
session: Database session
|
| 211 |
+
|
| 212 |
+
Returns:
|
| 213 |
+
None (204 No Content)
|
| 214 |
+
|
| 215 |
+
Raises:
|
| 216 |
+
HTTPException 401: If JWT token is invalid
|
| 217 |
+
HTTPException 404: If subtask not found or doesn't belong to user
|
| 218 |
+
"""
|
| 219 |
+
# Delete subtask
|
| 220 |
+
success = subtask_service.delete_subtask(
|
| 221 |
+
session=session,
|
| 222 |
+
subtask_id=subtask_id,
|
| 223 |
+
user_id=user_id
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
if not success:
|
| 227 |
+
raise HTTPException(
|
| 228 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 229 |
+
detail="Subtask not found"
|
| 230 |
+
)
|
src/api/tasks.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tasks API endpoints for CRUD operations on tasks.
|
| 3 |
+
|
| 4 |
+
This module provides:
|
| 5 |
+
- GET /api/tasks - List all user tasks
|
| 6 |
+
- POST /api/tasks - Create new task
|
| 7 |
+
- PUT /api/tasks/{id} - Update existing task
|
| 8 |
+
- DELETE /api/tasks/{id} - Delete task
|
| 9 |
+
|
| 10 |
+
All endpoints require JWT authentication and enforce user isolation.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 14 |
+
from sqlmodel import Session
|
| 15 |
+
from pydantic import BaseModel, Field
|
| 16 |
+
from typing import Optional, List
|
| 17 |
+
|
| 18 |
+
from ..models.task import Task
|
| 19 |
+
from ..services import tasks as task_service
|
| 20 |
+
from ..middleware.jwt_auth import get_current_user_id
|
| 21 |
+
from ..database import get_session
|
| 22 |
+
|
| 23 |
+
router = APIRouter()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Request/Response Models
|
| 27 |
+
class CreateTaskRequest(BaseModel):
|
| 28 |
+
"""Request model for creating a task."""
|
| 29 |
+
title: str = Field(..., min_length=1, max_length=500, description="Task title")
|
| 30 |
+
description: Optional[str] = Field(None, description="Optional task description")
|
| 31 |
+
category: Optional[str] = Field(None, max_length=50, description="Task category/tag")
|
| 32 |
+
due_date: Optional[str] = Field(None, description="Due date in ISO format")
|
| 33 |
+
priority: Optional[str] = Field("medium", description="Task priority: low, medium, high")
|
| 34 |
+
is_recurring: Optional[bool] = Field(False, description="Whether task is recurring")
|
| 35 |
+
recurrence_type: Optional[str] = Field(None, description="Recurrence type: daily, weekly, monthly, yearly")
|
| 36 |
+
recurrence_interval: Optional[int] = Field(1, description="Recurrence interval (e.g., every 2 days)")
|
| 37 |
+
recurrence_end_date: Optional[str] = Field(None, description="Recurrence end date in ISO format")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class UpdateTaskRequest(BaseModel):
|
| 41 |
+
"""Request model for updating a task."""
|
| 42 |
+
title: Optional[str] = Field(None, min_length=1, max_length=500, description="Task title")
|
| 43 |
+
description: Optional[str] = Field(None, description="Task description")
|
| 44 |
+
completed: Optional[bool] = Field(None, description="Task completion status")
|
| 45 |
+
category: Optional[str] = Field(None, max_length=50, description="Task category/tag")
|
| 46 |
+
due_date: Optional[str] = Field(None, description="Due date in ISO format")
|
| 47 |
+
priority: Optional[str] = Field(None, description="Task priority: low, medium, high")
|
| 48 |
+
is_recurring: Optional[bool] = Field(None, description="Whether task is recurring")
|
| 49 |
+
recurrence_type: Optional[str] = Field(None, description="Recurrence type: daily, weekly, monthly, yearly")
|
| 50 |
+
recurrence_interval: Optional[int] = Field(None, description="Recurrence interval")
|
| 51 |
+
recurrence_end_date: Optional[str] = Field(None, description="Recurrence end date in ISO format")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class TaskResponse(BaseModel):
|
| 55 |
+
"""Task data response model."""
|
| 56 |
+
id: int
|
| 57 |
+
user_id: int
|
| 58 |
+
title: str
|
| 59 |
+
description: Optional[str]
|
| 60 |
+
completed: bool
|
| 61 |
+
category: Optional[str]
|
| 62 |
+
due_date: Optional[str]
|
| 63 |
+
priority: Optional[str]
|
| 64 |
+
is_recurring: bool
|
| 65 |
+
recurrence_type: Optional[str]
|
| 66 |
+
recurrence_interval: Optional[int]
|
| 67 |
+
recurrence_end_date: Optional[str]
|
| 68 |
+
parent_task_id: Optional[int]
|
| 69 |
+
created_at: str
|
| 70 |
+
updated_at: str
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class TaskListResponse(BaseModel):
|
| 74 |
+
"""Response model for task list."""
|
| 75 |
+
tasks: List[TaskResponse]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.get("", response_model=TaskListResponse)
|
| 79 |
+
async def list_tasks(
|
| 80 |
+
user_id: int = Depends(get_current_user_id),
|
| 81 |
+
session: Session = Depends(get_session)
|
| 82 |
+
) -> TaskListResponse:
|
| 83 |
+
"""
|
| 84 |
+
Get all tasks for the authenticated user.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
user_id: Current user ID from JWT token
|
| 88 |
+
session: Database session
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
TaskListResponse with array of user's tasks
|
| 92 |
+
|
| 93 |
+
Raises:
|
| 94 |
+
HTTPException 401: If JWT token is invalid
|
| 95 |
+
"""
|
| 96 |
+
# Get user tasks
|
| 97 |
+
tasks = task_service.get_user_tasks(session, user_id)
|
| 98 |
+
|
| 99 |
+
# Convert to response format
|
| 100 |
+
task_responses = [
|
| 101 |
+
TaskResponse(
|
| 102 |
+
id=task.id,
|
| 103 |
+
user_id=task.user_id,
|
| 104 |
+
title=task.title,
|
| 105 |
+
description=task.description,
|
| 106 |
+
completed=task.completed,
|
| 107 |
+
category=task.category,
|
| 108 |
+
due_date=task.due_date.isoformat() if task.due_date else None,
|
| 109 |
+
priority=task.priority,
|
| 110 |
+
is_recurring=task.is_recurring,
|
| 111 |
+
recurrence_type=task.recurrence_type,
|
| 112 |
+
recurrence_interval=task.recurrence_interval,
|
| 113 |
+
recurrence_end_date=task.recurrence_end_date.isoformat() if task.recurrence_end_date else None,
|
| 114 |
+
parent_task_id=task.parent_task_id,
|
| 115 |
+
created_at=task.created_at.isoformat(),
|
| 116 |
+
updated_at=task.updated_at.isoformat()
|
| 117 |
+
)
|
| 118 |
+
for task in tasks
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
return TaskListResponse(tasks=task_responses)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
| 125 |
+
async def create_task(
|
| 126 |
+
request: CreateTaskRequest,
|
| 127 |
+
user_id: int = Depends(get_current_user_id),
|
| 128 |
+
session: Session = Depends(get_session)
|
| 129 |
+
) -> TaskResponse:
|
| 130 |
+
"""
|
| 131 |
+
Create a new task for the authenticated user.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
request: Task creation request with title and optional description
|
| 135 |
+
user_id: Current user ID from JWT token
|
| 136 |
+
session: Database session
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
TaskResponse with created task data
|
| 140 |
+
|
| 141 |
+
Raises:
|
| 142 |
+
HTTPException 401: If JWT token is invalid
|
| 143 |
+
HTTPException 422: If validation fails
|
| 144 |
+
"""
|
| 145 |
+
# Create task
|
| 146 |
+
task = task_service.create_task(
|
| 147 |
+
session=session,
|
| 148 |
+
user_id=user_id,
|
| 149 |
+
title=request.title,
|
| 150 |
+
description=request.description,
|
| 151 |
+
category=request.category,
|
| 152 |
+
due_date=request.due_date,
|
| 153 |
+
priority=request.priority,
|
| 154 |
+
is_recurring=request.is_recurring or False,
|
| 155 |
+
recurrence_type=request.recurrence_type,
|
| 156 |
+
recurrence_interval=request.recurrence_interval or 1,
|
| 157 |
+
recurrence_end_date=request.recurrence_end_date
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# Return response
|
| 161 |
+
return TaskResponse(
|
| 162 |
+
id=task.id,
|
| 163 |
+
user_id=task.user_id,
|
| 164 |
+
title=task.title,
|
| 165 |
+
description=task.description,
|
| 166 |
+
completed=task.completed,
|
| 167 |
+
category=task.category,
|
| 168 |
+
due_date=task.due_date.isoformat() if task.due_date else None,
|
| 169 |
+
priority=task.priority,
|
| 170 |
+
is_recurring=task.is_recurring,
|
| 171 |
+
recurrence_type=task.recurrence_type,
|
| 172 |
+
recurrence_interval=task.recurrence_interval,
|
| 173 |
+
recurrence_end_date=task.recurrence_end_date.isoformat() if task.recurrence_end_date else None,
|
| 174 |
+
parent_task_id=task.parent_task_id,
|
| 175 |
+
created_at=task.created_at.isoformat(),
|
| 176 |
+
updated_at=task.updated_at.isoformat()
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.put("/{task_id}", response_model=TaskResponse)
|
| 181 |
+
async def update_task(
|
| 182 |
+
task_id: int,
|
| 183 |
+
request: UpdateTaskRequest,
|
| 184 |
+
user_id: int = Depends(get_current_user_id),
|
| 185 |
+
session: Session = Depends(get_session)
|
| 186 |
+
) -> TaskResponse:
|
| 187 |
+
"""
|
| 188 |
+
Update an existing task.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
task_id: ID of the task to update
|
| 192 |
+
request: Task update request with optional fields
|
| 193 |
+
user_id: Current user ID from JWT token
|
| 194 |
+
session: Database session
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
TaskResponse with updated task data
|
| 198 |
+
|
| 199 |
+
Raises:
|
| 200 |
+
HTTPException 401: If JWT token is invalid
|
| 201 |
+
HTTPException 404: If task not found or doesn't belong to user
|
| 202 |
+
"""
|
| 203 |
+
# Update task
|
| 204 |
+
task = task_service.update_task(
|
| 205 |
+
session=session,
|
| 206 |
+
task_id=task_id,
|
| 207 |
+
user_id=user_id,
|
| 208 |
+
title=request.title,
|
| 209 |
+
description=request.description,
|
| 210 |
+
completed=request.completed,
|
| 211 |
+
category=request.category,
|
| 212 |
+
due_date=request.due_date,
|
| 213 |
+
priority=request.priority,
|
| 214 |
+
is_recurring=request.is_recurring,
|
| 215 |
+
recurrence_type=request.recurrence_type,
|
| 216 |
+
recurrence_interval=request.recurrence_interval,
|
| 217 |
+
recurrence_end_date=request.recurrence_end_date
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
if not task:
|
| 221 |
+
raise HTTPException(
|
| 222 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 223 |
+
detail="Task not found"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Return response
|
| 227 |
+
return TaskResponse(
|
| 228 |
+
id=task.id,
|
| 229 |
+
user_id=task.user_id,
|
| 230 |
+
title=task.title,
|
| 231 |
+
description=task.description,
|
| 232 |
+
completed=task.completed,
|
| 233 |
+
category=task.category,
|
| 234 |
+
due_date=task.due_date.isoformat() if task.due_date else None,
|
| 235 |
+
priority=task.priority,
|
| 236 |
+
is_recurring=task.is_recurring,
|
| 237 |
+
recurrence_type=task.recurrence_type,
|
| 238 |
+
recurrence_interval=task.recurrence_interval,
|
| 239 |
+
recurrence_end_date=task.recurrence_end_date.isoformat() if task.recurrence_end_date else None,
|
| 240 |
+
parent_task_id=task.parent_task_id,
|
| 241 |
+
created_at=task.created_at.isoformat(),
|
| 242 |
+
updated_at=task.updated_at.isoformat()
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 247 |
+
async def delete_task(
|
| 248 |
+
task_id: int,
|
| 249 |
+
user_id: int = Depends(get_current_user_id),
|
| 250 |
+
session: Session = Depends(get_session)
|
| 251 |
+
) -> None:
|
| 252 |
+
"""
|
| 253 |
+
Delete a task.
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
task_id: ID of the task to delete
|
| 257 |
+
user_id: Current user ID from JWT token
|
| 258 |
+
session: Database session
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
None (204 No Content)
|
| 262 |
+
|
| 263 |
+
Raises:
|
| 264 |
+
HTTPException 401: If JWT token is invalid
|
| 265 |
+
HTTPException 404: If task not found or doesn't belong to user
|
| 266 |
+
"""
|
| 267 |
+
# Delete task
|
| 268 |
+
success = task_service.delete_task(
|
| 269 |
+
session=session,
|
| 270 |
+
task_id=task_id,
|
| 271 |
+
user_id=user_id
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
if not success:
|
| 275 |
+
raise HTTPException(
|
| 276 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 277 |
+
detail="Task not found"
|
| 278 |
+
)
|
src/database.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database configuration and session management.
|
| 3 |
+
|
| 4 |
+
This module provides:
|
| 5 |
+
- Database engine creation
|
| 6 |
+
- Session management
|
| 7 |
+
- Dependency injection for FastAPI routes
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
from typing import Generator
|
| 12 |
+
|
| 13 |
+
from sqlmodel import Session, create_engine, SQLModel
|
| 14 |
+
|
| 15 |
+
# Get database URL from environment variable
|
| 16 |
+
# For Vercel serverless, use /tmp directory for SQLite
|
| 17 |
+
DATABASE_URL = os.getenv("DATABASE_URL")
|
| 18 |
+
|
| 19 |
+
if DATABASE_URL is None:
|
| 20 |
+
# Check if running on Vercel (serverless environment)
|
| 21 |
+
if os.getenv("VERCEL"):
|
| 22 |
+
# Use /tmp directory which is writable in Vercel serverless
|
| 23 |
+
DATABASE_URL = "sqlite:////tmp/todo.db"
|
| 24 |
+
else:
|
| 25 |
+
# Local development
|
| 26 |
+
DATABASE_URL = "sqlite:///./todo.db"
|
| 27 |
+
|
| 28 |
+
# Create database engine
|
| 29 |
+
# connect_args only needed for SQLite
|
| 30 |
+
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
| 31 |
+
|
| 32 |
+
engine = create_engine(
|
| 33 |
+
DATABASE_URL,
|
| 34 |
+
echo=False, # Disable SQL query logging for serverless
|
| 35 |
+
connect_args=connect_args,
|
| 36 |
+
pool_pre_ping=True, # Verify connections before using
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def create_db_and_tables():
|
| 41 |
+
"""Create all database tables."""
|
| 42 |
+
SQLModel.metadata.create_all(engine)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_session() -> Generator[Session, None, None]:
|
| 46 |
+
"""
|
| 47 |
+
Dependency function to provide database session to FastAPI routes.
|
| 48 |
+
|
| 49 |
+
Yields:
|
| 50 |
+
Session: SQLModel database session
|
| 51 |
+
|
| 52 |
+
Example:
|
| 53 |
+
@app.get("/items")
|
| 54 |
+
def get_items(session: Session = Depends(get_session)):
|
| 55 |
+
items = session.exec(select(Item)).all()
|
| 56 |
+
return items
|
| 57 |
+
"""
|
| 58 |
+
with Session(engine) as session:
|
| 59 |
+
yield session
|
src/main.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# Load environment variables
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
# Create FastAPI application
|
| 10 |
+
app = FastAPI(
|
| 11 |
+
title="Todo Application API",
|
| 12 |
+
description="Backend API for Todo application with JWT authentication",
|
| 13 |
+
version="1.0.0",
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# CORS Configuration
|
| 17 |
+
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,http://localhost:3004,http://localhost:3005").split(",")
|
| 18 |
+
|
| 19 |
+
# Configure CORS middleware
|
| 20 |
+
app.add_middleware(
|
| 21 |
+
CORSMiddleware,
|
| 22 |
+
allow_origins=CORS_ORIGINS,
|
| 23 |
+
allow_credentials=True,
|
| 24 |
+
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
| 25 |
+
allow_headers=["*"],
|
| 26 |
+
expose_headers=["*"],
|
| 27 |
+
max_age=3600,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# Initialize database tables on startup
|
| 31 |
+
from src.database import create_db_and_tables
|
| 32 |
+
|
| 33 |
+
@app.on_event("startup")
|
| 34 |
+
def on_startup():
|
| 35 |
+
"""Initialize database tables on application startup."""
|
| 36 |
+
try:
|
| 37 |
+
create_db_and_tables()
|
| 38 |
+
except Exception as e:
|
| 39 |
+
print(f"Warning: Could not initialize database tables: {e}")
|
| 40 |
+
# Continue anyway - tables might already exist
|
| 41 |
+
|
| 42 |
+
# Health check endpoint
|
| 43 |
+
@app.get("/health")
|
| 44 |
+
async def health_check():
|
| 45 |
+
"""Health check endpoint to verify API is running."""
|
| 46 |
+
return {"status": "healthy"}
|
| 47 |
+
|
| 48 |
+
# Root endpoint
|
| 49 |
+
@app.get("/")
|
| 50 |
+
async def root():
|
| 51 |
+
"""Root endpoint with API information."""
|
| 52 |
+
return {
|
| 53 |
+
"message": "Todo Application API",
|
| 54 |
+
"version": "1.0.0",
|
| 55 |
+
"docs": "/docs",
|
| 56 |
+
"health": "/health"
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
# Router registration
|
| 60 |
+
from src.api import auth, tasks, subtasks, password_reset
|
| 61 |
+
# AI router temporarily disabled due to Vercel size constraints
|
| 62 |
+
# from src.api import ai
|
| 63 |
+
|
| 64 |
+
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
| 65 |
+
app.include_router(password_reset.router, prefix="/api/auth", tags=["Password Reset"])
|
| 66 |
+
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
|
| 67 |
+
app.include_router(subtasks.router, prefix="/api", tags=["Subtasks"])
|
| 68 |
+
# app.include_router(ai.router, prefix="/api/ai", tags=["AI Features"])
|
src/main_minimal.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
app = FastAPI(title="Todo API - Minimal Test")
|
| 5 |
+
|
| 6 |
+
@app.get("/")
|
| 7 |
+
def root():
|
| 8 |
+
return {
|
| 9 |
+
"status": "ok",
|
| 10 |
+
"message": "Railway FastAPI is working!",
|
| 11 |
+
"port": os.getenv("PORT", "not set"),
|
| 12 |
+
"database": "connected" if os.getenv("DATABASE_URL") else "not configured"
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
@app.get("/health")
|
| 16 |
+
def health():
|
| 17 |
+
return {"status": "healthy", "service": "railway-test"}
|
| 18 |
+
|
| 19 |
+
@app.get("/api/health")
|
| 20 |
+
def api_health():
|
| 21 |
+
return {"status": "healthy", "api": "working"}
|
src/middleware/__pycache__/jwt_auth.cpython-314.pyc
ADDED
|
Binary file (3.55 kB). View file
|
|
|
src/middleware/jwt_auth.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Request, HTTPException, status, Depends
|
| 2 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 3 |
+
from jose import JWTError, jwt
|
| 4 |
+
from typing import Optional
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
# JWT Configuration
|
| 8 |
+
SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-here")
|
| 9 |
+
ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
|
| 10 |
+
|
| 11 |
+
security = HTTPBearer()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def verify_jwt_token(credentials: HTTPAuthorizationCredentials) -> dict:
|
| 15 |
+
"""
|
| 16 |
+
Verify JWT token and return payload.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
credentials: HTTP Authorization credentials with Bearer token
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
dict: JWT payload containing user_id and other claims
|
| 23 |
+
|
| 24 |
+
Raises:
|
| 25 |
+
HTTPException: If token is invalid or expired
|
| 26 |
+
"""
|
| 27 |
+
try:
|
| 28 |
+
token = credentials.credentials
|
| 29 |
+
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
| 30 |
+
user_id: Optional[int] = payload.get("user_id")
|
| 31 |
+
|
| 32 |
+
if user_id is None:
|
| 33 |
+
raise HTTPException(
|
| 34 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 35 |
+
detail="Invalid authentication credentials",
|
| 36 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
return payload
|
| 40 |
+
|
| 41 |
+
except JWTError:
|
| 42 |
+
raise HTTPException(
|
| 43 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 44 |
+
detail="Could not validate credentials",
|
| 45 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> int:
|
| 50 |
+
"""
|
| 51 |
+
Extract user_id from JWT token.
|
| 52 |
+
|
| 53 |
+
This function is used as a dependency in FastAPI routes to get the
|
| 54 |
+
authenticated user's ID from the JWT token.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
credentials: HTTP Authorization credentials (injected by FastAPI)
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
int: The authenticated user's ID
|
| 61 |
+
|
| 62 |
+
Raises:
|
| 63 |
+
HTTPException: If token is invalid or user_id is missing
|
| 64 |
+
"""
|
| 65 |
+
payload = await verify_jwt_token(credentials)
|
| 66 |
+
return payload["user_id"]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
|
| 70 |
+
"""
|
| 71 |
+
Extract full user payload from JWT token.
|
| 72 |
+
|
| 73 |
+
This function is used as a dependency in FastAPI routes to get the
|
| 74 |
+
authenticated user's full information from the JWT token.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
credentials: HTTP Authorization credentials (injected by FastAPI)
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
dict: The JWT payload containing user information
|
| 81 |
+
|
| 82 |
+
Raises:
|
| 83 |
+
HTTPException: If token is invalid
|
| 84 |
+
"""
|
| 85 |
+
payload = await verify_jwt_token(credentials)
|
| 86 |
+
return payload
|
src/models/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .user import User
|
| 2 |
+
from .task import Task
|
| 3 |
+
from .subtask import Subtask
|
| 4 |
+
from .password_reset import PasswordResetToken
|
| 5 |
+
|
| 6 |
+
__all__ = ["User", "Task", "Subtask", "PasswordResetToken"]
|
src/models/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (421 Bytes). View file
|
|
|
src/models/__pycache__/password_reset.cpython-314.pyc
ADDED
|
Binary file (2.09 kB). View file
|
|
|
src/models/__pycache__/subtask.cpython-314.pyc
ADDED
|
Binary file (2.11 kB). View file
|
|
|
src/models/__pycache__/task.cpython-314.pyc
ADDED
|
Binary file (3.18 kB). View file
|
|
|
src/models/__pycache__/user.cpython-314.pyc
ADDED
|
Binary file (1.95 kB). View file
|
|
|
src/models/password_reset.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field, Relationship
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
class PasswordResetToken(SQLModel, table=True):
|
| 6 |
+
"""Password reset token model for secure password recovery."""
|
| 7 |
+
|
| 8 |
+
__tablename__ = "password_reset_tokens"
|
| 9 |
+
|
| 10 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 11 |
+
user_id: int = Field(foreign_key="users.id", index=True)
|
| 12 |
+
token: str = Field(unique=True, index=True, max_length=255)
|
| 13 |
+
expires_at: datetime = Field(index=True)
|
| 14 |
+
used: bool = Field(default=False)
|
| 15 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 16 |
+
|
| 17 |
+
# Relationships
|
| 18 |
+
user: Optional["User"] = Relationship()
|
| 19 |
+
|
| 20 |
+
class Config:
|
| 21 |
+
json_schema_extra = {
|
| 22 |
+
"example": {
|
| 23 |
+
"id": 1,
|
| 24 |
+
"user_id": 1,
|
| 25 |
+
"token": "abc123def456...",
|
| 26 |
+
"expires_at": "2026-02-07T12:15:00Z",
|
| 27 |
+
"used": False,
|
| 28 |
+
"created_at": "2026-02-07T12:00:00Z"
|
| 29 |
+
}
|
| 30 |
+
}
|
src/models/subtask.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field, Relationship
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
class Subtask(SQLModel, table=True):
|
| 6 |
+
"""Subtask model representing a checklist item within a task."""
|
| 7 |
+
|
| 8 |
+
__tablename__ = "subtasks"
|
| 9 |
+
|
| 10 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 11 |
+
task_id: int = Field(foreign_key="tasks.id", index=True)
|
| 12 |
+
title: str = Field(max_length=500)
|
| 13 |
+
completed: bool = Field(default=False)
|
| 14 |
+
order: int = Field(default=0) # For ordering subtasks
|
| 15 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 16 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 17 |
+
|
| 18 |
+
# Relationships
|
| 19 |
+
task: "Task" = Relationship(back_populates="subtasks")
|
| 20 |
+
|
| 21 |
+
class Config:
|
| 22 |
+
json_schema_extra = {
|
| 23 |
+
"example": {
|
| 24 |
+
"id": 1,
|
| 25 |
+
"task_id": 42,
|
| 26 |
+
"title": "Review documentation",
|
| 27 |
+
"completed": False,
|
| 28 |
+
"order": 0,
|
| 29 |
+
"created_at": "2026-02-05T10:00:00Z",
|
| 30 |
+
"updated_at": "2026-02-05T10:00:00Z"
|
| 31 |
+
}
|
| 32 |
+
}
|
src/models/task.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field, Relationship
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional, List, TYPE_CHECKING
|
| 4 |
+
|
| 5 |
+
if TYPE_CHECKING:
|
| 6 |
+
from .subtask import Subtask
|
| 7 |
+
|
| 8 |
+
class Task(SQLModel, table=True):
|
| 9 |
+
"""Task model representing a work item belonging to a user."""
|
| 10 |
+
|
| 11 |
+
__tablename__ = "tasks"
|
| 12 |
+
|
| 13 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 14 |
+
user_id: int = Field(foreign_key="users.id", index=True)
|
| 15 |
+
title: str = Field(max_length=500)
|
| 16 |
+
description: Optional[str] = Field(default=None)
|
| 17 |
+
completed: bool = Field(default=False)
|
| 18 |
+
category: Optional[str] = Field(default=None, max_length=50)
|
| 19 |
+
due_date: Optional[datetime] = Field(default=None)
|
| 20 |
+
priority: Optional[str] = Field(default="medium", max_length=20) # low, medium, high
|
| 21 |
+
|
| 22 |
+
# Recurring task fields
|
| 23 |
+
is_recurring: bool = Field(default=False)
|
| 24 |
+
recurrence_type: Optional[str] = Field(default=None, max_length=20) # daily, weekly, monthly, yearly
|
| 25 |
+
recurrence_interval: Optional[int] = Field(default=1) # e.g., every 2 days, every 3 weeks
|
| 26 |
+
recurrence_end_date: Optional[datetime] = Field(default=None)
|
| 27 |
+
parent_task_id: Optional[int] = Field(default=None, foreign_key="tasks.id") # For recurring instances
|
| 28 |
+
|
| 29 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 30 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 31 |
+
|
| 32 |
+
# Relationships
|
| 33 |
+
user: "User" = Relationship(back_populates="tasks")
|
| 34 |
+
subtasks: List["Subtask"] = Relationship(back_populates="task")
|
| 35 |
+
|
| 36 |
+
class Config:
|
| 37 |
+
json_schema_extra = {
|
| 38 |
+
"example": {
|
| 39 |
+
"id": 1,
|
| 40 |
+
"user_id": 42,
|
| 41 |
+
"title": "Buy groceries",
|
| 42 |
+
"description": "Milk, eggs, bread",
|
| 43 |
+
"completed": False,
|
| 44 |
+
"category": "Personal",
|
| 45 |
+
"due_date": "2026-02-10T10:00:00Z",
|
| 46 |
+
"created_at": "2026-02-05T10:00:00Z",
|
| 47 |
+
"updated_at": "2026-02-05T10:00:00Z"
|
| 48 |
+
}
|
| 49 |
+
}
|
src/models/user.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field, Relationship
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional, List
|
| 4 |
+
|
| 5 |
+
class User(SQLModel, table=True):
|
| 6 |
+
"""User model representing an authenticated user of the application."""
|
| 7 |
+
|
| 8 |
+
__tablename__ = "users"
|
| 9 |
+
|
| 10 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 11 |
+
email: str = Field(unique=True, index=True, max_length=255)
|
| 12 |
+
hashed_password: str = Field(max_length=255)
|
| 13 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 14 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 15 |
+
|
| 16 |
+
# Relationships
|
| 17 |
+
tasks: List["Task"] = Relationship(back_populates="user")
|
| 18 |
+
|
| 19 |
+
class Config:
|
| 20 |
+
json_schema_extra = {
|
| 21 |
+
"example": {
|
| 22 |
+
"id": 1,
|
| 23 |
+
"email": "user@example.com",
|
| 24 |
+
"created_at": "2026-02-05T10:00:00Z",
|
| 25 |
+
"updated_at": "2026-02-05T10:00:00Z"
|
| 26 |
+
}
|
| 27 |
+
}
|
src/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Services module
|
src/services/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (211 Bytes). View file
|
|
|
src/services/__pycache__/auth.cpython-314.pyc
ADDED
|
Binary file (4.72 kB). View file
|
|
|
src/services/__pycache__/email.cpython-314.pyc
ADDED
|
Binary file (9.1 kB). View file
|
|
|