Add user authentication to the Todo app
- Created User model and schemas - Implemented secure password hashing with bcrypt - Added JWT token-based authentication - Created user registration and login endpoints - Added authentication to todo routes - Updated todos to be associated with users - Created migration script for the user table - Updated documentation with auth information
This commit is contained in:
parent
2204ae214d
commit
8fefbb7c13
38
README.md
38
README.md
@ -1,10 +1,13 @@
|
|||||||
# Simple Todo App with FastAPI and SQLite
|
# Simple Todo App with FastAPI and SQLite
|
||||||
|
|
||||||
A simple Todo API application built with FastAPI and SQLite that provides CRUD operations for todo items.
|
A simple Todo API application built with FastAPI and SQLite that provides CRUD operations for todo items with user authentication.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Create, read, update, and delete todo items
|
- Create, read, update, and delete todo items
|
||||||
|
- User authentication with JWT tokens
|
||||||
|
- User registration and login
|
||||||
|
- Secure password hashing with bcrypt
|
||||||
- RESTful API with FastAPI
|
- RESTful API with FastAPI
|
||||||
- SQLite database with SQLAlchemy ORM
|
- SQLite database with SQLAlchemy ORM
|
||||||
- Database migrations with Alembic
|
- Database migrations with Alembic
|
||||||
@ -19,19 +22,26 @@ simpletodoapp/
|
|||||||
├── migrations/ # Database migration scripts
|
├── migrations/ # Database migration scripts
|
||||||
├── app/ # Application package
|
├── app/ # Application package
|
||||||
│ ├── api/ # API routes
|
│ ├── api/ # API routes
|
||||||
|
│ │ ├── deps.py # Dependency injection and auth
|
||||||
│ │ └── routes/ # Route modules
|
│ │ └── routes/ # Route modules
|
||||||
|
│ │ ├── auth.py # Authentication endpoints
|
||||||
│ │ ├── health.py # Health check endpoint
|
│ │ ├── health.py # Health check endpoint
|
||||||
│ │ └── todos.py # Todo endpoints
|
│ │ └── todos.py # Todo endpoints
|
||||||
│ ├── core/ # Core modules
|
│ ├── core/ # Core modules
|
||||||
│ │ └── config.py # App configuration
|
│ │ ├── config.py # App configuration
|
||||||
|
│ │ └── security.py # Security utilities
|
||||||
│ ├── crud/ # CRUD operations
|
│ ├── crud/ # CRUD operations
|
||||||
│ │ └── todo.py # Todo CRUD operations
|
│ │ ├── todo.py # Todo CRUD operations
|
||||||
|
│ │ └── user.py # User CRUD operations
|
||||||
│ ├── db/ # Database setup
|
│ ├── db/ # Database setup
|
||||||
│ │ └── session.py # DB session and engine
|
│ │ └── session.py # DB session and engine
|
||||||
│ ├── models/ # SQLAlchemy models
|
│ ├── models/ # SQLAlchemy models
|
||||||
│ │ └── todo.py # Todo model
|
│ │ ├── todo.py # Todo model
|
||||||
|
│ │ └── user.py # User model
|
||||||
│ └── schemas/ # Pydantic schemas
|
│ └── schemas/ # Pydantic schemas
|
||||||
│ └── todo.py # Todo schemas
|
│ ├── todo.py # Todo schemas
|
||||||
|
│ ├── user.py # User schemas
|
||||||
|
│ └── token.py # Token schemas
|
||||||
├── main.py # FastAPI application creation
|
├── main.py # FastAPI application creation
|
||||||
└── requirements.txt # Python dependencies
|
└── requirements.txt # Python dependencies
|
||||||
```
|
```
|
||||||
@ -56,7 +66,12 @@ cd simpletodoapp
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Run the application:
|
3. Apply the database migrations:
|
||||||
|
```bash
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Run the application:
|
||||||
```bash
|
```bash
|
||||||
uvicorn main:app --reload
|
uvicorn main:app --reload
|
||||||
```
|
```
|
||||||
@ -74,9 +89,14 @@ After starting the application, you can access the API documentation at:
|
|||||||
### Health Check
|
### Health Check
|
||||||
- `GET /health` - Check the health of the application and database connection
|
- `GET /health` - Check the health of the application and database connection
|
||||||
|
|
||||||
### Todo Operations
|
### Authentication
|
||||||
- `GET /api/v1/todos` - Retrieve all todos (with pagination)
|
- `POST /api/v1/auth/register` - Register a new user
|
||||||
- `POST /api/v1/todos` - Create a new todo
|
- `POST /api/v1/auth/login` - Login and get access token
|
||||||
|
- `GET /api/v1/auth/me` - Get current user information
|
||||||
|
|
||||||
|
### Todo Operations (Requires Authentication)
|
||||||
|
- `GET /api/v1/todos` - Retrieve all todos for current user (with pagination)
|
||||||
|
- `POST /api/v1/todos` - Create a new todo for current user
|
||||||
- `GET /api/v1/todos/{todo_id}` - Retrieve a specific todo
|
- `GET /api/v1/todos/{todo_id}` - Retrieve a specific todo
|
||||||
- `PUT /api/v1/todos/{todo_id}` - Update a specific todo
|
- `PUT /api/v1/todos/{todo_id}` - Update a specific todo
|
||||||
- `DELETE /api/v1/todos/{todo_id}` - Delete a specific todo
|
- `DELETE /api/v1/todos/{todo_id}` - Delete a specific todo
|
||||||
|
57
app/api/deps.py
Normal file
57
app/api/deps.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import crud, models, schemas
|
||||||
|
from app.core import security
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import get_db
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
|
||||||
|
) -> models.User:
|
||||||
|
"""
|
||||||
|
Validate access token and return current user
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[security.ALGORITHM])
|
||||||
|
token_data = schemas.TokenPayload(**payload)
|
||||||
|
except (JWTError, ValidationError) as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
user = crud.user.get(db, id=token_data.sub)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_active_user(
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
) -> models.User:
|
||||||
|
"""
|
||||||
|
Check if current user is active
|
||||||
|
"""
|
||||||
|
if not current_user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_active_superuser(
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
) -> models.User:
|
||||||
|
"""
|
||||||
|
Check if current user is a superuser
|
||||||
|
"""
|
||||||
|
if not current_user.is_superuser:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="The user doesn't have enough privileges"
|
||||||
|
)
|
||||||
|
return current_user
|
77
app/api/routes/auth.py
Normal file
77
app/api/routes/auth.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import crud, models, schemas
|
||||||
|
from app.api import deps
|
||||||
|
from app.core import security
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=schemas.User)
|
||||||
|
def register(
|
||||||
|
*,
|
||||||
|
db: Session = Depends(deps.get_db),
|
||||||
|
user_in: schemas.UserCreate,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Register a new user
|
||||||
|
"""
|
||||||
|
# Check if user with same email exists
|
||||||
|
user = crud.user.get_by_email(db, email=user_in.email)
|
||||||
|
if user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="A user with this email already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user with same username exists
|
||||||
|
user = crud.user.get_by_username(db, username=user_in.username)
|
||||||
|
if user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="A user with this username already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the user
|
||||||
|
user = crud.user.create(db, obj_in=user_in)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=schemas.Token)
|
||||||
|
def login_access_token(
|
||||||
|
db: Session = Depends(deps.get_db),
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
OAuth2 compatible token login, get an access token for future requests
|
||||||
|
"""
|
||||||
|
user = crud.user.authenticate(db, email=form_data.username, password=form_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect email or password",
|
||||||
|
)
|
||||||
|
if not crud.user.is_active(user):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user")
|
||||||
|
|
||||||
|
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
return {
|
||||||
|
"access_token": security.create_access_token(user.id, expires_delta=access_token_expires),
|
||||||
|
"token_type": "bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=schemas.User)
|
||||||
|
def read_users_me(
|
||||||
|
current_user: models.User = Depends(deps.get_current_active_user),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Get current user
|
||||||
|
"""
|
||||||
|
return current_user
|
@ -3,7 +3,8 @@ from typing import List
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import crud
|
from app import crud, models
|
||||||
|
from app.api import deps
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.schemas.todo import Todo, TodoCreate, TodoUpdate
|
from app.schemas.todo import Todo, TodoCreate, TodoUpdate
|
||||||
|
|
||||||
@ -15,55 +16,76 @@ def get_todos(
|
|||||||
skip: int = Query(0, description="Skip the first N items"),
|
skip: int = Query(0, description="Skip the first N items"),
|
||||||
limit: int = Query(100, description="Limit the number of items returned"),
|
limit: int = Query(100, description="Limit the number of items returned"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(deps.get_current_active_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all todos with pagination
|
Get all todos for the current user with pagination
|
||||||
"""
|
"""
|
||||||
return crud.todo.get_todos(db=db, skip=skip, limit=limit)
|
return crud.todo.get_todos(db=db, user_id=current_user.id, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=Todo, status_code=status.HTTP_201_CREATED)
|
@router.post("/", response_model=Todo, status_code=status.HTTP_201_CREATED)
|
||||||
def create_todo(todo: TodoCreate, db: Session = Depends(get_db)):
|
def create_todo(
|
||||||
|
todo: TodoCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(deps.get_current_active_user),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Create a new todo
|
Create a new todo for the current user
|
||||||
"""
|
"""
|
||||||
return crud.todo.create_todo(db=db, todo=todo)
|
return crud.todo.create_todo(db=db, todo=todo, user_id=current_user.id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{todo_id}", response_model=Todo)
|
@router.get("/{todo_id}", response_model=Todo)
|
||||||
def get_todo(todo_id: int, db: Session = Depends(get_db)):
|
def get_todo(
|
||||||
|
todo_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(deps.get_current_active_user),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Get a specific todo by ID
|
Get a specific todo by ID, ensuring it belongs to the current user
|
||||||
"""
|
"""
|
||||||
db_todo = crud.todo.get_todo(db=db, todo_id=todo_id)
|
db_todo = crud.todo.get_todo(db=db, todo_id=todo_id, user_id=current_user.id)
|
||||||
if db_todo is None:
|
if db_todo is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Todo with ID {todo_id} not found"
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Todo with ID {todo_id} not found",
|
||||||
)
|
)
|
||||||
return db_todo
|
return db_todo
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{todo_id}", response_model=Todo)
|
@router.put("/{todo_id}", response_model=Todo)
|
||||||
def update_todo(todo_id: int, todo: TodoUpdate, db: Session = Depends(get_db)):
|
def update_todo(
|
||||||
|
todo_id: int,
|
||||||
|
todo: TodoUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(deps.get_current_active_user),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Update a todo
|
Update a todo, ensuring it belongs to the current user
|
||||||
"""
|
"""
|
||||||
db_todo = crud.todo.update_todo(db=db, todo_id=todo_id, todo=todo)
|
db_todo = crud.todo.update_todo(db=db, todo_id=todo_id, todo=todo, user_id=current_user.id)
|
||||||
if db_todo is None:
|
if db_todo is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Todo with ID {todo_id} not found"
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Todo with ID {todo_id} not found",
|
||||||
)
|
)
|
||||||
return db_todo
|
return db_todo
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
@router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||||
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
|
def delete_todo(
|
||||||
|
todo_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(deps.get_current_active_user),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Delete a todo
|
Delete a todo, ensuring it belongs to the current user
|
||||||
"""
|
"""
|
||||||
success = crud.todo.delete_todo(db=db, todo_id=todo_id)
|
success = crud.todo.delete_todo(db=db, todo_id=todo_id, user_id=current_user.id)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Todo with ID {todo_id} not found"
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Todo with ID {todo_id} not found",
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import secrets
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
|
|
||||||
from pydantic import AnyHttpUrl, validator
|
from pydantic import AnyHttpUrl, validator
|
||||||
@ -8,6 +9,10 @@ class Settings(BaseSettings):
|
|||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
PROJECT_NAME: str = "SimpleTodoApp"
|
PROJECT_NAME: str = "SimpleTodoApp"
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
|
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
|
||||||
|
|
||||||
|
39
app/core/security.py
Normal file
39
app/core/security.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
from jose import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""
|
||||||
|
Creates a JWT token using the subject (typically user ID) and expiration time
|
||||||
|
"""
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
to_encode = {"exp": expire, "sub": str(subject)}
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verifies a plain password against a hashed password
|
||||||
|
"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""
|
||||||
|
Creates a hash from a plain password
|
||||||
|
"""
|
||||||
|
return pwd_context.hash(password)
|
@ -0,0 +1,8 @@
|
|||||||
|
from app.crud import user # noqa: F401
|
||||||
|
from app.crud.todo import ( # noqa: F401
|
||||||
|
create_todo,
|
||||||
|
delete_todo,
|
||||||
|
get_todo,
|
||||||
|
get_todos,
|
||||||
|
update_todo,
|
||||||
|
)
|
@ -6,36 +6,44 @@ from app.models.todo import Todo
|
|||||||
from app.schemas.todo import TodoCreate, TodoUpdate
|
from app.schemas.todo import TodoCreate, TodoUpdate
|
||||||
|
|
||||||
|
|
||||||
def get_todos(db: Session, skip: int = 0, limit: int = 100) -> List[Todo]:
|
def get_todos(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[Todo]:
|
||||||
"""
|
"""
|
||||||
Get all todo items with pagination
|
Get all todo items for a specific user with pagination
|
||||||
"""
|
"""
|
||||||
return db.query(Todo).offset(skip).limit(limit).all()
|
return db.query(Todo).filter(Todo.owner_id == user_id).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
def get_todo(db: Session, todo_id: int) -> Optional[Todo]:
|
def get_todo(db: Session, todo_id: int, user_id: int = None) -> Optional[Todo]:
|
||||||
"""
|
"""
|
||||||
Get a specific todo item by ID
|
Get a specific todo item by ID, optionally filtering by user_id
|
||||||
"""
|
"""
|
||||||
return db.query(Todo).filter(Todo.id == todo_id).first()
|
query = db.query(Todo).filter(Todo.id == todo_id)
|
||||||
|
if user_id is not None:
|
||||||
|
query = query.filter(Todo.owner_id == user_id)
|
||||||
|
return query.first()
|
||||||
|
|
||||||
|
|
||||||
def create_todo(db: Session, todo: TodoCreate) -> Todo:
|
def create_todo(db: Session, todo: TodoCreate, user_id: int) -> Todo:
|
||||||
"""
|
"""
|
||||||
Create a new todo item
|
Create a new todo item
|
||||||
"""
|
"""
|
||||||
db_todo = Todo(title=todo.title, description=todo.description, completed=todo.completed)
|
db_todo = Todo(
|
||||||
|
title=todo.title,
|
||||||
|
description=todo.description,
|
||||||
|
completed=todo.completed,
|
||||||
|
owner_id=user_id,
|
||||||
|
)
|
||||||
db.add(db_todo)
|
db.add(db_todo)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_todo)
|
db.refresh(db_todo)
|
||||||
return db_todo
|
return db_todo
|
||||||
|
|
||||||
|
|
||||||
def update_todo(db: Session, todo_id: int, todo: TodoUpdate) -> Optional[Todo]:
|
def update_todo(db: Session, todo_id: int, todo: TodoUpdate, user_id: int = None) -> Optional[Todo]:
|
||||||
"""
|
"""
|
||||||
Update a todo item
|
Update a todo item, optionally checking owner
|
||||||
"""
|
"""
|
||||||
db_todo = get_todo(db, todo_id)
|
db_todo = get_todo(db, todo_id, user_id)
|
||||||
if not db_todo:
|
if not db_todo:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -49,11 +57,11 @@ def update_todo(db: Session, todo_id: int, todo: TodoUpdate) -> Optional[Todo]:
|
|||||||
return db_todo
|
return db_todo
|
||||||
|
|
||||||
|
|
||||||
def delete_todo(db: Session, todo_id: int) -> bool:
|
def delete_todo(db: Session, todo_id: int, user_id: int = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Delete a todo item
|
Delete a todo item, optionally checking owner
|
||||||
"""
|
"""
|
||||||
db_todo = get_todo(db, todo_id)
|
db_todo = get_todo(db, todo_id, user_id)
|
||||||
if not db_todo:
|
if not db_todo:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
94
app/crud/user.py
Normal file
94
app/crud/user.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
from typing import Any, Dict, Optional, Union
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.security import get_password_hash, verify_password
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserCreate, UserUpdate
|
||||||
|
|
||||||
|
|
||||||
|
def get(db: Session, id: int) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get a user by ID
|
||||||
|
"""
|
||||||
|
return db.query(User).filter(User.id == id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_email(db: Session, email: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get a user by email
|
||||||
|
"""
|
||||||
|
return db.query(User).filter(User.email == email).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_username(db: Session, username: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get a user by username
|
||||||
|
"""
|
||||||
|
return db.query(User).filter(User.username == username).first()
|
||||||
|
|
||||||
|
|
||||||
|
def create(db: Session, *, obj_in: UserCreate) -> User:
|
||||||
|
"""
|
||||||
|
Create a new user
|
||||||
|
"""
|
||||||
|
db_obj = User(
|
||||||
|
email=obj_in.email,
|
||||||
|
username=obj_in.username,
|
||||||
|
hashed_password=get_password_hash(obj_in.password),
|
||||||
|
is_active=obj_in.is_active,
|
||||||
|
is_superuser=obj_in.is_superuser,
|
||||||
|
)
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
def update(db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]) -> User:
|
||||||
|
"""
|
||||||
|
Update a user
|
||||||
|
"""
|
||||||
|
if isinstance(obj_in, dict):
|
||||||
|
update_data = obj_in
|
||||||
|
else:
|
||||||
|
update_data = obj_in.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if update_data.get("password"):
|
||||||
|
hashed_password = get_password_hash(update_data["password"])
|
||||||
|
del update_data["password"]
|
||||||
|
update_data["hashed_password"] = hashed_password
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_obj, field, value)
|
||||||
|
|
||||||
|
db.add(db_obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(db: Session, *, email: str, password: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Authenticate a user
|
||||||
|
"""
|
||||||
|
user = get_by_email(db, email=email)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def is_active(user: User) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user is active
|
||||||
|
"""
|
||||||
|
return user.is_active
|
||||||
|
|
||||||
|
|
||||||
|
def is_superuser(user: User) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user is superuser
|
||||||
|
"""
|
||||||
|
return user.is_superuser
|
@ -0,0 +1,2 @@
|
|||||||
|
from app.models.todo import Todo # noqa: F401
|
||||||
|
from app.models.user import User # noqa: F401
|
@ -1,4 +1,5 @@
|
|||||||
from sqlalchemy import Boolean, Column, DateTime, Integer, String
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
@ -13,5 +14,9 @@ class Todo(Base):
|
|||||||
title = Column(String, index=True)
|
title = Column(String, index=True)
|
||||||
description = Column(String, nullable=True)
|
description = Column(String, nullable=True)
|
||||||
completed = Column(Boolean, default=False)
|
completed = Column(Boolean, default=False)
|
||||||
|
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at = Column(DateTime(timezone=True), default=func.now())
|
created_at = Column(DateTime(timezone=True), default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
owner = relationship("User", back_populates="todos")
|
||||||
|
23
app/models/user.py
Normal file
23
app/models/user.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import Boolean, Column, DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""Database model for User accounts"""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
email = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
username = Column(String, unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String, nullable=False)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_superuser = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
todos = relationship("Todo", back_populates="owner", cascade="all, delete-orphan")
|
@ -0,0 +1,3 @@
|
|||||||
|
from app.schemas.todo import Todo, TodoCreate, TodoUpdate # noqa: F401
|
||||||
|
from app.schemas.token import Token, TokenPayload # noqa: F401
|
||||||
|
from app.schemas.user import User, UserCreate, UserInDB, UserUpdate # noqa: F401
|
@ -15,7 +15,7 @@ class TodoBase(BaseModel):
|
|||||||
class TodoCreate(TodoBase):
|
class TodoCreate(TodoBase):
|
||||||
"""Schema for creating a new Todo item"""
|
"""Schema for creating a new Todo item"""
|
||||||
|
|
||||||
pass
|
# No need to include owner_id here as it will be set from the current user
|
||||||
|
|
||||||
|
|
||||||
class TodoUpdate(BaseModel):
|
class TodoUpdate(BaseModel):
|
||||||
@ -30,6 +30,7 @@ class TodoInDBBase(TodoBase):
|
|||||||
"""Base schema for Todo items from the database"""
|
"""Base schema for Todo items from the database"""
|
||||||
|
|
||||||
id: int
|
id: int
|
||||||
|
owner_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
16
app/schemas/token.py
Normal file
16
app/schemas/token.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""Schema for token response"""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPayload(BaseModel):
|
||||||
|
"""Schema for token payload"""
|
||||||
|
|
||||||
|
sub: Optional[int] = None
|
59
app/schemas/user.py
Normal file
59
app/schemas/user.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
"""Base schema for User objects"""
|
||||||
|
|
||||||
|
email: EmailStr
|
||||||
|
username: str = Field(..., min_length=3, max_length=50)
|
||||||
|
is_active: bool = True
|
||||||
|
is_superuser: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
"""Schema for creating a new user"""
|
||||||
|
|
||||||
|
password: str = Field(..., min_length=8, max_length=100)
|
||||||
|
password_confirm: str
|
||||||
|
|
||||||
|
@field_validator("password_confirm")
|
||||||
|
def passwords_match(cls, v, values):
|
||||||
|
if "password" in values.data and v != values.data["password"]:
|
||||||
|
raise ValueError("Passwords do not match")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""Schema for updating a user"""
|
||||||
|
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
is_superuser: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserInDBBase(UserBase):
|
||||||
|
"""Base schema for User objects from the database"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserInDBBase):
|
||||||
|
"""Schema for User objects returned from the API"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UserInDB(UserInDBBase):
|
||||||
|
"""Schema for User objects with hashed_password"""
|
||||||
|
|
||||||
|
hashed_password: str
|
3
main.py
3
main.py
@ -1,7 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import health, todos
|
from app.api.routes import auth, health, todos
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@ -22,6 +22,7 @@ if settings.BACKEND_CORS_ORIGINS:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
|
app.include_router(auth.router, prefix=settings.API_V1_STR)
|
||||||
app.include_router(todos.router, prefix=settings.API_V1_STR)
|
app.include_router(todos.router, prefix=settings.API_V1_STR)
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
|
|
||||||
|
62
migrations/versions/0002_add_users_table.py
Normal file
62
migrations/versions/0002_add_users_table.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""add users table and update todos table
|
||||||
|
|
||||||
|
Revision ID: 0002
|
||||||
|
Revises: 0001
|
||||||
|
Create Date: 2023-11-10
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "0002"
|
||||||
|
down_revision = "0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create users table
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("email", sa.String(), nullable=False),
|
||||||
|
sa.Column("username", sa.String(), nullable=False),
|
||||||
|
sa.Column("hashed_password", sa.String(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, default=True),
|
||||||
|
sa.Column("is_superuser", sa.Boolean(), nullable=False, default=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
|
||||||
|
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||||
|
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
|
||||||
|
|
||||||
|
# Add owner_id column to todos table
|
||||||
|
with op.batch_alter_table("todos") as batch_op:
|
||||||
|
# First, create a default user to associate with existing todos
|
||||||
|
# For SQLite, we need to handle this differently
|
||||||
|
batch_op.add_column(sa.Column("owner_id", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# Create a foreign key constraint separately
|
||||||
|
with op.batch_alter_table("todos") as batch_op:
|
||||||
|
batch_op.create_foreign_key("fk_todos_owner_id_users", "users", ["owner_id"], ["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove foreign key and owner_id column from todos table
|
||||||
|
with op.batch_alter_table("todos") as batch_op:
|
||||||
|
batch_op.drop_constraint("fk_todos_owner_id_users", type_="foreignkey")
|
||||||
|
batch_op.drop_column("owner_id")
|
||||||
|
|
||||||
|
# Drop users table
|
||||||
|
op.drop_index(op.f("ix_users_username"), table_name="users")
|
||||||
|
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||||
|
op.drop_index(op.f("ix_users_id"), table_name="users")
|
||||||
|
op.drop_table("users")
|
@ -7,4 +7,7 @@ pydantic-settings>=2.0.3
|
|||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
ruff>=0.1.3
|
ruff>=0.1.3
|
||||||
pytest>=7.4.3
|
pytest>=7.4.3
|
||||||
httpx>=0.25.1
|
httpx>=0.25.1
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
passlib[bcrypt]>=1.7.4
|
||||||
|
email-validator>=2.1.0
|
Loading…
x
Reference in New Issue
Block a user