Spaces:
Sleeping
Sleeping
suhail
commited on
Commit
·
7ffe51d
1
Parent(s):
7cebe69
Initial deployment
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +45 -0
- .env +13 -0
- .env.example +12 -0
- Dockerfile +26 -0
- README.md +309 -11
- README_HF.md +33 -0
- alembic.ini +112 -0
- alembic/__pycache__/env.cpython-313.pyc +0 -0
- alembic/env.py +63 -0
- alembic/script.py.mako +24 -0
- alembic/versions/001_initial.py +63 -0
- alembic/versions/002_add_user_password.py +26 -0
- alembic/versions/__pycache__/001_initial.cpython-313.pyc +0 -0
- alembic/versions/__pycache__/002_add_user_password.cpython-313.pyc +0 -0
- deploy-prepare.ps1 +129 -0
- deploy-prepare.sh +125 -0
- requirements.txt +12 -0
- src/__pycache__/main.cpython-313.pyc +0 -0
- src/api/__init__.py +1 -0
- src/api/__pycache__/__init__.cpython-313.pyc +0 -0
- src/api/__pycache__/deps.cpython-313.pyc +0 -0
- src/api/deps.py +55 -0
- src/api/routes/__init__.py +1 -0
- src/api/routes/__pycache__/__init__.cpython-313.pyc +0 -0
- src/api/routes/__pycache__/auth.cpython-313.pyc +0 -0
- src/api/routes/__pycache__/tasks.cpython-313.pyc +0 -0
- src/api/routes/auth.py +88 -0
- src/api/routes/tasks.py +179 -0
- src/core/__init__.py +1 -0
- src/core/__pycache__/__init__.cpython-313.pyc +0 -0
- src/core/__pycache__/config.cpython-313.pyc +0 -0
- src/core/__pycache__/database.cpython-313.pyc +0 -0
- src/core/__pycache__/security.cpython-313.pyc +0 -0
- src/core/config.py +27 -0
- src/core/database.py +17 -0
- src/core/security.py +110 -0
- src/main.py +34 -0
- src/models/__init__.py +5 -0
- src/models/__pycache__/__init__.cpython-313.pyc +0 -0
- src/models/__pycache__/task.cpython-313.pyc +0 -0
- src/models/__pycache__/user.cpython-313.pyc +0 -0
- src/models/task.py +17 -0
- src/models/user.py +16 -0
- src/schemas/__init__.py +1 -0
- src/schemas/__pycache__/__init__.cpython-313.pyc +0 -0
- src/schemas/__pycache__/auth.cpython-313.pyc +0 -0
- src/schemas/__pycache__/task.cpython-313.pyc +0 -0
- src/schemas/auth.py +47 -0
- src/schemas/task.py +102 -0
- src/services/__init__.py +1 -0
.dockerignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
env/
|
| 8 |
+
venv/
|
| 9 |
+
ENV/
|
| 10 |
+
.venv
|
| 11 |
+
|
| 12 |
+
# Testing
|
| 13 |
+
.pytest_cache/
|
| 14 |
+
.coverage
|
| 15 |
+
htmlcov/
|
| 16 |
+
*.cover
|
| 17 |
+
|
| 18 |
+
# IDE
|
| 19 |
+
.vscode/
|
| 20 |
+
.idea/
|
| 21 |
+
*.swp
|
| 22 |
+
*.swo
|
| 23 |
+
*~
|
| 24 |
+
|
| 25 |
+
# Environment
|
| 26 |
+
.env
|
| 27 |
+
.env.local
|
| 28 |
+
.env.*.local
|
| 29 |
+
|
| 30 |
+
# Database
|
| 31 |
+
*.db
|
| 32 |
+
*.sqlite
|
| 33 |
+
|
| 34 |
+
# Logs
|
| 35 |
+
*.log
|
| 36 |
+
|
| 37 |
+
# OS
|
| 38 |
+
.DS_Store
|
| 39 |
+
Thumbs.db
|
| 40 |
+
|
| 41 |
+
# Alembic
|
| 42 |
+
alembic/versions/*.pyc
|
| 43 |
+
|
| 44 |
+
# Documentation
|
| 45 |
+
docs/_build/
|
.env
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Database Configuration
|
| 2 |
+
# For local PostgreSQL: postgresql://user:password@localhost:5432/todo_db
|
| 3 |
+
# For Neon: Use your Neon connection string from the dashboard
|
| 4 |
+
DATABASE_URL=postgresql://neondb_owner:npg_MmFvJBHT8Y0k@ep-silent-thunder-ab0rbvrp-pooler.eu-west-2.aws.neon.tech/neondb?sslmode=require&channel_binding=require
|
| 5 |
+
# Application Settings
|
| 6 |
+
APP_NAME=Task CRUD API
|
| 7 |
+
DEBUG=True
|
| 8 |
+
CORS_ORIGINS=http://localhost:3000
|
| 9 |
+
|
| 10 |
+
# Authentication
|
| 11 |
+
BETTER_AUTH_SECRET=zMdW1P03wJvWJnLKzQ8YYO26vHeinqmR
|
| 12 |
+
JWT_ALGORITHM=HS256
|
| 13 |
+
JWT_EXPIRATION_DAYS=7
|
.env.example
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Database Configuration
|
| 2 |
+
DATABASE_URL=postgresql://user:password@host:5432/database
|
| 3 |
+
|
| 4 |
+
# Application Settings
|
| 5 |
+
APP_NAME=Task CRUD API
|
| 6 |
+
DEBUG=True
|
| 7 |
+
CORS_ORIGINS=http://localhost:3000
|
| 8 |
+
|
| 9 |
+
# Authentication (Placeholder for Spec 2)
|
| 10 |
+
# JWT_SECRET=your-secret-key-here
|
| 11 |
+
# JWT_ALGORITHM=HS256
|
| 12 |
+
# JWT_EXPIRATION_MINUTES=1440
|
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
gcc \
|
| 10 |
+
postgresql-client \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
# Copy requirements first for better caching
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
|
| 16 |
+
# Install Python dependencies
|
| 17 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 18 |
+
|
| 19 |
+
# Copy application code
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
# Expose port 7860 (Hugging Face Spaces default)
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
# Run database migrations and start the application
|
| 26 |
+
CMD alembic upgrade head && uvicorn src.main:app --host 0.0.0.0 --port 7860
|
README.md
CHANGED
|
@@ -1,11 +1,309 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Task CRUD API - Backend
|
| 2 |
+
|
| 3 |
+
FastAPI backend for the Task Manager application with full CRUD operations, filtering, and sorting.
|
| 4 |
+
|
| 5 |
+
## Tech Stack
|
| 6 |
+
|
| 7 |
+
- **Python**: 3.11+
|
| 8 |
+
- **FastAPI**: 0.104+ (Web framework)
|
| 9 |
+
- **SQLModel**: 0.0.14+ (ORM)
|
| 10 |
+
- **Alembic**: 1.13.0 (Database migrations)
|
| 11 |
+
- **PostgreSQL**: Neon Serverless or local PostgreSQL
|
| 12 |
+
- **Pydantic**: 2.x (Data validation)
|
| 13 |
+
|
| 14 |
+
## Project Structure
|
| 15 |
+
|
| 16 |
+
```
|
| 17 |
+
backend/
|
| 18 |
+
├── src/
|
| 19 |
+
│ ├── api/
|
| 20 |
+
│ │ ├── deps.py # Dependency injection (DB session, auth stub)
|
| 21 |
+
│ │ └── routes/
|
| 22 |
+
│ │ └── tasks.py # Task CRUD endpoints
|
| 23 |
+
│ ├── core/
|
| 24 |
+
│ │ ├── config.py # Application settings
|
| 25 |
+
│ │ └── database.py # Database connection
|
| 26 |
+
│ ├── models/
|
| 27 |
+
│ │ ├── user.py # User model (stub)
|
| 28 |
+
│ │ └── task.py # Task model
|
| 29 |
+
│ ├── schemas/
|
| 30 |
+
│ │ └── task.py # Pydantic schemas
|
| 31 |
+
│ ├── services/
|
| 32 |
+
│ │ └── task_service.py # Business logic
|
| 33 |
+
│ └── main.py # FastAPI application
|
| 34 |
+
├── alembic/
|
| 35 |
+
│ ├── versions/
|
| 36 |
+
│ │ └── 001_initial.py # Initial migration
|
| 37 |
+
│ └── env.py # Alembic configuration
|
| 38 |
+
├── tests/ # Test directory (to be implemented)
|
| 39 |
+
├── .env # Environment variables
|
| 40 |
+
├── .env.example # Environment template
|
| 41 |
+
├── alembic.ini # Alembic configuration
|
| 42 |
+
└── requirements.txt # Python dependencies
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## Setup Instructions
|
| 46 |
+
|
| 47 |
+
### 1. Install Dependencies
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
cd backend
|
| 51 |
+
pip install -r requirements.txt
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
### 2. Configure Environment
|
| 55 |
+
|
| 56 |
+
Copy `.env.example` to `.env` and configure:
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
cp .env.example .env
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
Edit `.env`:
|
| 63 |
+
|
| 64 |
+
```env
|
| 65 |
+
# For Neon PostgreSQL (recommended)
|
| 66 |
+
DATABASE_URL=postgresql://user:password@ep-xxx.neon.tech/dbname?sslmode=require
|
| 67 |
+
|
| 68 |
+
# OR for local PostgreSQL
|
| 69 |
+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/todo_db
|
| 70 |
+
|
| 71 |
+
APP_NAME=Task CRUD API
|
| 72 |
+
DEBUG=True
|
| 73 |
+
CORS_ORIGINS=http://localhost:3000
|
| 74 |
+
|
| 75 |
+
# Authentication (REQUIRED)
|
| 76 |
+
BETTER_AUTH_SECRET=<generate-32-char-random-string>
|
| 77 |
+
JWT_ALGORITHM=HS256
|
| 78 |
+
JWT_EXPIRATION_DAYS=7
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
**Generate BETTER_AUTH_SECRET:**
|
| 82 |
+
```bash
|
| 83 |
+
# Use Python to generate a secure random secret
|
| 84 |
+
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
| 85 |
+
|
| 86 |
+
# IMPORTANT: Use the SAME secret in both backend/.env and frontend/.env.local
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### 3. Run Database Migrations
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
# Apply migrations to create tables
|
| 93 |
+
python -m alembic upgrade head
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
### 4. Start Development Server
|
| 97 |
+
|
| 98 |
+
```bash
|
| 99 |
+
# Start with auto-reload
|
| 100 |
+
uvicorn src.main:app --reload
|
| 101 |
+
|
| 102 |
+
# Server runs at http://localhost:8000
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
## API Endpoints
|
| 106 |
+
|
| 107 |
+
### Authentication
|
| 108 |
+
|
| 109 |
+
| Method | Endpoint | Description | Auth Required |
|
| 110 |
+
|--------|----------|-------------|---------------|
|
| 111 |
+
| POST | `/api/auth/signup` | Register new user account | No |
|
| 112 |
+
| POST | `/api/auth/signin` | Authenticate and receive JWT token | No |
|
| 113 |
+
| GET | `/api/auth/me` | Get current user profile | Yes |
|
| 114 |
+
|
| 115 |
+
### Tasks
|
| 116 |
+
|
| 117 |
+
| Method | Endpoint | Description | Auth Required |
|
| 118 |
+
|--------|----------|-------------|---------------|
|
| 119 |
+
| GET | `/api/tasks` | List tasks with filtering and sorting | Yes |
|
| 120 |
+
| POST | `/api/tasks` | Create a new task | Yes |
|
| 121 |
+
| GET | `/api/tasks/{id}` | Get a single task | Yes |
|
| 122 |
+
| PUT | `/api/tasks/{id}` | Update task (replace all fields) | Yes |
|
| 123 |
+
| PATCH | `/api/tasks/{id}` | Partially update task | Yes |
|
| 124 |
+
| DELETE | `/api/tasks/{id}` | Delete a task | Yes |
|
| 125 |
+
|
| 126 |
+
### Query Parameters (GET /api/tasks)
|
| 127 |
+
|
| 128 |
+
- `completed`: Filter by status (true/false/null for all)
|
| 129 |
+
- `sort`: Sort field (created_at or updated_at)
|
| 130 |
+
- `order`: Sort order (asc or desc)
|
| 131 |
+
- `limit`: Maximum number of results
|
| 132 |
+
- `offset`: Number of results to skip
|
| 133 |
+
|
| 134 |
+
### Example Requests
|
| 135 |
+
|
| 136 |
+
**Sign Up:**
|
| 137 |
+
```bash
|
| 138 |
+
curl -X POST http://localhost:8000/api/auth/signup \
|
| 139 |
+
-H "Content-Type: application/json" \
|
| 140 |
+
-d '{
|
| 141 |
+
"email": "user@example.com",
|
| 142 |
+
"password": "SecurePass123",
|
| 143 |
+
"name": "John Doe"
|
| 144 |
+
}'
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
**Sign In:**
|
| 148 |
+
```bash
|
| 149 |
+
curl -X POST http://localhost:8000/api/auth/signin \
|
| 150 |
+
-H "Content-Type: application/json" \
|
| 151 |
+
-d '{
|
| 152 |
+
"email": "user@example.com",
|
| 153 |
+
"password": "SecurePass123"
|
| 154 |
+
}'
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
**Get Current User (requires JWT token):**
|
| 158 |
+
```bash
|
| 159 |
+
curl http://localhost:8000/api/auth/me \
|
| 160 |
+
-H "Authorization: Bearer <your-jwt-token>"
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
**Create Task (requires JWT token):**
|
| 164 |
+
```bash
|
| 165 |
+
curl -X POST http://localhost:8000/api/tasks \
|
| 166 |
+
-H "Content-Type: application/json" \
|
| 167 |
+
-H "Authorization: Bearer <your-jwt-token>" \
|
| 168 |
+
-d '{"title": "Buy groceries", "description": "Milk, eggs, bread"}'
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
**List Active Tasks:**
|
| 172 |
+
```bash
|
| 173 |
+
curl "http://localhost:8000/api/tasks?completed=false&sort=created_at&order=desc" \
|
| 174 |
+
-H "Authorization: Bearer <your-jwt-token>"
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
**Toggle Completion:**
|
| 178 |
+
```bash
|
| 179 |
+
curl -X PATCH http://localhost:8000/api/tasks/1 \
|
| 180 |
+
-H "Content-Type: application/json" \
|
| 181 |
+
-H "Authorization: Bearer <your-jwt-token>" \
|
| 182 |
+
-d '{"completed": true}'
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
## API Documentation
|
| 186 |
+
|
| 187 |
+
Interactive API documentation available at:
|
| 188 |
+
- **Swagger UI**: http://localhost:8000/docs
|
| 189 |
+
- **ReDoc**: http://localhost:8000/redoc
|
| 190 |
+
|
| 191 |
+
## Database Schema
|
| 192 |
+
|
| 193 |
+
### Tasks Table
|
| 194 |
+
|
| 195 |
+
| Column | Type | Description |
|
| 196 |
+
|--------|------|-------------|
|
| 197 |
+
| id | INTEGER | Primary key |
|
| 198 |
+
| user_id | INTEGER | Foreign key to users |
|
| 199 |
+
| title | VARCHAR(200) | Task title (required) |
|
| 200 |
+
| description | VARCHAR(1000) | Task description (optional) |
|
| 201 |
+
| completed | BOOLEAN | Completion status |
|
| 202 |
+
| created_at | DATETIME | Creation timestamp |
|
| 203 |
+
| updated_at | DATETIME | Last update timestamp |
|
| 204 |
+
|
| 205 |
+
**Indexes:**
|
| 206 |
+
- `ix_tasks_user_id` - User lookup
|
| 207 |
+
- `ix_tasks_completed` - Status filtering
|
| 208 |
+
- `ix_tasks_user_id_completed` - Combined user + status
|
| 209 |
+
- `ix_tasks_created_at` - Date sorting
|
| 210 |
+
|
| 211 |
+
## Authentication
|
| 212 |
+
|
| 213 |
+
**Status**: JWT-based authentication with Better Auth integration
|
| 214 |
+
|
| 215 |
+
### Authentication Flow
|
| 216 |
+
|
| 217 |
+
1. **User Registration** (`POST /api/auth/signup`):
|
| 218 |
+
- Validates email format (RFC 5322)
|
| 219 |
+
- Validates password strength (min 8 chars, uppercase, lowercase, number)
|
| 220 |
+
- Hashes password with bcrypt (cost factor 12)
|
| 221 |
+
- Creates user account in database
|
| 222 |
+
- Returns user profile (no token issued)
|
| 223 |
+
|
| 224 |
+
2. **User Sign In** (`POST /api/auth/signin`):
|
| 225 |
+
- Verifies email and password
|
| 226 |
+
- Creates JWT token with 7-day expiration
|
| 227 |
+
- Token includes: user_id (sub), email, issued_at (iat), expiration (exp)
|
| 228 |
+
- Returns token and user profile
|
| 229 |
+
|
| 230 |
+
3. **Protected Endpoints**:
|
| 231 |
+
- All `/api/tasks/*` endpoints require JWT authentication
|
| 232 |
+
- Client must include `Authorization: Bearer <token>` header
|
| 233 |
+
- Backend verifies token signature using `BETTER_AUTH_SECRET`
|
| 234 |
+
- Extracts user_id from token and filters all queries by authenticated user
|
| 235 |
+
- Returns 401 Unauthorized for missing, invalid, or expired tokens
|
| 236 |
+
|
| 237 |
+
### Security Features
|
| 238 |
+
|
| 239 |
+
- **Stateless Authentication**: No server-side session storage
|
| 240 |
+
- **User Data Isolation**: All task queries automatically filtered by authenticated user_id
|
| 241 |
+
- **Password Security**: Bcrypt hashing with cost factor 12
|
| 242 |
+
- **Token Expiration**: 7-day JWT expiration (configurable via JWT_EXPIRATION_DAYS)
|
| 243 |
+
- **Shared Secret**: BETTER_AUTH_SECRET must match between frontend and backend
|
| 244 |
+
- **Error Handling**: Generic error messages for invalid credentials (prevents user enumeration)
|
| 245 |
+
|
| 246 |
+
### Token Structure
|
| 247 |
+
|
| 248 |
+
```json
|
| 249 |
+
{
|
| 250 |
+
"sub": "123", // User ID
|
| 251 |
+
"email": "user@example.com",
|
| 252 |
+
"iat": 1704067200, // Issued at timestamp
|
| 253 |
+
"exp": 1704672000, // Expiration timestamp (7 days)
|
| 254 |
+
"iss": "better-auth" // Issuer
|
| 255 |
+
}
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
### Error Responses
|
| 259 |
+
|
| 260 |
+
- **401 TOKEN_EXPIRED**: JWT token has expired
|
| 261 |
+
- **401 TOKEN_INVALID**: Invalid signature or malformed token
|
| 262 |
+
- **401 TOKEN_MISSING**: No Authorization header provided
|
| 263 |
+
- **401 INVALID_CREDENTIALS**: Email or password incorrect (generic message)
|
| 264 |
+
- **409 EMAIL_EXISTS**: Email already registered during signup
|
| 265 |
+
|
| 266 |
+
## Development
|
| 267 |
+
|
| 268 |
+
### Create New Migration
|
| 269 |
+
|
| 270 |
+
```bash
|
| 271 |
+
python -m alembic revision --autogenerate -m "description"
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
### Rollback Migration
|
| 275 |
+
|
| 276 |
+
```bash
|
| 277 |
+
python -m alembic downgrade -1
|
| 278 |
+
```
|
| 279 |
+
|
| 280 |
+
### Run Tests (when implemented)
|
| 281 |
+
|
| 282 |
+
```bash
|
| 283 |
+
pytest
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
## Troubleshooting
|
| 287 |
+
|
| 288 |
+
### Database Connection Issues
|
| 289 |
+
|
| 290 |
+
1. **Neon**: Ensure connection string includes `?sslmode=require`
|
| 291 |
+
2. **Local PostgreSQL**: Verify PostgreSQL is running and database exists
|
| 292 |
+
3. Check `.env` file has correct `DATABASE_URL`
|
| 293 |
+
|
| 294 |
+
### Migration Errors
|
| 295 |
+
|
| 296 |
+
```bash
|
| 297 |
+
# Reset database (WARNING: deletes all data)
|
| 298 |
+
python -m alembic downgrade base
|
| 299 |
+
python -m alembic upgrade head
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
## Next Steps
|
| 303 |
+
|
| 304 |
+
1. Implement JWT authentication (Spec 2)
|
| 305 |
+
2. Add comprehensive test suite
|
| 306 |
+
3. Add API rate limiting
|
| 307 |
+
4. Implement pagination metadata
|
| 308 |
+
5. Add task categories/tags
|
| 309 |
+
6. Deploy to production (Vercel/Railway)
|
README_HF.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: TaskFlow API
|
| 3 |
+
emoji: ✅
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# TaskFlow Backend API
|
| 12 |
+
|
| 13 |
+
FastAPI backend for TaskFlow task management application.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- User authentication with JWT
|
| 18 |
+
- Task CRUD operations
|
| 19 |
+
- PostgreSQL database with SQLModel ORM
|
| 20 |
+
- RESTful API design
|
| 21 |
+
|
| 22 |
+
## Environment Variables
|
| 23 |
+
|
| 24 |
+
Configure these in your Space settings:
|
| 25 |
+
|
| 26 |
+
- `DATABASE_URL`: PostgreSQL connection string
|
| 27 |
+
- `SECRET_KEY`: JWT secret key (generate a secure random string)
|
| 28 |
+
- `ALGORITHM`: JWT algorithm (default: HS256)
|
| 29 |
+
- `ACCESS_TOKEN_EXPIRE_MINUTES`: Token expiration time (default: 30)
|
| 30 |
+
|
| 31 |
+
## API Documentation
|
| 32 |
+
|
| 33 |
+
Once deployed, visit `/docs` for interactive API documentation.
|
alembic.ini
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 8 |
+
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
| 9 |
+
|
| 10 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 11 |
+
prepend_sys_path = .
|
| 12 |
+
|
| 13 |
+
# timezone to use when rendering the date within the migration file
|
| 14 |
+
# as well as the filename.
|
| 15 |
+
# If specified, requires the python-dateutil library that can be
|
| 16 |
+
# installed by adding `alembic[tz]` to the pip requirements
|
| 17 |
+
# string value is passed to dateutil.tz.gettz()
|
| 18 |
+
# leave blank for localtime
|
| 19 |
+
# timezone =
|
| 20 |
+
|
| 21 |
+
# max length of characters to apply to the
|
| 22 |
+
# "slug" field
|
| 23 |
+
# truncate_slug_length = 40
|
| 24 |
+
|
| 25 |
+
# set to 'true' to run the environment during
|
| 26 |
+
# the 'revision' command, regardless of autogenerate
|
| 27 |
+
# revision_environment = false
|
| 28 |
+
|
| 29 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 30 |
+
# a source .py file to be detected as revisions in the
|
| 31 |
+
# versions/ directory
|
| 32 |
+
# sourceless = false
|
| 33 |
+
|
| 34 |
+
# version location specification; This defaults
|
| 35 |
+
# to alembic/versions. When using multiple version
|
| 36 |
+
# directories, initial revisions must be specified with --version-path.
|
| 37 |
+
# The path separator used here should be the separator specified by "version_path_separator" below.
|
| 38 |
+
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
| 39 |
+
|
| 40 |
+
# version path separator; As mentioned above, this is the character used to split
|
| 41 |
+
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
| 42 |
+
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
| 43 |
+
# Valid values for version_path_separator are:
|
| 44 |
+
#
|
| 45 |
+
# version_path_separator = :
|
| 46 |
+
# version_path_separator = ;
|
| 47 |
+
# version_path_separator = space
|
| 48 |
+
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
| 49 |
+
|
| 50 |
+
# set to 'true' to search source files recursively
|
| 51 |
+
# in each "version_locations" directory
|
| 52 |
+
# new in Alembic version 1.10
|
| 53 |
+
# recursive_version_locations = false
|
| 54 |
+
|
| 55 |
+
# the output encoding used when revision files
|
| 56 |
+
# are written from script.py.mako
|
| 57 |
+
# output_encoding = utf-8
|
| 58 |
+
|
| 59 |
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
[post_write_hooks]
|
| 63 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 64 |
+
# on newly generated revision scripts. See the documentation for further
|
| 65 |
+
# detail and examples
|
| 66 |
+
|
| 67 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 68 |
+
# hooks = black
|
| 69 |
+
# black.type = console_scripts
|
| 70 |
+
# black.entrypoint = black
|
| 71 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 72 |
+
|
| 73 |
+
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
| 74 |
+
# hooks = ruff
|
| 75 |
+
# ruff.type = exec
|
| 76 |
+
# ruff.executable = %(here)s/.venv/bin/ruff
|
| 77 |
+
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
| 78 |
+
|
| 79 |
+
# Logging configuration
|
| 80 |
+
[loggers]
|
| 81 |
+
keys = root,sqlalchemy,alembic
|
| 82 |
+
|
| 83 |
+
[handlers]
|
| 84 |
+
keys = console
|
| 85 |
+
|
| 86 |
+
[formatters]
|
| 87 |
+
keys = generic
|
| 88 |
+
|
| 89 |
+
[logger_root]
|
| 90 |
+
level = WARN
|
| 91 |
+
handlers = console
|
| 92 |
+
qualname =
|
| 93 |
+
|
| 94 |
+
[logger_sqlalchemy]
|
| 95 |
+
level = WARN
|
| 96 |
+
handlers =
|
| 97 |
+
qualname = sqlalchemy.engine
|
| 98 |
+
|
| 99 |
+
[logger_alembic]
|
| 100 |
+
level = INFO
|
| 101 |
+
handlers =
|
| 102 |
+
qualname = alembic
|
| 103 |
+
|
| 104 |
+
[handler_console]
|
| 105 |
+
class = StreamHandler
|
| 106 |
+
args = (sys.stderr,)
|
| 107 |
+
level = NOTSET
|
| 108 |
+
formatter = generic
|
| 109 |
+
|
| 110 |
+
[formatter_generic]
|
| 111 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 112 |
+
datefmt = %H:%M:%S
|
alembic/__pycache__/env.cpython-313.pyc
ADDED
|
Binary file (2.81 kB). View file
|
|
|
alembic/env.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from logging.config import fileConfig
|
| 2 |
+
from sqlalchemy import engine_from_config
|
| 3 |
+
from sqlalchemy import pool
|
| 4 |
+
from alembic import context
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# Add the src directory to the path
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
| 10 |
+
|
| 11 |
+
from src.core.config import settings
|
| 12 |
+
from src.models import User, Task
|
| 13 |
+
from sqlmodel import SQLModel
|
| 14 |
+
|
| 15 |
+
# this is the Alembic Config object
|
| 16 |
+
config = context.config
|
| 17 |
+
|
| 18 |
+
# Set the database URL from settings
|
| 19 |
+
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
| 20 |
+
|
| 21 |
+
# Interpret the config file for Python logging
|
| 22 |
+
if config.config_file_name is not None:
|
| 23 |
+
fileConfig(config.config_file_name)
|
| 24 |
+
|
| 25 |
+
# Add your model's MetaData object here for 'autogenerate' support
|
| 26 |
+
target_metadata = SQLModel.metadata
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def run_migrations_offline() -> None:
|
| 30 |
+
"""Run migrations in 'offline' mode."""
|
| 31 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 32 |
+
context.configure(
|
| 33 |
+
url=url,
|
| 34 |
+
target_metadata=target_metadata,
|
| 35 |
+
literal_binds=True,
|
| 36 |
+
dialect_opts={"paramstyle": "named"},
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
with context.begin_transaction():
|
| 40 |
+
context.run_migrations()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def run_migrations_online() -> None:
|
| 44 |
+
"""Run migrations in 'online' mode."""
|
| 45 |
+
connectable = engine_from_config(
|
| 46 |
+
config.get_section(config.config_ini_section, {}),
|
| 47 |
+
prefix="sqlalchemy.",
|
| 48 |
+
poolclass=pool.NullPool,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
with connectable.connect() as connection:
|
| 52 |
+
context.configure(
|
| 53 |
+
connection=connection, target_metadata=target_metadata
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
with context.begin_transaction():
|
| 57 |
+
context.run_migrations()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if context.is_offline_mode():
|
| 61 |
+
run_migrations_offline()
|
| 62 |
+
else:
|
| 63 |
+
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.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create users and tasks tables
|
| 2 |
+
|
| 3 |
+
Revision ID: 001_initial
|
| 4 |
+
Revises:
|
| 5 |
+
Create Date: 2026-01-08
|
| 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_initial'
|
| 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('name', sa.String(length=100), 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('ix_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=200), nullable=False),
|
| 38 |
+
sa.Column('description', sa.String(length=1000), 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.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
| 43 |
+
sa.PrimaryKeyConstraint('id')
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Create indexes for tasks table
|
| 47 |
+
op.create_index('ix_tasks_user_id', 'tasks', ['user_id'])
|
| 48 |
+
op.create_index('ix_tasks_completed', 'tasks', ['completed'])
|
| 49 |
+
op.create_index('ix_tasks_user_id_completed', 'tasks', ['user_id', 'completed'])
|
| 50 |
+
op.create_index('ix_tasks_created_at', 'tasks', ['created_at'])
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def downgrade() -> None:
|
| 54 |
+
# Drop indexes
|
| 55 |
+
op.drop_index('ix_tasks_created_at', table_name='tasks')
|
| 56 |
+
op.drop_index('ix_tasks_user_id_completed', table_name='tasks')
|
| 57 |
+
op.drop_index('ix_tasks_completed', table_name='tasks')
|
| 58 |
+
op.drop_index('ix_tasks_user_id', table_name='tasks')
|
| 59 |
+
|
| 60 |
+
# Drop tables
|
| 61 |
+
op.drop_table('tasks')
|
| 62 |
+
op.drop_index('ix_users_email', table_name='users')
|
| 63 |
+
op.drop_table('users')
|
alembic/versions/002_add_user_password.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Add password_hash to users table
|
| 2 |
+
|
| 3 |
+
Revision ID: 002_add_user_password
|
| 4 |
+
Revises: 001_initial
|
| 5 |
+
Create Date: 2026-01-09
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = '002_add_user_password'
|
| 14 |
+
down_revision = '001_initial'
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade():
|
| 20 |
+
"""Add password_hash column to users table."""
|
| 21 |
+
op.add_column('users', sa.Column('password_hash', sa.String(length=255), nullable=False))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def downgrade():
|
| 25 |
+
"""Remove password_hash column from users table."""
|
| 26 |
+
op.drop_column('users', 'password_hash')
|
alembic/versions/__pycache__/001_initial.cpython-313.pyc
ADDED
|
Binary file (3.57 kB). View file
|
|
|
alembic/versions/__pycache__/002_add_user_password.cpython-313.pyc
ADDED
|
Binary file (1.17 kB). View file
|
|
|
deploy-prepare.ps1
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Quick Deployment Script for Hugging Face Spaces (PowerShell)
|
| 2 |
+
# This script helps prepare your backend for deployment
|
| 3 |
+
|
| 4 |
+
Write-Host "🚀 TaskFlow Backend - Hugging Face Deployment Preparation" -ForegroundColor Cyan
|
| 5 |
+
Write-Host "==========================================================" -ForegroundColor Cyan
|
| 6 |
+
Write-Host ""
|
| 7 |
+
|
| 8 |
+
# Check if we're in the backend directory
|
| 9 |
+
if (-not (Test-Path "requirements.txt")) {
|
| 10 |
+
Write-Host "❌ Error: Please run this script from the backend directory" -ForegroundColor Red
|
| 11 |
+
exit 1
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
Write-Host "✅ Found backend directory" -ForegroundColor Green
|
| 15 |
+
Write-Host ""
|
| 16 |
+
|
| 17 |
+
# Check required files
|
| 18 |
+
Write-Host "📋 Checking required files..." -ForegroundColor Yellow
|
| 19 |
+
$files = @("Dockerfile", "requirements.txt", "alembic.ini", "src/main.py")
|
| 20 |
+
$missing_files = @()
|
| 21 |
+
|
| 22 |
+
foreach ($file in $files) {
|
| 23 |
+
if (Test-Path $file) {
|
| 24 |
+
Write-Host " ✅ $file" -ForegroundColor Green
|
| 25 |
+
} else {
|
| 26 |
+
Write-Host " ❌ $file (missing)" -ForegroundColor Red
|
| 27 |
+
$missing_files += $file
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
if ($missing_files.Count -gt 0) {
|
| 32 |
+
Write-Host ""
|
| 33 |
+
Write-Host "❌ Missing required files. Please ensure all files are present." -ForegroundColor Red
|
| 34 |
+
exit 1
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
Write-Host ""
|
| 38 |
+
Write-Host "🔐 Generating secrets..." -ForegroundColor Yellow
|
| 39 |
+
|
| 40 |
+
# Generate BETTER_AUTH_SECRET (using .NET crypto)
|
| 41 |
+
$bytes = New-Object byte[] 32
|
| 42 |
+
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
| 43 |
+
$rng.GetBytes($bytes)
|
| 44 |
+
$SECRET = [System.BitConverter]::ToString($bytes).Replace("-", "").ToLower()
|
| 45 |
+
|
| 46 |
+
Write-Host ""
|
| 47 |
+
Write-Host "Your BETTER_AUTH_SECRET (save this!):" -ForegroundColor Cyan
|
| 48 |
+
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
| 49 |
+
Write-Host $SECRET -ForegroundColor Yellow
|
| 50 |
+
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
|
| 51 |
+
Write-Host ""
|
| 52 |
+
|
| 53 |
+
# Create deployment notes
|
| 54 |
+
$deploymentNotes = @"
|
| 55 |
+
TaskFlow Backend - Deployment Information
|
| 56 |
+
Generated: $(Get-Date)
|
| 57 |
+
|
| 58 |
+
BETTER_AUTH_SECRET: $SECRET
|
| 59 |
+
|
| 60 |
+
Required Environment Variables for Hugging Face:
|
| 61 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 62 |
+
|
| 63 |
+
1. DATABASE_URL
|
| 64 |
+
Get from: Neon PostgreSQL Dashboard
|
| 65 |
+
Format: postgresql://user:password@host/database
|
| 66 |
+
|
| 67 |
+
2. BETTER_AUTH_SECRET
|
| 68 |
+
Value: $SECRET
|
| 69 |
+
|
| 70 |
+
3. CORS_ORIGINS
|
| 71 |
+
Initial: http://localhost:3000
|
| 72 |
+
After frontend deploy: https://your-app.vercel.app,https://your-app-*.vercel.app
|
| 73 |
+
|
| 74 |
+
4. DEBUG
|
| 75 |
+
Value: False
|
| 76 |
+
|
| 77 |
+
5. JWT_ALGORITHM (optional)
|
| 78 |
+
Value: HS256
|
| 79 |
+
|
| 80 |
+
6. JWT_EXPIRATION_DAYS (optional)
|
| 81 |
+
Value: 7
|
| 82 |
+
|
| 83 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 84 |
+
|
| 85 |
+
Next Steps:
|
| 86 |
+
1. Create Hugging Face Space at https://huggingface.co/new-space
|
| 87 |
+
2. Choose Docker SDK
|
| 88 |
+
3. Clone the Space repository
|
| 89 |
+
4. Copy all backend files to the Space directory
|
| 90 |
+
5. Rename README_HF.md to README.md
|
| 91 |
+
6. Commit and push
|
| 92 |
+
7. Add environment variables in Space Settings
|
| 93 |
+
8. Wait for build to complete
|
| 94 |
+
|
| 95 |
+
"@
|
| 96 |
+
|
| 97 |
+
$deploymentNotes | Out-File -FilePath "DEPLOYMENT_NOTES.txt" -Encoding UTF8
|
| 98 |
+
|
| 99 |
+
Write-Host "📝 Deployment notes saved to: DEPLOYMENT_NOTES.txt" -ForegroundColor Green
|
| 100 |
+
Write-Host ""
|
| 101 |
+
|
| 102 |
+
# Check if Docker is available
|
| 103 |
+
$dockerInstalled = Get-Command docker -ErrorAction SilentlyContinue
|
| 104 |
+
if ($dockerInstalled) {
|
| 105 |
+
Write-Host "🐳 Docker is installed" -ForegroundColor Green
|
| 106 |
+
Write-Host ""
|
| 107 |
+
$response = Read-Host "Would you like to test the Docker build locally? (y/n)"
|
| 108 |
+
if ($response -eq "y" -or $response -eq "Y") {
|
| 109 |
+
Write-Host "Building Docker image..." -ForegroundColor Yellow
|
| 110 |
+
docker build -t taskflow-backend-test .
|
| 111 |
+
if ($LASTEXITCODE -eq 0) {
|
| 112 |
+
Write-Host "✅ Docker build successful!" -ForegroundColor Green
|
| 113 |
+
} else {
|
| 114 |
+
Write-Host "❌ Docker build failed. Please check the errors above." -ForegroundColor Red
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
} else {
|
| 118 |
+
Write-Host "ℹ️ Docker not found. Skipping local build test." -ForegroundColor Yellow
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
Write-Host ""
|
| 122 |
+
Write-Host "✅ Preparation complete!" -ForegroundColor Green
|
| 123 |
+
Write-Host ""
|
| 124 |
+
Write-Host "📚 Next steps:" -ForegroundColor Cyan
|
| 125 |
+
Write-Host " 1. Review DEPLOYMENT_NOTES.txt"
|
| 126 |
+
Write-Host " 2. Follow the deployment guide in ../DEPLOYMENT_GUIDE.md"
|
| 127 |
+
Write-Host " 3. Create your Hugging Face Space"
|
| 128 |
+
Write-Host " 4. Push your code"
|
| 129 |
+
Write-Host ""
|
deploy-prepare.sh
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# Quick Deployment Script for Hugging Face Spaces
|
| 4 |
+
# This script helps prepare your backend for deployment
|
| 5 |
+
|
| 6 |
+
echo "🚀 TaskFlow Backend - Hugging Face Deployment Preparation"
|
| 7 |
+
echo "=========================================================="
|
| 8 |
+
echo ""
|
| 9 |
+
|
| 10 |
+
# Check if we're in the backend directory
|
| 11 |
+
if [ ! -f "requirements.txt" ]; then
|
| 12 |
+
echo "❌ Error: Please run this script from the backend directory"
|
| 13 |
+
exit 1
|
| 14 |
+
fi
|
| 15 |
+
|
| 16 |
+
echo "✅ Found backend directory"
|
| 17 |
+
echo ""
|
| 18 |
+
|
| 19 |
+
# Check required files
|
| 20 |
+
echo "📋 Checking required files..."
|
| 21 |
+
files=("Dockerfile" "requirements.txt" "alembic.ini" "src/main.py")
|
| 22 |
+
missing_files=()
|
| 23 |
+
|
| 24 |
+
for file in "${files[@]}"; do
|
| 25 |
+
if [ -f "$file" ]; then
|
| 26 |
+
echo " ✅ $file"
|
| 27 |
+
else
|
| 28 |
+
echo " ❌ $file (missing)"
|
| 29 |
+
missing_files+=("$file")
|
| 30 |
+
fi
|
| 31 |
+
done
|
| 32 |
+
|
| 33 |
+
if [ ${#missing_files[@]} -ne 0 ]; then
|
| 34 |
+
echo ""
|
| 35 |
+
echo "❌ Missing required files. Please ensure all files are present."
|
| 36 |
+
exit 1
|
| 37 |
+
fi
|
| 38 |
+
|
| 39 |
+
echo ""
|
| 40 |
+
echo "🔐 Generating secrets..."
|
| 41 |
+
|
| 42 |
+
# Generate BETTER_AUTH_SECRET
|
| 43 |
+
SECRET=$(openssl rand -hex 32)
|
| 44 |
+
echo ""
|
| 45 |
+
echo "Your BETTER_AUTH_SECRET (save this!):"
|
| 46 |
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
| 47 |
+
echo "$SECRET"
|
| 48 |
+
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
| 49 |
+
echo ""
|
| 50 |
+
|
| 51 |
+
# Create deployment notes
|
| 52 |
+
cat > DEPLOYMENT_NOTES.txt << EOF
|
| 53 |
+
TaskFlow Backend - Deployment Information
|
| 54 |
+
Generated: $(date)
|
| 55 |
+
|
| 56 |
+
BETTER_AUTH_SECRET: $SECRET
|
| 57 |
+
|
| 58 |
+
Required Environment Variables for Hugging Face:
|
| 59 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 60 |
+
|
| 61 |
+
1. DATABASE_URL
|
| 62 |
+
Get from: Neon PostgreSQL Dashboard
|
| 63 |
+
Format: postgresql://user:password@host/database
|
| 64 |
+
|
| 65 |
+
2. BETTER_AUTH_SECRET
|
| 66 |
+
Value: $SECRET
|
| 67 |
+
|
| 68 |
+
3. CORS_ORIGINS
|
| 69 |
+
Initial: http://localhost:3000
|
| 70 |
+
After frontend deploy: https://your-app.vercel.app,https://your-app-*.vercel.app
|
| 71 |
+
|
| 72 |
+
4. DEBUG
|
| 73 |
+
Value: False
|
| 74 |
+
|
| 75 |
+
5. JWT_ALGORITHM (optional)
|
| 76 |
+
Value: HS256
|
| 77 |
+
|
| 78 |
+
6. JWT_EXPIRATION_DAYS (optional)
|
| 79 |
+
Value: 7
|
| 80 |
+
|
| 81 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 82 |
+
|
| 83 |
+
Next Steps:
|
| 84 |
+
1. Create Hugging Face Space at https://huggingface.co/new-space
|
| 85 |
+
2. Choose Docker SDK
|
| 86 |
+
3. Clone the Space repository
|
| 87 |
+
4. Copy all backend files to the Space directory
|
| 88 |
+
5. Rename README_HF.md to README.md
|
| 89 |
+
6. Commit and push
|
| 90 |
+
7. Add environment variables in Space Settings
|
| 91 |
+
8. Wait for build to complete
|
| 92 |
+
|
| 93 |
+
EOF
|
| 94 |
+
|
| 95 |
+
echo "📝 Deployment notes saved to: DEPLOYMENT_NOTES.txt"
|
| 96 |
+
echo ""
|
| 97 |
+
|
| 98 |
+
# Test if Docker is available
|
| 99 |
+
if command -v docker &> /dev/null; then
|
| 100 |
+
echo "🐳 Docker is installed"
|
| 101 |
+
echo ""
|
| 102 |
+
echo "Would you like to test the Docker build locally? (y/n)"
|
| 103 |
+
read -r response
|
| 104 |
+
if [[ "$response" =~ ^[Yy]$ ]]; then
|
| 105 |
+
echo "Building Docker image..."
|
| 106 |
+
docker build -t taskflow-backend-test .
|
| 107 |
+
if [ $? -eq 0 ]; then
|
| 108 |
+
echo "✅ Docker build successful!"
|
| 109 |
+
else
|
| 110 |
+
echo "❌ Docker build failed. Please check the errors above."
|
| 111 |
+
fi
|
| 112 |
+
fi
|
| 113 |
+
else
|
| 114 |
+
echo "ℹ️ Docker not found. Skipping local build test."
|
| 115 |
+
fi
|
| 116 |
+
|
| 117 |
+
echo ""
|
| 118 |
+
echo "✅ Preparation complete!"
|
| 119 |
+
echo ""
|
| 120 |
+
echo "📚 Next steps:"
|
| 121 |
+
echo " 1. Review DEPLOYMENT_NOTES.txt"
|
| 122 |
+
echo " 2. Follow the deployment guide in ../DEPLOYMENT_GUIDE.md"
|
| 123 |
+
echo " 3. Create your Hugging Face Space"
|
| 124 |
+
echo " 4. Push your code"
|
| 125 |
+
echo ""
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
sqlmodel==0.0.14
|
| 3 |
+
pydantic==2.5.0
|
| 4 |
+
uvicorn[standard]==0.24.0
|
| 5 |
+
alembic==1.13.0
|
| 6 |
+
psycopg2-binary==2.9.9
|
| 7 |
+
python-dotenv==1.0.0
|
| 8 |
+
pytest==7.4.3
|
| 9 |
+
httpx==0.25.2
|
| 10 |
+
PyJWT==2.8.0
|
| 11 |
+
passlib[bcrypt]==1.7.4
|
| 12 |
+
python-multipart==0.0.6
|
src/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (1.46 kB). View file
|
|
|
src/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API module initialization."""
|
src/api/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (245 Bytes). View file
|
|
|
src/api/__pycache__/deps.cpython-313.pyc
ADDED
|
Binary file (1.99 kB). View file
|
|
|
src/api/deps.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import Session
|
| 2 |
+
from typing import Generator
|
| 3 |
+
from fastapi import Depends, HTTPException, status
|
| 4 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 5 |
+
from src.core.database import get_session
|
| 6 |
+
from src.core.security import verify_jwt_token
|
| 7 |
+
from src.core.config import settings
|
| 8 |
+
|
| 9 |
+
security = HTTPBearer()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_db() -> Generator[Session, None, None]:
|
| 13 |
+
"""Get database session dependency."""
|
| 14 |
+
yield from get_session()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_current_user(
|
| 18 |
+
credentials: HTTPAuthorizationCredentials = Depends(security)
|
| 19 |
+
) -> int:
|
| 20 |
+
"""
|
| 21 |
+
Get current user ID from JWT token.
|
| 22 |
+
|
| 23 |
+
Extracts and verifies JWT from Authorization header.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
credentials: HTTP Bearer credentials from Authorization header
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
User ID extracted from validated token
|
| 30 |
+
|
| 31 |
+
Raises:
|
| 32 |
+
HTTPException: 401 if token is missing, invalid, or expired
|
| 33 |
+
"""
|
| 34 |
+
if not credentials:
|
| 35 |
+
raise HTTPException(
|
| 36 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 37 |
+
detail="Not authenticated",
|
| 38 |
+
headers={"WWW-Authenticate": "Bearer"}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
token = credentials.credentials
|
| 42 |
+
|
| 43 |
+
# Verify token and extract payload
|
| 44 |
+
payload = verify_jwt_token(token, settings.BETTER_AUTH_SECRET)
|
| 45 |
+
|
| 46 |
+
# Extract user ID from 'sub' claim
|
| 47 |
+
user_id = payload.get("sub")
|
| 48 |
+
if not user_id:
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 51 |
+
detail="Invalid token payload",
|
| 52 |
+
headers={"WWW-Authenticate": "Bearer"}
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return int(user_id)
|
src/api/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API routes module initialization."""
|
src/api/routes/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (259 Bytes). View file
|
|
|
src/api/routes/__pycache__/auth.cpython-313.pyc
ADDED
|
Binary file (3.28 kB). View file
|
|
|
src/api/routes/__pycache__/tasks.cpython-313.pyc
ADDED
|
Binary file (5.83 kB). View file
|
|
|
src/api/routes/auth.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication API routes."""
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 3 |
+
from sqlmodel import Session, select
|
| 4 |
+
from src.api.deps import get_db, get_current_user
|
| 5 |
+
from src.schemas.auth import SignupRequest, SigninRequest, SignupResponse, TokenResponse, UserProfile
|
| 6 |
+
from src.services.auth_service import AuthService
|
| 7 |
+
from src.models.user import User
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.post("/signup", response_model=SignupResponse, status_code=status.HTTP_201_CREATED)
|
| 13 |
+
def signup(
|
| 14 |
+
signup_data: SignupRequest,
|
| 15 |
+
db: Session = Depends(get_db)
|
| 16 |
+
):
|
| 17 |
+
"""
|
| 18 |
+
Register a new user account.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
signup_data: User signup information (email, password, name)
|
| 22 |
+
db: Database session
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
SignupResponse: Created user details
|
| 26 |
+
|
| 27 |
+
Raises:
|
| 28 |
+
HTTPException: 400 if validation fails
|
| 29 |
+
HTTPException: 409 if email already exists
|
| 30 |
+
"""
|
| 31 |
+
service = AuthService(db)
|
| 32 |
+
return service.signup(signup_data)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("/signin", response_model=TokenResponse)
|
| 36 |
+
def signin(
|
| 37 |
+
signin_data: SigninRequest,
|
| 38 |
+
db: Session = Depends(get_db)
|
| 39 |
+
):
|
| 40 |
+
"""
|
| 41 |
+
Authenticate user and issue JWT token.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
signin_data: User signin credentials (email, password)
|
| 45 |
+
db: Database session
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
TokenResponse: JWT token and user profile
|
| 49 |
+
|
| 50 |
+
Raises:
|
| 51 |
+
HTTPException: 401 if credentials are invalid
|
| 52 |
+
"""
|
| 53 |
+
service = AuthService(db)
|
| 54 |
+
return service.signin(signin_data)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/me", response_model=UserProfile)
|
| 58 |
+
def get_current_user_profile(
|
| 59 |
+
current_user_id: int = Depends(get_current_user),
|
| 60 |
+
db: Session = Depends(get_db)
|
| 61 |
+
):
|
| 62 |
+
"""
|
| 63 |
+
Get current authenticated user's profile.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
current_user_id: ID of authenticated user from JWT token
|
| 67 |
+
db: Database session
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
UserProfile: Current user's profile information
|
| 71 |
+
|
| 72 |
+
Raises:
|
| 73 |
+
HTTPException: 404 if user not found
|
| 74 |
+
"""
|
| 75 |
+
user = db.exec(select(User).where(User.id == current_user_id)).first()
|
| 76 |
+
|
| 77 |
+
if not user:
|
| 78 |
+
raise HTTPException(
|
| 79 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 80 |
+
detail="User not found"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
return UserProfile(
|
| 84 |
+
id=user.id,
|
| 85 |
+
email=user.email,
|
| 86 |
+
name=user.name,
|
| 87 |
+
created_at=user.created_at
|
| 88 |
+
)
|
src/api/routes/tasks.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task API routes."""
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 4 |
+
from sqlmodel import Session
|
| 5 |
+
from src.api.deps import get_db, get_current_user
|
| 6 |
+
from src.schemas.task import TaskCreate, TaskUpdate, TaskPatch, TaskResponse, TaskListResponse
|
| 7 |
+
from src.services.task_service import TaskService
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("", response_model=TaskListResponse)
|
| 13 |
+
def get_tasks(
|
| 14 |
+
completed: Optional[bool] = Query(None, description="Filter by completion status"),
|
| 15 |
+
sort: str = Query("created_at", description="Sort field (created_at or updated_at)"),
|
| 16 |
+
order: str = Query("desc", description="Sort order (asc or desc)"),
|
| 17 |
+
limit: Optional[int] = Query(None, description="Maximum number of tasks to return"),
|
| 18 |
+
offset: int = Query(0, description="Number of tasks to skip"),
|
| 19 |
+
db: Session = Depends(get_db),
|
| 20 |
+
current_user_id: int = Depends(get_current_user)
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Get tasks for the current user with filtering and sorting.
|
| 24 |
+
|
| 25 |
+
Query Parameters:
|
| 26 |
+
- completed: Filter by completion status (true/false/null for all)
|
| 27 |
+
- sort: Sort field (created_at or updated_at)
|
| 28 |
+
- order: Sort order (asc or desc)
|
| 29 |
+
- limit: Maximum number of tasks to return
|
| 30 |
+
- offset: Number of tasks to skip
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
TaskListResponse: List of tasks with total count
|
| 34 |
+
"""
|
| 35 |
+
service = TaskService(db)
|
| 36 |
+
tasks = service.get_tasks(
|
| 37 |
+
user_id=current_user_id,
|
| 38 |
+
completed=completed,
|
| 39 |
+
sort=sort,
|
| 40 |
+
order=order,
|
| 41 |
+
limit=limit,
|
| 42 |
+
offset=offset
|
| 43 |
+
)
|
| 44 |
+
return TaskListResponse(tasks=tasks, total=len(tasks))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
| 48 |
+
def create_task(
|
| 49 |
+
task_data: TaskCreate,
|
| 50 |
+
db: Session = Depends(get_db),
|
| 51 |
+
current_user_id: int = Depends(get_current_user)
|
| 52 |
+
):
|
| 53 |
+
"""
|
| 54 |
+
Create a new task for the current user.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
task_data: Task creation data
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
TaskResponse: Created task
|
| 61 |
+
"""
|
| 62 |
+
service = TaskService(db)
|
| 63 |
+
task = service.create_task(user_id=current_user_id, task_data=task_data)
|
| 64 |
+
return task
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.get("/{task_id}", response_model=TaskResponse)
|
| 68 |
+
def get_task(
|
| 69 |
+
task_id: int,
|
| 70 |
+
db: Session = Depends(get_db),
|
| 71 |
+
current_user_id: int = Depends(get_current_user)
|
| 72 |
+
):
|
| 73 |
+
"""
|
| 74 |
+
Get a single task by ID.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
task_id: ID of the task
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
TaskResponse: Task details
|
| 81 |
+
|
| 82 |
+
Raises:
|
| 83 |
+
HTTPException: 404 if task not found or doesn't belong to user
|
| 84 |
+
"""
|
| 85 |
+
service = TaskService(db)
|
| 86 |
+
task = service.get_task(task_id=task_id, user_id=current_user_id)
|
| 87 |
+
if not task:
|
| 88 |
+
raise HTTPException(
|
| 89 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 90 |
+
detail="Task not found"
|
| 91 |
+
)
|
| 92 |
+
return task
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.put("/{task_id}", response_model=TaskResponse)
|
| 96 |
+
def update_task(
|
| 97 |
+
task_id: int,
|
| 98 |
+
task_data: TaskUpdate,
|
| 99 |
+
db: Session = Depends(get_db),
|
| 100 |
+
current_user_id: int = Depends(get_current_user)
|
| 101 |
+
):
|
| 102 |
+
"""
|
| 103 |
+
Update a task (PUT - replaces all fields).
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
task_id: ID of the task
|
| 107 |
+
task_data: Task update data
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
TaskResponse: Updated task
|
| 111 |
+
|
| 112 |
+
Raises:
|
| 113 |
+
HTTPException: 404 if task not found or doesn't belong to user
|
| 114 |
+
"""
|
| 115 |
+
service = TaskService(db)
|
| 116 |
+
task = service.update_task(task_id=task_id, user_id=current_user_id, task_data=task_data)
|
| 117 |
+
if not task:
|
| 118 |
+
raise HTTPException(
|
| 119 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 120 |
+
detail="Task not found"
|
| 121 |
+
)
|
| 122 |
+
return task
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.patch("/{task_id}", response_model=TaskResponse)
|
| 126 |
+
def patch_task(
|
| 127 |
+
task_id: int,
|
| 128 |
+
task_data: TaskPatch,
|
| 129 |
+
db: Session = Depends(get_db),
|
| 130 |
+
current_user_id: int = Depends(get_current_user)
|
| 131 |
+
):
|
| 132 |
+
"""
|
| 133 |
+
Partially update a task (PATCH - updates only provided fields).
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
task_id: ID of the task
|
| 137 |
+
task_data: Task patch data
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
TaskResponse: Updated task
|
| 141 |
+
|
| 142 |
+
Raises:
|
| 143 |
+
HTTPException: 404 if task not found or doesn't belong to user
|
| 144 |
+
"""
|
| 145 |
+
service = TaskService(db)
|
| 146 |
+
task = service.patch_task(task_id=task_id, user_id=current_user_id, task_data=task_data)
|
| 147 |
+
if not task:
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 150 |
+
detail="Task not found"
|
| 151 |
+
)
|
| 152 |
+
return task
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 156 |
+
def delete_task(
|
| 157 |
+
task_id: int,
|
| 158 |
+
db: Session = Depends(get_db),
|
| 159 |
+
current_user_id: int = Depends(get_current_user)
|
| 160 |
+
):
|
| 161 |
+
"""
|
| 162 |
+
Delete a task.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
task_id: ID of the task
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
No content (204)
|
| 169 |
+
|
| 170 |
+
Raises:
|
| 171 |
+
HTTPException: 404 if task not found or doesn't belong to user
|
| 172 |
+
"""
|
| 173 |
+
service = TaskService(db)
|
| 174 |
+
deleted = service.delete_task(task_id=task_id, user_id=current_user_id)
|
| 175 |
+
if not deleted:
|
| 176 |
+
raise HTTPException(
|
| 177 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 178 |
+
detail="Task not found"
|
| 179 |
+
)
|
src/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Core module initialization."""
|
src/core/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (247 Bytes). View file
|
|
|
src/core/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (1.18 kB). View file
|
|
|
src/core/__pycache__/database.cpython-313.pyc
ADDED
|
Binary file (786 Bytes). View file
|
|
|
src/core/__pycache__/security.cpython-313.pyc
ADDED
|
Binary file (3.5 kB). View file
|
|
|
src/core/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Settings(BaseSettings):
|
| 5 |
+
"""Application settings."""
|
| 6 |
+
|
| 7 |
+
# Database
|
| 8 |
+
DATABASE_URL: str
|
| 9 |
+
|
| 10 |
+
# Application
|
| 11 |
+
APP_NAME: str = "Task CRUD API"
|
| 12 |
+
DEBUG: bool = True
|
| 13 |
+
|
| 14 |
+
# CORS
|
| 15 |
+
CORS_ORIGINS: str = "http://localhost:3000"
|
| 16 |
+
|
| 17 |
+
# Authentication
|
| 18 |
+
BETTER_AUTH_SECRET: str # Required - must be set in .env
|
| 19 |
+
JWT_ALGORITHM: str = "HS256"
|
| 20 |
+
JWT_EXPIRATION_DAYS: int = 7
|
| 21 |
+
|
| 22 |
+
class Config:
|
| 23 |
+
env_file = ".env"
|
| 24 |
+
case_sensitive = True
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
settings = Settings()
|
src/core/database.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import create_engine, Session
|
| 2 |
+
from .config import settings
|
| 3 |
+
|
| 4 |
+
# Create database engine
|
| 5 |
+
engine = create_engine(
|
| 6 |
+
settings.DATABASE_URL,
|
| 7 |
+
echo=settings.DEBUG,
|
| 8 |
+
pool_pre_ping=True,
|
| 9 |
+
pool_size=5,
|
| 10 |
+
max_overflow=10
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_session():
|
| 15 |
+
"""Get database session."""
|
| 16 |
+
with Session(engine) as session:
|
| 17 |
+
yield session
|
src/core/security.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Security utilities for authentication and authorization."""
|
| 2 |
+
import jwt
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from passlib.context import CryptContext
|
| 5 |
+
from fastapi import HTTPException, status
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
# Password hashing context
|
| 9 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def hash_password(password: str) -> str:
|
| 13 |
+
"""
|
| 14 |
+
Hash a password using bcrypt.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
password: Plain text password
|
| 18 |
+
|
| 19 |
+
Returns:
|
| 20 |
+
Hashed password string
|
| 21 |
+
"""
|
| 22 |
+
return pwd_context.hash(password)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 26 |
+
"""
|
| 27 |
+
Verify a password against its hash.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
plain_password: Plain text password to verify
|
| 31 |
+
hashed_password: Hashed password to compare against
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
True if password matches, False otherwise
|
| 35 |
+
"""
|
| 36 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def create_jwt_token(user_id: int, email: str, secret: str, expiration_days: int = 7) -> str:
|
| 40 |
+
"""
|
| 41 |
+
Create a JWT token for a user.
|
| 42 |
+
|
| 43 |
+
Args:
|
| 44 |
+
user_id: User's unique identifier
|
| 45 |
+
email: User's email address
|
| 46 |
+
secret: Secret key for signing the token
|
| 47 |
+
expiration_days: Number of days until token expires (default: 7)
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
Encoded JWT token string
|
| 51 |
+
"""
|
| 52 |
+
now = datetime.utcnow()
|
| 53 |
+
payload = {
|
| 54 |
+
"sub": str(user_id),
|
| 55 |
+
"email": email,
|
| 56 |
+
"iat": now,
|
| 57 |
+
"exp": now + timedelta(days=expiration_days),
|
| 58 |
+
"iss": "better-auth"
|
| 59 |
+
}
|
| 60 |
+
return jwt.encode(payload, secret, algorithm="HS256")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def verify_jwt_token(token: str, secret: str) -> dict:
|
| 64 |
+
"""
|
| 65 |
+
Verify and decode a JWT token.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
token: JWT token string to verify
|
| 69 |
+
secret: Secret key used to sign the token
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
Decoded token payload as dictionary
|
| 73 |
+
|
| 74 |
+
Raises:
|
| 75 |
+
HTTPException: 401 if token is expired or invalid
|
| 76 |
+
"""
|
| 77 |
+
try:
|
| 78 |
+
payload = jwt.decode(
|
| 79 |
+
token,
|
| 80 |
+
secret,
|
| 81 |
+
algorithms=["HS256"],
|
| 82 |
+
options={
|
| 83 |
+
"verify_signature": True,
|
| 84 |
+
"verify_exp": True,
|
| 85 |
+
"require": ["sub", "email", "iat", "exp", "iss"]
|
| 86 |
+
}
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# Validate issuer
|
| 90 |
+
if payload.get("iss") != "better-auth":
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 93 |
+
detail="Invalid token issuer",
|
| 94 |
+
headers={"WWW-Authenticate": "Bearer"}
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
return payload
|
| 98 |
+
|
| 99 |
+
except jwt.ExpiredSignatureError:
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 102 |
+
detail="Token has expired",
|
| 103 |
+
headers={"WWW-Authenticate": "Bearer"}
|
| 104 |
+
)
|
| 105 |
+
except jwt.InvalidTokenError:
|
| 106 |
+
raise HTTPException(
|
| 107 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 108 |
+
detail="Invalid token",
|
| 109 |
+
headers={"WWW-Authenticate": "Bearer"}
|
| 110 |
+
)
|
src/main.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from .core.config import settings
|
| 4 |
+
from .api.routes import tasks, auth
|
| 5 |
+
|
| 6 |
+
app = FastAPI(
|
| 7 |
+
title=settings.APP_NAME,
|
| 8 |
+
debug=settings.DEBUG
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
# Configure CORS
|
| 12 |
+
app.add_middleware(
|
| 13 |
+
CORSMiddleware,
|
| 14 |
+
allow_origins=settings.CORS_ORIGINS.split(","),
|
| 15 |
+
allow_credentials=True,
|
| 16 |
+
allow_methods=["*"],
|
| 17 |
+
allow_headers=["*"],
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# Register routes
|
| 21 |
+
app.include_router(auth.router)
|
| 22 |
+
app.include_router(tasks.router)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@app.get("/")
|
| 26 |
+
async def root():
|
| 27 |
+
"""Root endpoint."""
|
| 28 |
+
return {"message": "Task CRUD API", "status": "running"}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@app.get("/health")
|
| 32 |
+
async def health():
|
| 33 |
+
"""Health check endpoint."""
|
| 34 |
+
return {"status": "healthy"}
|
src/models/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Models module initialization."""
|
| 2 |
+
from .user import User
|
| 3 |
+
from .task import Task
|
| 4 |
+
|
| 5 |
+
__all__ = ["User", "Task"]
|
src/models/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (361 Bytes). View file
|
|
|
src/models/__pycache__/task.cpython-313.pyc
ADDED
|
Binary file (1.36 kB). View file
|
|
|
src/models/__pycache__/user.cpython-313.pyc
ADDED
|
Binary file (1.23 kB). View file
|
|
|
src/models/task.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Task(SQLModel, table=True):
|
| 7 |
+
"""Task entity representing a to-do item."""
|
| 8 |
+
|
| 9 |
+
__tablename__ = "tasks"
|
| 10 |
+
|
| 11 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 12 |
+
user_id: int = Field(foreign_key="users.id", nullable=False, index=True)
|
| 13 |
+
title: str = Field(max_length=200, nullable=False)
|
| 14 |
+
description: Optional[str] = Field(default=None, max_length=1000)
|
| 15 |
+
completed: bool = Field(default=False, nullable=False, index=True)
|
| 16 |
+
created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False, index=True)
|
| 17 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
|
src/models/user.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlmodel import SQLModel, Field
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class User(SQLModel, table=True):
|
| 7 |
+
"""User entity with authentication support."""
|
| 8 |
+
|
| 9 |
+
__tablename__ = "users"
|
| 10 |
+
|
| 11 |
+
id: Optional[int] = Field(default=None, primary_key=True)
|
| 12 |
+
email: str = Field(max_length=255, unique=True, nullable=False, index=True)
|
| 13 |
+
name: str = Field(max_length=100, nullable=False)
|
| 14 |
+
password_hash: str = Field(max_length=255, nullable=False)
|
| 15 |
+
created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
|
| 16 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
|
src/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for request/response validation."""
|
src/schemas/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (272 Bytes). View file
|
|
|
src/schemas/__pycache__/auth.cpython-313.pyc
ADDED
|
Binary file (3.01 kB). View file
|
|
|
src/schemas/__pycache__/task.cpython-313.pyc
ADDED
|
Binary file (3.91 kB). View file
|
|
|
src/schemas/auth.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication schemas for request/response validation."""
|
| 2 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class SignupRequest(BaseModel):
|
| 8 |
+
"""Request schema for user signup."""
|
| 9 |
+
email: EmailStr = Field(..., description="User's email address")
|
| 10 |
+
password: str = Field(..., min_length=8, max_length=100, description="User's password")
|
| 11 |
+
name: str = Field(..., min_length=1, max_length=100, description="User's display name")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SigninRequest(BaseModel):
|
| 15 |
+
"""Request schema for user signin."""
|
| 16 |
+
email: EmailStr = Field(..., description="User's email address")
|
| 17 |
+
password: str = Field(..., description="User's password")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class UserProfile(BaseModel):
|
| 21 |
+
"""User profile response schema."""
|
| 22 |
+
id: int
|
| 23 |
+
email: str
|
| 24 |
+
name: str
|
| 25 |
+
created_at: datetime
|
| 26 |
+
|
| 27 |
+
class Config:
|
| 28 |
+
from_attributes = True
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class TokenResponse(BaseModel):
|
| 32 |
+
"""Response schema for authentication with JWT token."""
|
| 33 |
+
access_token: str = Field(..., description="JWT access token")
|
| 34 |
+
token_type: str = Field(default="bearer", description="Token type")
|
| 35 |
+
expires_in: int = Field(..., description="Token expiration time in seconds")
|
| 36 |
+
user: UserProfile
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class SignupResponse(BaseModel):
|
| 40 |
+
"""Response schema for successful signup."""
|
| 41 |
+
id: int
|
| 42 |
+
email: str
|
| 43 |
+
name: str
|
| 44 |
+
created_at: datetime
|
| 45 |
+
|
| 46 |
+
class Config:
|
| 47 |
+
from_attributes = True
|
src/schemas/task.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for Task CRUD operations."""
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from pydantic import BaseModel, Field, ConfigDict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TaskCreate(BaseModel):
|
| 8 |
+
"""Schema for creating a new task."""
|
| 9 |
+
title: str = Field(..., min_length=1, max_length=200, description="Task title")
|
| 10 |
+
description: Optional[str] = Field(None, max_length=1000, description="Task description")
|
| 11 |
+
|
| 12 |
+
model_config = ConfigDict(
|
| 13 |
+
json_schema_extra={
|
| 14 |
+
"example": {
|
| 15 |
+
"title": "Buy groceries",
|
| 16 |
+
"description": "Milk, eggs, bread"
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TaskResponse(BaseModel):
|
| 23 |
+
"""Schema for task response."""
|
| 24 |
+
id: int = Field(..., description="Task ID")
|
| 25 |
+
user_id: int = Field(..., description="User ID who owns the task")
|
| 26 |
+
title: str = Field(..., description="Task title")
|
| 27 |
+
description: Optional[str] = Field(None, description="Task description")
|
| 28 |
+
completed: bool = Field(..., description="Task completion status")
|
| 29 |
+
created_at: datetime = Field(..., description="Task creation timestamp")
|
| 30 |
+
updated_at: datetime = Field(..., description="Task last update timestamp")
|
| 31 |
+
|
| 32 |
+
model_config = ConfigDict(
|
| 33 |
+
from_attributes=True,
|
| 34 |
+
json_schema_extra={
|
| 35 |
+
"example": {
|
| 36 |
+
"id": 1,
|
| 37 |
+
"user_id": 1,
|
| 38 |
+
"title": "Buy groceries",
|
| 39 |
+
"description": "Milk, eggs, bread",
|
| 40 |
+
"completed": False,
|
| 41 |
+
"created_at": "2026-01-08T10:00:00Z",
|
| 42 |
+
"updated_at": "2026-01-08T10:00:00Z"
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class TaskUpdate(BaseModel):
|
| 49 |
+
"""Schema for updating a task (PUT - replaces all fields)."""
|
| 50 |
+
title: str = Field(..., min_length=1, max_length=200, description="Task title")
|
| 51 |
+
description: Optional[str] = Field(None, max_length=1000, description="Task description")
|
| 52 |
+
completed: bool = Field(..., description="Task completion status")
|
| 53 |
+
|
| 54 |
+
model_config = ConfigDict(
|
| 55 |
+
json_schema_extra={
|
| 56 |
+
"example": {
|
| 57 |
+
"title": "Buy groceries",
|
| 58 |
+
"description": "Milk, eggs, bread, cheese",
|
| 59 |
+
"completed": False
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TaskPatch(BaseModel):
|
| 66 |
+
"""Schema for partially updating a task (PATCH - updates only provided fields)."""
|
| 67 |
+
title: Optional[str] = Field(None, min_length=1, max_length=200, description="Task title")
|
| 68 |
+
description: Optional[str] = Field(None, max_length=1000, description="Task description")
|
| 69 |
+
completed: Optional[bool] = Field(None, description="Task completion status")
|
| 70 |
+
|
| 71 |
+
model_config = ConfigDict(
|
| 72 |
+
json_schema_extra={
|
| 73 |
+
"example": {
|
| 74 |
+
"completed": True
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TaskListResponse(BaseModel):
|
| 81 |
+
"""Schema for list of tasks response."""
|
| 82 |
+
tasks: list[TaskResponse] = Field(..., description="List of tasks")
|
| 83 |
+
total: int = Field(..., description="Total number of tasks")
|
| 84 |
+
|
| 85 |
+
model_config = ConfigDict(
|
| 86 |
+
json_schema_extra={
|
| 87 |
+
"example": {
|
| 88 |
+
"tasks": [
|
| 89 |
+
{
|
| 90 |
+
"id": 1,
|
| 91 |
+
"user_id": 1,
|
| 92 |
+
"title": "Buy groceries",
|
| 93 |
+
"description": "Milk, eggs, bread",
|
| 94 |
+
"completed": False,
|
| 95 |
+
"created_at": "2026-01-08T10:00:00Z",
|
| 96 |
+
"updated_at": "2026-01-08T10:00:00Z"
|
| 97 |
+
}
|
| 98 |
+
],
|
| 99 |
+
"total": 1
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
)
|
src/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Business logic services."""
|