diff --git a/README.md b/README.md index 50381e3..7d4086a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # SimpleTodoApp API -A FastAPI-based backend for a simple Todo application with SQLite database. +A FastAPI-based backend for a simple Todo application with SQLite database and user authentication. ## Features -- Create, read, update, and delete Todo items +- User registration and authentication with JWT tokens +- Create, read, update, and delete Todo items (protected by authentication) +- User-specific Todo items +- Role-based access control (regular users and superusers) - Health check endpoint - SQLite database with SQLAlchemy ORM - Database migrations with Alembic @@ -16,8 +19,19 @@ A FastAPI-based backend for a simple Todo application with SQLite database. simpletodoapp/ ├── api/ # API-related code │ ├── crud/ # CRUD operations +│ │ ├── todo.py # Todo CRUD operations +│ │ └── user.py # User CRUD operations │ ├── routers/ # API endpoints -│ └── schemas/ # Pydantic models for request/response validation +│ │ ├── auth_router.py # Authentication endpoints +│ │ ├── health_router.py # Health check endpoint +│ │ ├── todo_router.py # Todo endpoints +│ │ └── user_router.py # User endpoints +│ ├── schemas/ # Pydantic models for request/response validation +│ │ ├── health.py # Health check schemas +│ │ ├── todo.py # Todo schemas +│ │ └── user.py # User and authentication schemas +│ └── utils/ # Utility functions +│ └── auth.py # Authentication utilities ├── db/ # Database-related code │ ├── database.py # Database connection and session │ └── models.py # SQLAlchemy models @@ -34,11 +48,28 @@ simpletodoapp/ ``` pip install -r requirements.txt ``` -3. Run the application: +3. Apply database migrations: + ``` + alembic upgrade head + ``` +4. Run the application: ``` uvicorn main:app --reload ``` +## Authentication + +The API uses JWT (JSON Web Tokens) for authentication. To use protected endpoints: + +1. Register a new user using `POST /api/users` +2. Get an access token using `POST /api/auth/token` with your username and password +3. Include the token in the `Authorization` header of your requests: + ``` + Authorization: Bearer + ``` + +Access tokens expire after 30 minutes by default. + ## API Documentation Once the server is running, you can access: @@ -47,9 +78,29 @@ Once the server is running, you can access: ## API Endpoints -- `GET /api/health` - Health check endpoint -- `GET /api/todos` - List all todos -- `GET /api/todos/{id}` - Get a single todo by ID -- `POST /api/todos` - Create a new todo -- `PATCH /api/todos/{id}` - Update a todo (partial update) -- `DELETE /api/todos/{id}` - Delete a todo \ No newline at end of file +### Authentication + +- `POST /api/auth/token` - Get access token (login) +- `POST /api/users` - Register a new user + +### Users + +- `GET /api/users/me` - Get current user information +- `PUT /api/users/me` - Update current user information +- `GET /api/users/{id}` - Get user information by ID (current user or superuser only) +- `GET /api/users` - List all users (superuser only) +- `DELETE /api/users/{id}` - Delete a user (superuser only) + +### Todos + +All todo endpoints require authentication. + +- `GET /api/todos` - List all todos for current user +- `GET /api/todos/{id}` - Get a single todo by ID (owned by current user) +- `POST /api/todos` - Create a new todo (owned by current user) +- `PATCH /api/todos/{id}` - Update a todo (owned by current user) +- `DELETE /api/todos/{id}` - Delete a todo (owned by current user) + +### Health + +- `GET /api/health` - Health check endpoint \ No newline at end of file diff --git a/api/crud/todo.py b/api/crud/todo.py index 6989485..c64dd7d 100644 --- a/api/crud/todo.py +++ b/api/crud/todo.py @@ -6,36 +6,36 @@ from api.schemas.todo import TodoCreate, TodoUpdate from db.models import Todo -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 todos with pagination + Get all todos for a 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) -> Optional[Todo]: """ - Get a specific todo by ID + Get a specific todo by ID for a specific user """ - return db.query(Todo).filter(Todo.id == todo_id).first() + return db.query(Todo).filter(Todo.id == todo_id, Todo.owner_id == user_id).first() -def create_todo(db: Session, todo: TodoCreate) -> Todo: +def create_todo(db: Session, todo: TodoCreate, user_id: int) -> Todo: """ - Create a new todo + Create a new todo for a user """ - db_todo = Todo(**todo.dict()) + db_todo = Todo(**todo.dict(), owner_id=user_id) db.add(db_todo) db.commit() db.refresh(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) -> Optional[Todo]: """ - Update an existing todo + Update an existing todo for a user """ - db_todo = get_todo(db, todo_id) + db_todo = get_todo(db, todo_id, user_id) if db_todo is None: return None @@ -49,12 +49,12 @@ def update_todo(db: Session, todo_id: int, todo: TodoUpdate) -> Optional[Todo]: return db_todo -def delete_todo(db: Session, todo_id: int) -> bool: +def delete_todo(db: Session, todo_id: int, user_id: int) -> bool: """ - Delete a todo by ID + Delete a todo by ID for a user Returns True if the todo was deleted, False if it didn't exist """ - db_todo = get_todo(db, todo_id) + db_todo = get_todo(db, todo_id, user_id) if db_todo is None: return False diff --git a/api/crud/user.py b/api/crud/user.py new file mode 100644 index 0000000..f17788c --- /dev/null +++ b/api/crud/user.py @@ -0,0 +1,84 @@ +from typing import Optional + +from sqlalchemy.orm import Session + +from api.schemas.user import UserCreate, UserUpdate +from api.utils.auth import get_password_hash +from db.models import User + + +def get_user(db: Session, user_id: int) -> Optional[User]: + """ + Get a user by ID + """ + return db.query(User).filter(User.id == user_id).first() + + +def get_user_by_email(db: Session, email: str) -> Optional[User]: + """ + Get a user by email + """ + return db.query(User).filter(User.email == email).first() + + +def get_user_by_username(db: Session, username: str) -> Optional[User]: + """ + Get a user by username + """ + return db.query(User).filter(User.username == username).first() + + +def get_users(db: Session, skip: int = 0, limit: int = 100) -> list[User]: + """ + Get all users with pagination + """ + return db.query(User).offset(skip).limit(limit).all() + + +def create_user(db: Session, user: UserCreate) -> User: + """ + Create a new user + """ + hashed_password = get_password_hash(user.password) + db_user = User( + email=user.email, + username=user.username, + hashed_password=hashed_password, + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def update_user(db: Session, user_id: int, user: UserUpdate) -> Optional[User]: + """ + Update an existing user + """ + db_user = get_user(db, user_id) + if db_user is None: + return None + + update_data = user.dict(exclude_unset=True) + if "password" in update_data: + update_data["hashed_password"] = get_password_hash(update_data.pop("password")) + + for key, value in update_data.items(): + setattr(db_user, key, value) + + db.commit() + db.refresh(db_user) + return db_user + + +def delete_user(db: Session, user_id: int) -> bool: + """ + Delete a user + """ + db_user = get_user(db, user_id) + if db_user is None: + return False + + db.delete(db_user) + db.commit() + return True diff --git a/api/routers/auth_router.py b/api/routers/auth_router.py new file mode 100644 index 0000000..febd091 --- /dev/null +++ b/api/routers/auth_router.py @@ -0,0 +1,39 @@ +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 api.schemas.user import Token +from api.utils.auth import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + authenticate_user, + create_access_token, +) +from db.database import get_db + +router = APIRouter() + + +@router.post("/token", response_model=Token) +def login_for_access_token( + form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db), +) -> Any: + """ + Generate JWT token for authenticated user + """ + user = authenticate_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires, + ) + + return {"access_token": access_token, "token_type": "bearer"} diff --git a/api/routers/todo_router.py b/api/routers/todo_router.py index 6c8eee9..c7efa39 100644 --- a/api/routers/todo_router.py +++ b/api/routers/todo_router.py @@ -3,56 +3,80 @@ from sqlalchemy.orm import Session from api.crud.todo import create_todo, delete_todo, get_todo, get_todos, update_todo from api.schemas.todo import TodoCreate, TodoResponse, TodoUpdate +from api.utils.auth import get_current_active_user from db.database import get_db +from db.models import User router = APIRouter() @router.get("/", response_model=list[TodoResponse]) -def read_todos(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): +def read_todos( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): """ - Get all todos with pagination + Get all todos for the current user with pagination """ - todos = get_todos(db, skip=skip, limit=limit) + todos = get_todos(db, user_id=current_user.id, skip=skip, limit=limit) return todos @router.get("/{todo_id}", response_model=TodoResponse) -def read_todo(todo_id: int, db: Session = Depends(get_db)): +def read_todo( + todo_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): """ - Get a specific todo by ID + Get a specific todo by ID for the current user """ - db_todo = get_todo(db, todo_id=todo_id) + db_todo = get_todo(db, todo_id=todo_id, user_id=current_user.id) if db_todo is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found") return db_todo @router.post("/", response_model=TodoResponse, status_code=status.HTTP_201_CREATED) -def create_new_todo(todo: TodoCreate, db: Session = Depends(get_db)): +def create_new_todo( + todo: TodoCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): """ - Create a new todo + Create a new todo for the current user """ - return create_todo(db=db, todo=todo) + return create_todo(db=db, todo=todo, user_id=current_user.id) @router.patch("/{todo_id}", response_model=TodoResponse) -def update_existing_todo(todo_id: int, todo: TodoUpdate, db: Session = Depends(get_db)): +def update_existing_todo( + todo_id: int, + todo: TodoUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): """ - Update an existing todo (partial update) + Update an existing todo (partial update) for the current user """ - db_todo = update_todo(db=db, todo_id=todo_id, todo=todo) + db_todo = update_todo(db=db, todo_id=todo_id, todo=todo, user_id=current_user.id) if db_todo is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found") return db_todo @router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_existing_todo(todo_id: int, db: Session = Depends(get_db)): +def delete_existing_todo( + todo_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): """ - Delete a todo + Delete a todo for the current user """ - success = delete_todo(db=db, todo_id=todo_id) + success = delete_todo(db=db, todo_id=todo_id, user_id=current_user.id) if not success: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found") return None diff --git a/api/routers/user_router.py b/api/routers/user_router.py new file mode 100644 index 0000000..e350c72 --- /dev/null +++ b/api/routers/user_router.py @@ -0,0 +1,110 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from api.crud.user import ( + create_user, + delete_user, + get_user, + get_user_by_email, + get_user_by_username, + get_users, + update_user, +) +from api.schemas.user import UserCreate, UserResponse, UserUpdate +from api.utils.auth import get_current_active_user, get_current_superuser +from db.database import get_db +from db.models import User + +router = APIRouter() + + +@router.get("/", response_model=list[UserResponse]) +def read_users( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_superuser), +) -> list[User]: + """ + Get all users with pagination (superuser only) + """ + users = get_users(db, skip=skip, limit=limit) + return users + + +@router.get("/me", response_model=UserResponse) +def read_user_me(current_user: User = Depends(get_current_active_user)) -> User: + """ + Get current user + """ + return current_user + + +@router.put("/me", response_model=UserResponse) +def update_user_me( + user: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> User: + """ + Update current user + """ + # Prevent user from changing themselves to superuser + if user.is_superuser is not None: + user.is_superuser = current_user.is_superuser + + return update_user(db, current_user.id, user) + + +@router.get("/{user_id}", response_model=UserResponse) +def read_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> User: + """ + Get a specific user by ID (superuser or same user) + """ + db_user = get_user(db, user_id=user_id) + if db_user is None: + raise HTTPException(status_code=404, detail="User not found") + if current_user.id != user_id and not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + return db_user + + +@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def register_user(user: UserCreate, db: Session = Depends(get_db)) -> User: + """ + Register a new user + """ + db_user_by_email = get_user_by_email(db, email=user.email) + if db_user_by_email: + raise HTTPException( + status_code=400, + detail="Email already registered", + ) + + db_user_by_username = get_user_by_username(db, username=user.username) + if db_user_by_username: + raise HTTPException( + status_code=400, + detail="Username already taken", + ) + + return create_user(db=db, user=user) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user_by_id( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_superuser), +) -> None: + """ + Delete a user (superuser only) + """ + success = delete_user(db=db, user_id=user_id) + if not success: + raise HTTPException(status_code=404, detail="User not found") + return None diff --git a/api/schemas/user.py b/api/schemas/user.py new file mode 100644 index 0000000..e03a7da --- /dev/null +++ b/api/schemas/user.py @@ -0,0 +1,51 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field + + +class UserBase(BaseModel): + """Base User schema with common attributes""" + email: EmailStr = Field(..., description="User email address") + username: str = Field(..., min_length=3, max_length=50, description="User username") + + +class UserCreate(UserBase): + """Schema for creating a new user""" + password: str = Field(..., min_length=8, description="User password") + + +class UserUpdate(BaseModel): + """Schema for updating an existing user, all fields are optional""" + email: Optional[EmailStr] = Field(None, description="User email address") + username: Optional[str] = Field( + None, min_length=3, max_length=50, description="User username", + ) + password: Optional[str] = Field(None, min_length=8, description="User password") + is_active: Optional[bool] = Field(None, description="User active status") + is_superuser: Optional[bool] = Field(None, description="User superuser status") + + +class UserResponse(UserBase): + """Schema for user response that includes database fields""" + id: int + is_active: bool + is_superuser: bool + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + """ORM mode config for the UserResponse schema""" + orm_mode = True + from_attributes = True + + +class Token(BaseModel): + """Schema for JWT token""" + access_token: str + token_type: str + + +class TokenData(BaseModel): + """Schema for JWT token data""" + username: Optional[str] = None diff --git a/api/utils/__init__.py b/api/utils/__init__.py new file mode 100644 index 0000000..b5027a8 --- /dev/null +++ b/api/utils/__init__.py @@ -0,0 +1 @@ +"""API utility functions""" diff --git a/api/utils/auth.py b/api/utils/auth.py new file mode 100644 index 0000000..e6f71dd --- /dev/null +++ b/api/utils/auth.py @@ -0,0 +1,94 @@ +from datetime import datetime, timedelta +from typing import Optional, Union + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from api.schemas.user import TokenData +from db.database import get_db +from db.models import User + +# to get a string like this run: openssl rand -hex 32 +# In production, this should be stored in environment variables +SECRET_KEY = "09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7" # noqa: S105 +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/token") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hash a password""" + return pwd_context.hash(password) + + +def authenticate_user(db: Session, username: str, password: str) -> Union[User, bool]: + """Authenticate a user""" + user = db.query(User).filter(User.username == username).first() + if not user or not verify_password(password, user.hashed_password): + return False + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user", + ) + return user + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +async def get_current_user( + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db), +) -> User: + """Get the current user from a JWT token""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError as err: + raise credentials_exception from err + user = db.query(User).filter(User.username == token_data.username).first() + if user is None or not user.is_active: + raise credentials_exception + return user + + +async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: + """Get the current active user""" + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +async def get_current_superuser(current_user: User = Depends(get_current_user)) -> User: + """Get the current superuser""" + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions", + ) + return current_user diff --git a/db/models.py b/db/models.py index 3054722..e8aaeaf 100644 --- a/db/models.py +++ b/db/models.py @@ -1,9 +1,28 @@ -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 .database import Base +class User(Base): + """ + User model for authentication and authorization + """ + __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), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + todos = relationship("Todo", back_populates="owner", cascade="all, delete-orphan") + + class Todo(Base): """ Todo model representing a task to be done @@ -16,3 +35,7 @@ class Todo(Base): completed = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Add relationship to User + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) + owner = relationship("User", back_populates="todos") diff --git a/main.py b/main.py index 343603a..1aff3b6 100644 --- a/main.py +++ b/main.py @@ -2,12 +2,12 @@ import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from api.routers import health_router, todo_router +from api.routers import auth_router, health_router, todo_router, user_router from db.database import create_tables app = FastAPI( title="SimpleTodoApp API", - description="API for a simple todo application", + description="API for a simple todo application with authentication", version="0.1.0", ) @@ -21,6 +21,8 @@ app.add_middleware( ) # Include routers +app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) +app.include_router(user_router.router, prefix="/api/users", tags=["users"]) app.include_router(todo_router.router, prefix="/api/todos", tags=["todos"]) app.include_router(health_router.router, prefix="/api", tags=["health"]) diff --git a/migrations/versions/2_add_users_table_and_update_todos.py b/migrations/versions/2_add_users_table_and_update_todos.py new file mode 100644 index 0000000..7a64b4f --- /dev/null +++ b/migrations/versions/2_add_users_table_and_update_todos.py @@ -0,0 +1,69 @@ +"""Add users table and update todos + +Revision ID: 2_add_users_table_and_update_todos +Revises: 1_initial_create_todos_table +Create Date: 2023-07-20 10:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = '2_add_users_table_and_update_todos' +down_revision = '1_initial_create_todos_table' +branch_labels = None +depends_on = None + + +def upgrade(): + # 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=True, default=True), + sa.Column('is_superuser', sa.Boolean(), nullable=True, default=False), + sa.Column( + 'created_at', + sa.DateTime(timezone=True), + server_default=sa.text('(CURRENT_TIMESTAMP)'), + nullable=True, + ), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + + # Add owner_id column to todos table + op.add_column('todos', sa.Column('owner_id', sa.Integer(), nullable=True)) + + # Create foreign key from todos to users + op.create_foreign_key( + 'fk_todos_owner_id_users', + 'todos', 'users', + ['owner_id'], ['id'], + ) + + # Update all existing todos to have owner_id = 1 (first user) + op.execute("UPDATE todos SET owner_id = 1") + + # Make owner_id non-nullable after updating existing todos + op.alter_column('todos', 'owner_id', nullable=False) + + +def downgrade(): + # Drop foreign key constraint + op.drop_constraint('fk_todos_owner_id_users', 'todos', type_='foreignkey') + + # Drop owner_id column from todos + op.drop_column('todos', 'owner_id') + + # Drop users table + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') diff --git a/requirements.txt b/requirements.txt index 7dba23f..cd5c8f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,7 @@ alembic>=1.11.1 ruff>=0.0.280 python-dotenv>=1.0.0 python-multipart>=0.0.6 -pathlib>=1.0.1 \ No newline at end of file +pathlib>=1.0.1 +passlib>=1.7.4 +python-jose>=3.3.0 +bcrypt>=4.0.1 \ No newline at end of file