diff --git a/README.md b/README.md index cc2b7ac..c234998 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ A RESTful API for managing tasks, built with FastAPI and SQLite. ## Features -- Task CRUD operations +- User authentication with JWT tokens +- User registration and login +- Task CRUD operations with user-based access control - Task status and priority management - Task completion tracking - API documentation with Swagger UI and ReDoc @@ -18,6 +20,9 @@ A RESTful API for managing tasks, built with FastAPI and SQLite. - SQLite: Lightweight relational database - Pydantic: Data validation and settings management - Uvicorn: ASGI server for FastAPI applications +- JWT: JSON Web Tokens for authentication +- Passlib: Password hashing and verification +- Python-Jose: Python implementation of JWT ## API Endpoints @@ -25,14 +30,21 @@ A RESTful API for managing tasks, built with FastAPI and SQLite. - `GET /`: Get API information and available endpoints -### Task Management +### Authentication -- `GET /tasks`: Get all tasks -- `POST /tasks`: Create a new task -- `GET /tasks/{task_id}`: Get a specific task -- `PUT /tasks/{task_id}`: Update a task -- `DELETE /tasks/{task_id}`: Delete a task -- `POST /tasks/{task_id}/complete`: Mark a task as completed +- `POST /auth/register`: Register a new user +- `POST /auth/login`: Login to get access token +- `GET /auth/me`: Get current user information +- `POST /auth/test-token`: Test if the access token is valid + +### Task Management (requires authentication) + +- `GET /tasks`: Get all tasks for the current user +- `POST /tasks`: Create a new task for the current user +- `GET /tasks/{task_id}`: Get a specific task for the current user +- `PUT /tasks/{task_id}`: Update a task for the current user +- `DELETE /tasks/{task_id}`: Delete a task for the current user +- `POST /tasks/{task_id}/complete`: Mark a task as completed for the current user ### Health and Diagnostic Endpoints @@ -41,12 +53,37 @@ A RESTful API for managing tasks, built with FastAPI and SQLite. ## Example Curl Commands -### Create a new task +### Register a new user + +```bash +curl -X 'POST' \ + 'https://taskmanagerapi-ttkjqk.backend.im/auth/register' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "user@example.com", + "username": "testuser", + "password": "password123" +}' +``` + +### Login to get access token + +```bash +curl -X 'POST' \ + 'https://taskmanagerapi-ttkjqk.backend.im/auth/login' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'username=user@example.com&password=password123' +``` + +### Create a new task (with authentication) ```bash curl -X 'POST' \ 'https://taskmanagerapi-ttkjqk.backend.im/tasks/' \ -H 'accept: application/json' \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "title": "Complete project documentation", @@ -58,28 +95,31 @@ curl -X 'POST' \ }' ``` -### Get all tasks +### Get all tasks (with authentication) ```bash curl -X 'GET' \ 'https://taskmanagerapi-ttkjqk.backend.im/tasks/' \ - -H 'accept: application/json' + -H 'accept: application/json' \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` -### Get a specific task +### Get a specific task (with authentication) ```bash curl -X 'GET' \ 'https://taskmanagerapi-ttkjqk.backend.im/tasks/1' \ - -H 'accept: application/json' + -H 'accept: application/json' \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` -### Update a task +### Update a task (with authentication) ```bash curl -X 'PUT' \ 'https://taskmanagerapi-ttkjqk.backend.im/tasks/1' \ -H 'accept: application/json' \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \ -H 'Content-Type: application/json' \ -d '{ "title": "Updated title", @@ -88,20 +128,31 @@ curl -X 'PUT' \ }' ``` -### Delete a task +### Delete a task (with authentication) ```bash curl -X 'DELETE' \ 'https://taskmanagerapi-ttkjqk.backend.im/tasks/1' \ - -H 'accept: application/json' + -H 'accept: application/json' \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` -### Mark a task as completed +### Mark a task as completed (with authentication) ```bash curl -X 'POST' \ 'https://taskmanagerapi-ttkjqk.backend.im/tasks/1/complete' \ - -H 'accept: application/json' + -H 'accept: application/json' \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' +``` + +### Get current user information (with authentication) + +```bash +curl -X 'GET' \ + 'https://taskmanagerapi-ttkjqk.backend.im/auth/me' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' ``` ## Project Structure @@ -112,12 +163,28 @@ taskmanagerapi/ │ └── versions/ # Migration scripts ├── app/ │ ├── api/ # API endpoints +│ │ ├── deps.py # Dependency injection for authentication │ │ └── routers/ # API route definitions +│ │ ├── auth.py # Authentication endpoints +│ │ └── tasks.py # Task management endpoints │ ├── core/ # Core application code +│ │ ├── config.py # Application configuration +│ │ └── security.py # Security utilities (JWT, password hashing) │ ├── crud/ # CRUD operations -│ ├── db/ # Database setup and models +│ │ ├── base.py # Base CRUD operations +│ │ ├── task.py # Task CRUD operations +│ │ └── user.py # User CRUD operations +│ ├── db/ # Database setup +│ │ ├── base.py # Base imports for models +│ │ ├── base_class.py # Base class for SQLAlchemy models +│ │ ├── init_db.py # Database initialization +│ │ └── session.py # Database session management │ ├── models/ # SQLAlchemy models +│ │ ├── task.py # Task model +│ │ └── user.py # User model │ └── schemas/ # Pydantic schemas/models +│ ├── task.py # Task schemas +│ └── user.py # User and authentication schemas ├── main.py # Application entry point └── requirements.txt # Project dependencies ``` @@ -183,6 +250,22 @@ uvicorn main:app --reload The API will be available at http://localhost:8000 +### Default Admin User + +During initialization, the system creates a default admin user: +- Email: admin@example.com +- Username: admin +- Password: adminpassword + +You can use these credentials to authenticate and get started right away. + +### Authentication Flow + +1. **Register a new user**: Send a POST request to `/auth/register` with email, username, and password +2. **Login**: Send a POST request to `/auth/login` with username/email and password to receive an access token +3. **Use token**: Include the token in your requests as a Bearer token in the Authorization header +4. **Access protected endpoints**: Use the token to access protected task management endpoints + ### API Documentation - Swagger UI: http://localhost:8000/docs diff --git a/alembic/env.py b/alembic/env.py index 7a84e77..65724c4 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -11,7 +11,7 @@ from sqlalchemy import pool from alembic import context # Add the parent directory to the Python path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -29,27 +29,27 @@ db_url = config.get_main_option("sqlalchemy.url") if db_url.startswith("sqlite:///"): # Extract the path part after sqlite:/// if db_url.startswith("sqlite:////"): # Absolute path (4 slashes) - db_path = db_url[len("sqlite:///"):] + db_path = db_url[len("sqlite:///") :] else: # Relative path (3 slashes) - db_path = db_url[len("sqlite:///"):] - + db_path = db_url[len("sqlite:///") :] + # Get the directory path db_dir = os.path.dirname(db_path) logger.info(f"Database URL: {db_url}") logger.info(f"Database path: {db_path}") logger.info(f"Database directory: {db_dir}") - + # Create directory if it doesn't exist try: os.makedirs(db_dir, exist_ok=True) logger.info(f"Ensured database directory exists: {db_dir}") - + # Test if we can create the database file try: # Try to touch the database file Path(db_path).touch(exist_ok=True) logger.info(f"Database file is accessible: {db_path}") - + # Test direct SQLite connection try: conn = sqlite3.connect(db_path) @@ -66,6 +66,7 @@ if db_url.startswith("sqlite:///"): # add your model's MetaData object here # for 'autogenerate' support from app.db.base import Base # noqa + target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, @@ -89,7 +90,7 @@ def run_migrations_offline(): try: url = config.get_main_option("sqlalchemy.url") logger.info(f"Running offline migrations using URL: {url}") - + context.configure( url=url, target_metadata=target_metadata, @@ -101,7 +102,7 @@ def run_migrations_offline(): logger.info("Running offline migrations...") context.run_migrations() logger.info("Offline migrations completed successfully") - + except Exception as e: logger.error(f"Offline migration error: {e}") # Re-raise the error @@ -120,11 +121,11 @@ def run_migrations_online(): cfg = config.get_section(config.config_ini_section) url = cfg.get("sqlalchemy.url") logger.info(f"Running online migrations using URL: {url}") - + # Create engine with retry logic max_retries = 3 last_error = None - + for retry in range(max_retries): try: logger.info(f"Connection attempt {retry + 1}/{max_retries}") @@ -133,13 +134,13 @@ def run_migrations_online(): prefix="sqlalchemy.", poolclass=pool.NullPool, ) - + # Configure SQLite for better reliability @event.listens_for(connectable, "connect") def setup_sqlite_connection(dbapi_connection, connection_record): dbapi_connection.execute("PRAGMA journal_mode=WAL") dbapi_connection.execute("PRAGMA synchronous=NORMAL") - + # Connect and run migrations with connectable.connect() as connection: logger.info("Connection successful") @@ -152,35 +153,39 @@ def run_migrations_online(): context.run_migrations() logger.info("Migrations completed successfully") return # Success, exit the function - + except Exception as e: last_error = e logger.error(f"Migration attempt {retry + 1} failed: {e}") if retry < max_retries - 1: import time + wait_time = (retry + 1) * 2 # Exponential backoff logger.info(f"Retrying in {wait_time} seconds...") time.sleep(wait_time) - + # If we get here, all retries failed - raise Exception(f"Failed to run migrations after {max_retries} attempts: {last_error}") - + raise Exception( + f"Failed to run migrations after {max_retries} attempts: {last_error}" + ) + except Exception as e: logger.error(f"Migration error: {e}") - + # Print diagnostic information from sqlalchemy import __version__ as sa_version + logger.error(f"SQLAlchemy version: {sa_version}") - + # Get directory info if url and url.startswith("sqlite:///"): if url.startswith("sqlite:////"): # Absolute path - db_path = url[len("sqlite:///"):] + db_path = url[len("sqlite:///") :] else: # Relative path - db_path = url[len("sqlite:///"):] - + db_path = url[len("sqlite:///") :] + db_dir = os.path.dirname(db_path) - + # Directory permissions if os.path.exists(db_dir): stats = os.stat(db_dir) @@ -189,7 +194,7 @@ def run_migrations_online(): logger.error(f"DB directory is writable: {os.access(db_dir, os.W_OK)}") else: logger.error("DB directory exists: No") - + # File permissions if file exists if os.path.exists(db_path): stats = os.stat(db_path) @@ -198,7 +203,7 @@ def run_migrations_online(): logger.error(f"DB file is writable: {os.access(db_path, os.W_OK)}") else: logger.error("DB file exists: No") - + # Re-raise the error raise @@ -206,4 +211,4 @@ def run_migrations_online(): if context.is_offline_mode(): run_migrations_offline() else: - run_migrations_online() \ No newline at end of file + run_migrations_online() diff --git a/alembic/versions/0001_create_tasks_table.py b/alembic/versions/0001_create_tasks_table.py index 88b9cde..7831ff0 100644 --- a/alembic/versions/0001_create_tasks_table.py +++ b/alembic/versions/0001_create_tasks_table.py @@ -1,16 +1,16 @@ """create tasks table Revision ID: 0001 -Revises: +Revises: Create Date: 2025-05-14 """ + from alembic import op import sqlalchemy as sa -from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. -revision = '0001' +revision = "0001" down_revision = None branch_labels = None depends_on = None @@ -18,21 +18,31 @@ depends_on = None def upgrade(): op.create_table( - 'task', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sa.String(length=100), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('priority', sa.Enum('low', 'medium', 'high', name='taskpriority'), default='medium'), - sa.Column('status', sa.Enum('todo', 'in_progress', 'done', name='taskstatus'), default='todo'), - sa.Column('due_date', sa.DateTime(), nullable=True), - sa.Column('completed', sa.Boolean(), default=False), - sa.Column('created_at', sa.DateTime(), default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), default=sa.func.now(), onupdate=sa.func.now()), - sa.PrimaryKeyConstraint('id') + "task", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "priority", + sa.Enum("low", "medium", "high", name="taskpriority"), + default="medium", + ), + sa.Column( + "status", + sa.Enum("todo", "in_progress", "done", name="taskstatus"), + default="todo", + ), + sa.Column("due_date", sa.DateTime(), nullable=True), + sa.Column("completed", sa.Boolean(), default=False), + sa.Column("created_at", sa.DateTime(), default=sa.func.now()), + sa.Column( + "updated_at", sa.DateTime(), default=sa.func.now(), onupdate=sa.func.now() + ), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_task_id'), 'task', ['id'], unique=False) + op.create_index(op.f("ix_task_id"), "task", ["id"], unique=False) def downgrade(): - op.drop_index(op.f('ix_task_id'), table_name='task') - op.drop_table('task') \ No newline at end of file + op.drop_index(op.f("ix_task_id"), table_name="task") + op.drop_table("task") diff --git a/alembic/versions/0002_add_user_table.py b/alembic/versions/0002_add_user_table.py new file mode 100644 index 0000000..a2a68e1 --- /dev/null +++ b/alembic/versions/0002_add_user_table.py @@ -0,0 +1,58 @@ +"""Add user table and task relation + +Revision ID: 0002 +Revises: 0001_create_tasks_table +Create Date: 2023-10-31 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic +revision = "0002" +down_revision = "0001_create_tasks_table" +branch_labels = None +depends_on = None + + +def upgrade(): + # Create user table + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("username", sa.String(length=100), nullable=False), + sa.Column("hashed_password", sa.String(length=255), 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(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + # Create indexes on user table + op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) + op.create_index(op.f("ix_user_id"), "user", ["id"], unique=False) + op.create_index(op.f("ix_user_username"), "user", ["username"], unique=True) + + # Add user_id column to task table + with op.batch_alter_table("task", schema=None) as batch_op: + batch_op.add_column(sa.Column("user_id", sa.Integer(), nullable=True)) + batch_op.create_foreign_key("fk_task_user_id", "user", ["user_id"], ["id"]) + + +def downgrade(): + # Remove foreign key and user_id column from task table + with op.batch_alter_table("task", schema=None) as batch_op: + batch_op.drop_constraint("fk_task_user_id", type_="foreignkey") + batch_op.drop_column("user_id") + + # Drop indexes on user table + op.drop_index(op.f("ix_user_username"), table_name="user") + op.drop_index(op.f("ix_user_id"), table_name="user") + op.drop_index(op.f("ix_user_email"), table_name="user") + + # Drop user table + op.drop_table("user") diff --git a/app/__init__.py b/app/__init__.py index bdff6c4..edabda9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -# App package \ No newline at end of file +# App package diff --git a/app/api/__init__.py b/app/api/__init__.py index a757e69..28b07ef 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1 +1 @@ -# API package \ No newline at end of file +# API package diff --git a/app/api/deps.py b/app/api/deps.py index b7be88f..521f3d3 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -1,4 +1,61 @@ -from typing import Generator +from typing import Optional -# Use the improved get_db function from session.py -from app.db.session import get_db \ No newline at end of file +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.core.config import settings +from app.core.security import ALGORITHM +from app.models.user import User +from app.schemas.user import TokenPayload +from app import crud + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False) + + +def get_current_user( + db: Session = Depends(get_db), token: Optional[str] = Depends(oauth2_scheme) +) -> Optional[User]: + """ + Get the current user from the provided JWT token. + Returns None if no token is provided or the token is invalid. + """ + if not token: + return None + + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + token_data = TokenPayload(**payload) + except (JWTError, ValidationError): + return None + + user = crud.user.get(db, id=token_data.sub) + if not user: + return None + + return user + + +def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + """ + Get the current active user and raise an exception if the user is not + authenticated or inactive. + """ + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not crud.user.is_active(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + ) + + return current_user diff --git a/app/api/routers/__init__.py b/app/api/routers/__init__.py index f6ad4ef..ec7a5e3 100644 --- a/app/api/routers/__init__.py +++ b/app/api/routers/__init__.py @@ -1,6 +1,7 @@ from fastapi import APIRouter -from app.api.routers import tasks +from app.api.routers import tasks, auth api_router = APIRouter() -api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"]) \ No newline at end of file +api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py new file mode 100644 index 0000000..e110b0d --- /dev/null +++ b/app/api/routers/auth.py @@ -0,0 +1,95 @@ +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 +from app.api.deps import get_db, get_current_active_user +from app.core.config import settings +from app.core.security import create_access_token +from app.models.user import User +from app.schemas.user import User as UserSchema +from app.schemas.user import UserCreate, Token + +router = APIRouter() + + +@router.post("/register", response_model=UserSchema) +def register( + *, + db: Session = Depends(get_db), + user_in: UserCreate, +) -> Any: + """ + Register a new user. + """ + # Check if user with this email already 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 this username already 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 new user + user = crud.user.create(db, obj_in=user_in) + return user + + +@router.post("/login", response_model=Token) +def login( + db: Session = Depends(get_db), + form_data: OAuth2PasswordRequestForm = Depends(), +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests. + """ + user = crud.user.authenticate( + db, email_or_username=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email/username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not crud.user.is_active(user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Inactive user", + ) + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": create_access_token( + subject=user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } + + +@router.post("/test-token", response_model=UserSchema) +def test_token(current_user: User = Depends(get_current_active_user)) -> Any: + """ + Test access token. + """ + return current_user + + +@router.get("/me", response_model=UserSchema) +def read_users_me(current_user: User = Depends(get_current_active_user)) -> Any: + """ + Get current user. + """ + return current_user diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index 567fda4..141028b 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -1,11 +1,12 @@ from typing import Any, List, Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from app import crud -from app.api.deps import get_db +from app.api.deps import get_db, get_current_active_user from app.models.task import TaskStatus +from app.models.user import User from app.schemas.task import Task, TaskCreate, TaskUpdate router = APIRouter() @@ -17,73 +18,82 @@ def read_tasks( skip: int = 0, limit: int = 100, status: Optional[TaskStatus] = None, + current_user: User = Depends(get_current_active_user), ) -> Any: """ - Retrieve tasks. + Retrieve tasks for the current user. """ try: import traceback import sqlite3 - from sqlalchemy import text from app.db.session import db_file - + print(f"Getting tasks with status: {status}, skip: {skip}, limit: {limit}") - + # Try the normal SQLAlchemy approach first try: if status: - tasks = crud.task.get_by_status(db, status=status) + tasks = crud.task.get_by_status( + db, status=status, user_id=current_user.id + ) else: - tasks = crud.task.get_multi(db, skip=skip, limit=limit) + tasks = crud.task.get_multi( + db, skip=skip, limit=limit, user_id=current_user.id + ) return tasks except Exception as e: print(f"Error getting tasks with SQLAlchemy: {e}") print(traceback.format_exc()) # Continue to fallback - + # Fallback to direct SQLite approach try: conn = sqlite3.connect(str(db_file)) conn.row_factory = sqlite3.Row cursor = conn.cursor() - + if status: - cursor.execute("SELECT * FROM task WHERE status = ? LIMIT ? OFFSET ?", - (status.value, limit, skip)) + cursor.execute( + "SELECT * FROM task WHERE status = ? AND user_id = ? LIMIT ? OFFSET ?", + (status.value, current_user.id, limit, skip), + ) else: - cursor.execute("SELECT * FROM task LIMIT ? OFFSET ?", (limit, skip)) - + cursor.execute( + "SELECT * FROM task WHERE user_id = ? LIMIT ? OFFSET ?", + (current_user.id, limit, skip), + ) + rows = cursor.fetchall() - + # Convert to Task objects tasks = [] for row in rows: task_dict = dict(row) # Convert completed to boolean - if 'completed' in task_dict: - task_dict['completed'] = bool(task_dict['completed']) - + if "completed" in task_dict: + task_dict["completed"] = bool(task_dict["completed"]) + # Convert to object with attributes class TaskResult: def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) - + tasks.append(TaskResult(**task_dict)) - + conn.close() return tasks except Exception as e: print(f"Error getting tasks with direct SQLite: {e}") print(traceback.format_exc()) raise - + except Exception as e: print(f"Global error in read_tasks: {e}") print(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error retrieving tasks: {str(e)}" + detail=f"Error retrieving tasks: {str(e)}", ) @@ -92,9 +102,10 @@ def create_task( *, db: Session = Depends(get_db), task_in: TaskCreate, + current_user: User = Depends(get_current_active_user), ) -> Any: """ - Create new task - using direct SQLite approach for reliability. + Create new task for the current user - using direct SQLite approach for reliability. """ import sqlite3 import time @@ -102,60 +113,60 @@ def create_task( import traceback from datetime import datetime from app.db.session import db_file - + # Log creation attempt print(f"[{datetime.now().isoformat()}] Task creation requested", file=sys.stdout) - + # Use direct SQLite for maximum reliability try: # Extract task data regardless of Pydantic version try: - if hasattr(task_in, 'model_dump'): + if hasattr(task_in, "model_dump"): task_data = task_in.model_dump() - elif hasattr(task_in, 'dict'): + elif hasattr(task_in, "dict"): task_data = task_in.dict() else: # Fallback for any case task_data = { - 'title': getattr(task_in, 'title', 'Untitled Task'), - 'description': getattr(task_in, 'description', ''), - 'priority': getattr(task_in, 'priority', 'medium'), - 'status': getattr(task_in, 'status', 'todo'), - 'due_date': getattr(task_in, 'due_date', None), - 'completed': getattr(task_in, 'completed', False) + "title": getattr(task_in, "title", "Untitled Task"), + "description": getattr(task_in, "description", ""), + "priority": getattr(task_in, "priority", "medium"), + "status": getattr(task_in, "status", "todo"), + "due_date": getattr(task_in, "due_date", None), + "completed": getattr(task_in, "completed", False), } print(f"Task data: {task_data}") except Exception as e: print(f"Error extracting task data: {e}") # Fallback to minimal data task_data = { - 'title': str(getattr(task_in, 'title', 'Unknown Title')), - 'description': str(getattr(task_in, 'description', '')), - 'priority': 'medium', - 'status': 'todo', - 'completed': False + "title": str(getattr(task_in, "title", "Unknown Title")), + "description": str(getattr(task_in, "description", "")), + "priority": "medium", + "status": "todo", + "completed": False, } - + # Format due_date if present - if task_data.get('due_date'): + if task_data.get("due_date"): try: - if isinstance(task_data['due_date'], datetime): - task_data['due_date'] = task_data['due_date'].isoformat() - elif isinstance(task_data['due_date'], str): + if isinstance(task_data["due_date"], datetime): + task_data["due_date"] = task_data["due_date"].isoformat() + elif isinstance(task_data["due_date"], str): # Standardize format by parsing and reformatting parsed_date = datetime.fromisoformat( - task_data['due_date'].replace('Z', '+00:00') + task_data["due_date"].replace("Z", "+00:00") ) - task_data['due_date'] = parsed_date.isoformat() + task_data["due_date"] = parsed_date.isoformat() except Exception as e: print(f"Warning: Could not parse due_date: {e}") # Keep as-is or set to None if invalid - if not isinstance(task_data['due_date'], str): - task_data['due_date'] = None - + if not isinstance(task_data["due_date"], str): + task_data["due_date"] = None + # Get current timestamp for created/updated fields now = datetime.utcnow().isoformat() - + # Connect to SQLite with retry logic for retry in range(3): conn = None @@ -163,7 +174,7 @@ def create_task( # Try to connect to the database with a timeout conn = sqlite3.connect(str(db_file), timeout=30) cursor = conn.cursor() - + # Create the task table if it doesn't exist - using minimal schema cursor.execute(""" CREATE TABLE IF NOT EXISTS task ( @@ -175,122 +186,134 @@ def create_task( due_date TEXT, completed INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT DEFAULT CURRENT_TIMESTAMP + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER ) """) - + # Insert the task - provide defaults for all fields cursor.execute( """ INSERT INTO task ( title, description, priority, status, - due_date, completed, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + due_date, completed, created_at, updated_at, user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( - task_data.get('title', 'Untitled'), - task_data.get('description', ''), - task_data.get('priority', 'medium'), - task_data.get('status', 'todo'), - task_data.get('due_date'), - 1 if task_data.get('completed') else 0, + task_data.get("title", "Untitled"), + task_data.get("description", ""), + task_data.get("priority", "medium"), + task_data.get("status", "todo"), + task_data.get("due_date"), + 1 if task_data.get("completed") else 0, now, - now - ) + now, + current_user.id, + ), ) - + # Get the ID of the inserted task task_id = cursor.lastrowid print(f"Task inserted with ID: {task_id}") - + # Commit the transaction conn.commit() - + # Retrieve the created task to return it - cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,)) + cursor.execute( + "SELECT * FROM task WHERE id = ? AND user_id = ?", + (task_id, current_user.id), + ) row = cursor.fetchone() - + if row: # Get column names from cursor description column_names = [desc[0] for desc in cursor.description] - + # Create a dictionary from row values task_dict = dict(zip(column_names, row)) - + # Convert 'completed' to boolean - if 'completed' in task_dict: - task_dict['completed'] = bool(task_dict['completed']) - + if "completed" in task_dict: + task_dict["completed"] = bool(task_dict["completed"]) + # Create an object that mimics the Task model class TaskResult: def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) - + print(f"Task created successfully: ID={task_id}") - + # Close the connection and return the task conn.close() return TaskResult(**task_dict) else: conn.close() - raise Exception(f"Task creation succeeded but retrieval failed for ID: {task_id}") - + raise Exception( + f"Task creation succeeded but retrieval failed for ID: {task_id}" + ) + except sqlite3.OperationalError as e: if conn: conn.close() - + # Check if retry is appropriate if "database is locked" in str(e) and retry < 2: wait_time = (retry + 1) * 1.5 # Exponential backoff - print(f"Database locked, retrying in {wait_time}s (attempt {retry+1}/3)") + print( + f"Database locked, retrying in {wait_time}s (attempt {retry + 1}/3)" + ) time.sleep(wait_time) else: print(f"SQLite operational error: {e}") raise - + except Exception as e: if conn: # Try to rollback if connection is still open try: conn.rollback() - except: + except Exception: pass conn.close() - + print(f"Error in SQLite task creation: {e}") print(traceback.format_exc()) - + # Only retry on specific transient errors - if retry < 2 and ("locked" in str(e).lower() or "busy" in str(e).lower()): + if retry < 2 and ( + "locked" in str(e).lower() or "busy" in str(e).lower() + ): time.sleep(1) continue raise - + # If we reach here, the retry loop failed raise Exception("Failed to create task after multiple attempts") - + except Exception as sqlite_error: # Final fallback: try SQLAlchemy approach try: print(f"Direct SQLite approach failed: {sqlite_error}") print("Trying SQLAlchemy as fallback...") - - task = crud.task.create(db, obj_in=task_in) + + task = crud.task.create_with_owner( + db, obj_in=task_in, user_id=current_user.id + ) print(f"Task created with SQLAlchemy fallback: ID={task.id}") return task - + except Exception as alch_error: print(f"SQLAlchemy fallback also failed: {alch_error}") print(traceback.format_exc()) - + # Provide detailed error information error_detail = f"Task creation failed. Primary error: {str(sqlite_error)}. Fallback error: {str(alch_error)}" print(f"Final error: {error_detail}") - + raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=error_detail + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=error_detail ) @@ -299,54 +322,60 @@ def read_task( *, db: Session = Depends(get_db), task_id: int, + current_user: User = Depends(get_current_active_user), ) -> Any: """ - Get task by ID. + Get task by ID for the current user. """ try: import traceback import sqlite3 from app.db.session import db_file - + print(f"Getting task with ID: {task_id}") - + # Try the normal SQLAlchemy approach first try: - task = crud.task.get(db, id=task_id) + task = crud.task.get_by_id_and_user( + db, task_id=task_id, user_id=current_user.id + ) if task: return task - # Fall through to direct SQLite if task not found + # Task not found or doesn't belong to user - check further with direct SQLite except Exception as e: print(f"Error getting task with SQLAlchemy: {e}") print(traceback.format_exc()) # Continue to fallback - + # Fallback to direct SQLite approach try: conn = sqlite3.connect(str(db_file)) conn.row_factory = sqlite3.Row cursor = conn.cursor() - - cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,)) + + cursor.execute( + "SELECT * FROM task WHERE id = ? AND user_id = ?", + (task_id, current_user.id), + ) row = cursor.fetchone() - + if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found", ) - + task_dict = dict(row) # Convert completed to boolean - if 'completed' in task_dict: - task_dict['completed'] = bool(task_dict['completed']) - + if "completed" in task_dict: + task_dict["completed"] = bool(task_dict["completed"]) + # Convert to object with attributes class TaskResult: def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) - + conn.close() return TaskResult(**task_dict) except HTTPException: @@ -356,9 +385,9 @@ def read_task( print(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error retrieving task: {str(e)}" + detail=f"Error retrieving task: {str(e)}", ) - + except HTTPException: raise # Re-raise any HTTP exceptions except Exception as e: @@ -366,7 +395,7 @@ def read_task( print(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error retrieving task: {str(e)}" + detail=f"Error retrieving task: {str(e)}", ) @@ -376,30 +405,34 @@ def update_task( db: Session = Depends(get_db), task_id: int, task_in: TaskUpdate, + current_user: User = Depends(get_current_active_user), ) -> Any: """ - Update a task. + Update a task for the current user. """ try: import traceback import sqlite3 - import json from datetime import datetime from app.db.session import db_file - + print(f"Updating task with ID: {task_id}, data: {task_in}") - + # Handle datetime conversion for due_date if present if hasattr(task_in, "due_date") and task_in.due_date is not None: if isinstance(task_in.due_date, str): try: - task_in.due_date = datetime.fromisoformat(task_in.due_date.replace('Z', '+00:00')) + task_in.due_date = datetime.fromisoformat( + task_in.due_date.replace("Z", "+00:00") + ) except Exception as e: print(f"Error parsing due_date: {e}") - + # Try the normal SQLAlchemy approach first try: - task = crud.task.get(db, id=task_id) + task = crud.task.get_by_id_and_user( + db, task_id=task_id, user_id=current_user.id + ) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -413,84 +446,94 @@ def update_task( print(f"Error updating task with SQLAlchemy: {e}") print(traceback.format_exc()) # Continue to fallback - + # Fallback to direct SQLite approach try: conn = sqlite3.connect(str(db_file)) conn.row_factory = sqlite3.Row cursor = conn.cursor() - - # First check if task exists - cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,)) + + # First check if task exists and belongs to current user + cursor.execute( + "SELECT * FROM task WHERE id = ? AND user_id = ?", + (task_id, current_user.id), + ) row = cursor.fetchone() - + if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found", ) - + # Convert Pydantic model to dict, excluding unset values updates = {} - model_data = task_in.model_dump(exclude_unset=True) if hasattr(task_in, "model_dump") else task_in.dict(exclude_unset=True) - + model_data = ( + task_in.model_dump(exclude_unset=True) + if hasattr(task_in, "model_dump") + else task_in.dict(exclude_unset=True) + ) + # Only include fields that were provided in the update for key, value in model_data.items(): if value is not None: # Skip None values updates[key] = value - + if not updates: # No updates provided task_dict = dict(row) # Convert completed to boolean - if 'completed' in task_dict: - task_dict['completed'] = bool(task_dict['completed']) - + if "completed" in task_dict: + task_dict["completed"] = bool(task_dict["completed"]) + # Return the unchanged task class TaskResult: def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) - + conn.close() return TaskResult(**task_dict) - + # Format datetime objects - if 'due_date' in updates and isinstance(updates['due_date'], datetime): - updates['due_date'] = updates['due_date'].isoformat() - + if "due_date" in updates and isinstance(updates["due_date"], datetime): + updates["due_date"] = updates["due_date"].isoformat() + # Add updated_at timestamp - updates['updated_at'] = datetime.utcnow().isoformat() - + updates["updated_at"] = datetime.utcnow().isoformat() + # Build the SQL update statement set_clause = ", ".join([f"{key} = ?" for key in updates.keys()]) params = list(updates.values()) params.append(task_id) # For the WHERE clause - + cursor.execute(f"UPDATE task SET {set_clause} WHERE id = ?", params) conn.commit() - + # Return the updated task - cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,)) + cursor.execute( + "SELECT * FROM task WHERE id = ? AND user_id = ?", + (task_id, current_user.id), + ) updated_row = cursor.fetchone() conn.close() - + if updated_row: task_dict = dict(updated_row) # Convert completed to boolean - if 'completed' in task_dict: - task_dict['completed'] = bool(task_dict['completed']) - + if "completed" in task_dict: + task_dict["completed"] = bool(task_dict["completed"]) + # Convert to object with attributes class TaskResult: def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) - + return TaskResult(**task_dict) else: raise Exception("Task was updated but could not be retrieved") - + except HTTPException: raise # Re-raise the 404 exception except Exception as e: @@ -498,9 +541,9 @@ def update_task( print(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error updating task: {str(e)}" + detail=f"Error updating task: {str(e)}", ) - + except HTTPException: raise # Re-raise any HTTP exceptions except Exception as e: @@ -508,7 +551,7 @@ def update_task( print(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error updating task: {str(e)}" + detail=f"Error updating task: {str(e)}", ) @@ -517,72 +560,77 @@ def delete_task( *, db: Session = Depends(get_db), task_id: int, + current_user: User = Depends(get_current_active_user), ) -> Any: """ - Delete a task. + Delete a task for the current user. """ try: import traceback import sqlite3 from app.db.session import db_file - + print(f"Deleting task with ID: {task_id}") - - # First, get the task to return it later - task_to_return = None - - # Try the normal SQLAlchemy approach first + + # Try the normal SQLAlchemy approach try: - task = crud.task.get(db, id=task_id) + task = crud.task.get_by_id_and_user( + db, task_id=task_id, user_id=current_user.id + ) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found", ) - task_to_return = task - task = crud.task.remove(db, id=task_id) - return task + removed_task = crud.task.remove(db, id=task_id) + return removed_task except HTTPException: raise # Re-raise the 404 exception except Exception as e: print(f"Error deleting task with SQLAlchemy: {e}") print(traceback.format_exc()) # Continue to fallback - + # Fallback to direct SQLite approach try: conn = sqlite3.connect(str(db_file)) conn.row_factory = sqlite3.Row cursor = conn.cursor() - + # First save the task data for the return value - cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,)) + cursor.execute( + "SELECT * FROM task WHERE id = ? AND user_id = ?", + (task_id, current_user.id), + ) row = cursor.fetchone() - + if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found", ) - + task_dict = dict(row) # Convert completed to boolean - if 'completed' in task_dict: - task_dict['completed'] = bool(task_dict['completed']) - + if "completed" in task_dict: + task_dict["completed"] = bool(task_dict["completed"]) + # Delete the task - cursor.execute("DELETE FROM task WHERE id = ?", (task_id,)) + cursor.execute( + "DELETE FROM task WHERE id = ? AND user_id = ?", + (task_id, current_user.id), + ) conn.commit() conn.close() - + # Convert to object with attributes class TaskResult: def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) - + return TaskResult(**task_dict) - + except HTTPException: raise # Re-raise the 404 exception except Exception as e: @@ -590,9 +638,9 @@ def delete_task( print(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error deleting task: {str(e)}" + detail=f"Error deleting task: {str(e)}", ) - + except HTTPException: raise # Re-raise any HTTP exceptions except Exception as e: @@ -600,7 +648,7 @@ def delete_task( print(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error deleting task: {str(e)}" + detail=f"Error deleting task: {str(e)}", ) @@ -609,21 +657,24 @@ def complete_task( *, db: Session = Depends(get_db), task_id: int, + current_user: User = Depends(get_current_active_user), ) -> Any: """ - Mark a task as completed. + Mark a task as completed for the current user. """ try: import traceback import sqlite3 from datetime import datetime from app.db.session import db_file - + print(f"Marking task {task_id} as completed") - + # Try the normal SQLAlchemy approach first try: - task = crud.task.mark_completed(db, task_id=task_id) + task = crud.task.mark_completed( + db, task_id=task_id, user_id=current_user.id + ) if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -634,52 +685,58 @@ def complete_task( print(f"Error completing task with SQLAlchemy: {e}") print(traceback.format_exc()) # Continue to fallback - + # Fallback to direct SQLite approach try: conn = sqlite3.connect(str(db_file)) conn.row_factory = sqlite3.Row cursor = conn.cursor() - - # First check if task exists - cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,)) + + # First check if task exists and belongs to current user + cursor.execute( + "SELECT * FROM task WHERE id = ? AND user_id = ?", + (task_id, current_user.id), + ) row = cursor.fetchone() - + if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found", ) - + # Update task to completed status now = datetime.utcnow().isoformat() cursor.execute( - "UPDATE task SET completed = ?, status = ?, updated_at = ? WHERE id = ?", - (1, "done", now, task_id) + "UPDATE task SET completed = ?, status = ?, updated_at = ? WHERE id = ? AND user_id = ?", + (1, "done", now, task_id, current_user.id), ) conn.commit() - + # Get the updated task - cursor.execute("SELECT * FROM task WHERE id = ?", (task_id,)) + cursor.execute( + "SELECT * FROM task WHERE id = ? AND user_id = ?", + (task_id, current_user.id), + ) updated_row = cursor.fetchone() conn.close() - + if updated_row: task_dict = dict(updated_row) # Convert completed to boolean - if 'completed' in task_dict: - task_dict['completed'] = bool(task_dict['completed']) - + if "completed" in task_dict: + task_dict["completed"] = bool(task_dict["completed"]) + # Convert to object with attributes class TaskResult: def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) - + return TaskResult(**task_dict) else: raise Exception("Task was completed but could not be retrieved") - + except HTTPException: raise # Re-raise the 404 exception except Exception as e: @@ -687,9 +744,9 @@ def complete_task( print(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error completing task: {str(e)}" + detail=f"Error completing task: {str(e)}", ) - + except HTTPException: raise # Re-raise any HTTP exceptions except Exception as e: @@ -697,5 +754,5 @@ def complete_task( print(traceback.format_exc()) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error completing task: {str(e)}" - ) \ No newline at end of file + detail=f"Error completing task: {str(e)}", + ) diff --git a/app/core/config.py b/app/core/config.py index 1152aa7..ec1c1f2 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,6 +1,6 @@ import secrets from pathlib import Path -from typing import Any, Dict, List, Optional, Union +from typing import List, Union from pydantic import AnyHttpUrl, field_validator from pydantic_settings import BaseSettings @@ -16,11 +16,11 @@ if DB_PATH: else: # Try production path first, then local directory paths_to_try = [ - Path("/app/db"), # Production path - Path.cwd() / "db", # Local development path - Path("/tmp/taskmanager") # Fallback path + Path("/app/db"), # Production path + Path.cwd() / "db", # Local development path + Path("/tmp/taskmanager"), # Fallback path ] - + # Find the first writable path DB_DIR = None for path in paths_to_try: @@ -35,16 +35,17 @@ else: break except Exception as e: print(f"Cannot use path {path}: {e}") - + # Last resort fallback if DB_DIR is None: DB_DIR = Path("/tmp") print(f"Falling back to temporary directory: {DB_DIR}") try: Path("/tmp").mkdir(exist_ok=True) - except: + except Exception: pass + class Settings(BaseSettings): PROJECT_NAME: str = "Task Manager API" # No API version prefix - use direct paths @@ -52,7 +53,7 @@ class Settings(BaseSettings): SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 8 days = 8 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 - + # CORS Configuration BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] @@ -66,10 +67,8 @@ class Settings(BaseSettings): # Database configuration SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" - - model_config = { - "case_sensitive": True - } + + model_config = {"case_sensitive": True} -settings = Settings() \ No newline at end of file +settings = Settings() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..1408b1e --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,42 @@ +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: + """ + Create a JWT access token for a user + """ + 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: + """ + 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) diff --git a/app/crud/__init__.py b/app/crud/__init__.py index 6dee8db..d05cacc 100644 --- a/app/crud/__init__.py +++ b/app/crud/__init__.py @@ -1 +1,4 @@ -from app.crud.task import task \ No newline at end of file +from app.crud.task import task as task +from app.crud.user import user as user + +__all__ = ["task", "user"] diff --git a/app/crud/base.py b/app/crud/base.py index 70a8904..a821a4a 100644 --- a/app/crud/base.py +++ b/app/crud/base.py @@ -30,12 +30,12 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): try: obj_in_data = jsonable_encoder(obj_in) print(f"Creating {self.model.__name__} with data: {obj_in_data}") - + db_obj = self.model(**obj_in_data) db.add(db_obj) db.commit() db.refresh(db_obj) - + print(f"Successfully created {self.model.__name__} with id: {db_obj.id}") return db_obj except Exception as e: @@ -43,6 +43,7 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): error_msg = f"Error creating {self.model.__name__}: {str(e)}" print(error_msg) import traceback + print(traceback.format_exc()) raise Exception(error_msg) from e @@ -51,15 +52,15 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): db: Session, *, db_obj: ModelType, - obj_in: Union[UpdateSchemaType, Dict[str, Any]] + obj_in: Union[UpdateSchemaType, Dict[str, Any]], ) -> ModelType: try: # Log update operation print(f"Updating {self.model.__name__} with id: {db_obj.id}") - + # Get the existing data obj_data = jsonable_encoder(db_obj) - + # Process the update data if isinstance(obj_in, dict): update_data = obj_in @@ -69,29 +70,30 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): update_data = obj_in.model_dump(exclude_unset=True) else: update_data = obj_in.dict(exclude_unset=True) - + # Log the changes being made changes = {k: v for k, v in update_data.items() if k in obj_data} print(f"Fields to update: {changes}") - + # Apply the updates for field in obj_data: if field in update_data: setattr(db_obj, field, update_data[field]) - + # Save changes db.add(db_obj) db.commit() db.refresh(db_obj) - + print(f"Successfully updated {self.model.__name__} with id: {db_obj.id}") return db_obj - + except Exception as e: db.rollback() error_msg = f"Error updating {self.model.__name__}: {str(e)}" print(error_msg) import traceback + print(traceback.format_exc()) raise Exception(error_msg) from e @@ -102,20 +104,21 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): if not obj: print(f"{self.model.__name__} with id {id} not found for deletion") return None - + print(f"Deleting {self.model.__name__} with id: {id}") - + # Delete the object db.delete(obj) db.commit() - + print(f"Successfully deleted {self.model.__name__} with id: {id}") return obj - + except Exception as e: db.rollback() error_msg = f"Error deleting {self.model.__name__}: {str(e)}" print(error_msg) import traceback + print(traceback.format_exc()) - raise Exception(error_msg) from e \ No newline at end of file + raise Exception(error_msg) from e diff --git a/app/crud/task.py b/app/crud/task.py index 1660ca6..78242f6 100644 --- a/app/crud/task.py +++ b/app/crud/task.py @@ -7,21 +7,69 @@ from app.schemas.task import TaskCreate, TaskUpdate class CRUDTask(CRUDBase[Task, TaskCreate, TaskUpdate]): - def get_by_status(self, db: Session, *, status: TaskStatus) -> List[Task]: - return db.query(self.model).filter(Task.status == status).all() - - def get_completed(self, db: Session) -> List[Task]: - return db.query(self.model).filter(Task.completed == True).all() - - def mark_completed(self, db: Session, *, task_id: int) -> Optional[Task]: - task = self.get(db, id=task_id) + def get_by_status( + self, db: Session, *, status: TaskStatus, user_id: Optional[int] = None + ) -> List[Task]: + query = db.query(self.model).filter(Task.status == status) + if user_id is not None: + query = query.filter(Task.user_id == user_id) + return query.all() + + def get_completed( + self, db: Session, *, user_id: Optional[int] = None + ) -> List[Task]: + query = db.query(self.model).filter(Task.completed.is_(True)) + if user_id is not None: + query = query.filter(Task.user_id == user_id) + return query.all() + + def get_multi( + self, + db: Session, + *, + skip: int = 0, + limit: int = 100, + user_id: Optional[int] = None, + ) -> List[Task]: + query = db.query(self.model) + if user_id is not None: + query = query.filter(Task.user_id == user_id) + return query.offset(skip).limit(limit).all() + + def create_with_owner( + self, db: Session, *, obj_in: TaskCreate, user_id: int + ) -> Task: + obj_in_data = ( + obj_in.model_dump() if hasattr(obj_in, "model_dump") else obj_in.dict() + ) + db_obj = self.model(**obj_in_data, user_id=user_id) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def get_by_id_and_user( + self, db: Session, *, task_id: int, user_id: int + ) -> Optional[Task]: + return ( + db.query(self.model) + .filter(Task.id == task_id, Task.user_id == user_id) + .first() + ) + + def mark_completed( + self, db: Session, *, task_id: int, user_id: Optional[int] = None + ) -> Optional[Task]: + if user_id: + task = self.get_by_id_and_user(db, task_id=task_id, user_id=user_id) + else: + task = self.get(db, id=task_id) + if not task: return None - task_in = TaskUpdate( - status=TaskStatus.DONE, - completed=True - ) + + task_in = TaskUpdate(status=TaskStatus.DONE, completed=True) return self.update(db, db_obj=task, obj_in=task_in) -task = CRUDTask(Task) \ No newline at end of file +task = CRUDTask(Task) diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..37971fc --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,74 @@ +from typing import Any, Dict, Optional, Union + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash, verify_password +from app.crud.base import CRUDBase +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate + + +class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): + def get_by_email(self, db: Session, *, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + def get_by_username(self, db: Session, *, username: str) -> Optional[User]: + return db.query(User).filter(User.username == username).first() + + def get_by_email_or_username( + self, db: Session, *, email_or_username: str + ) -> Optional[User]: + return ( + db.query(User) + .filter( + or_(User.email == email_or_username, User.username == email_or_username) + ) + .first() + ) + + def create(self, db: Session, *, obj_in: UserCreate) -> 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( + self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] + ) -> User: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + if "password" in update_data and update_data["password"]: + hashed_password = get_password_hash(update_data["password"]) + del update_data["password"] + update_data["hashed_password"] = hashed_password + return super().update(db, db_obj=db_obj, obj_in=update_data) + + def authenticate( + self, db: Session, *, email_or_username: str, password: str + ) -> Optional[User]: + user = self.get_by_email_or_username(db, email_or_username=email_or_username) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + def is_active(self, user: User) -> bool: + return user.is_active + + def is_superuser(self, user: User) -> bool: + return user.is_superuser + + +# Create instance for use throughout the app +user = CRUDUser(User) diff --git a/app/db/base.py b/app/db/base.py index e1c3785..98075f7 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,4 +1,5 @@ # Import all the models, so that Base has them before being # imported by Alembic from app.db.base_class import Base # noqa -from app.models.task import Task # noqa \ No newline at end of file +from app.models.task import Task # noqa +from app.models.user import User # noqa diff --git a/app/db/base_class.py b/app/db/base_class.py index d88c48b..b1daeb8 100644 --- a/app/db/base_class.py +++ b/app/db/base_class.py @@ -7,8 +7,8 @@ from sqlalchemy.ext.declarative import as_declarative, declared_attr class Base: id: Any __name__: str - + # Generate __tablename__ automatically based on class name @declared_attr def __tablename__(cls) -> str: - return cls.__name__.lower() \ No newline at end of file + return cls.__name__.lower() diff --git a/app/db/init_db.py b/app/db/init_db.py index 4460f84..f1db14a 100644 --- a/app/db/init_db.py +++ b/app/db/init_db.py @@ -1,12 +1,8 @@ import os -import sys -import time import sqlite3 -from pathlib import Path from datetime import datetime, timedelta -from sqlalchemy import inspect, text -from sqlalchemy.exc import OperationalError +from sqlalchemy import text from app.db.base import Base # Import all models from app.db.session import engine, db_file @@ -21,22 +17,22 @@ def init_db() -> None: print(f"Initializing database at {db_file}") print(f"Using SQLAlchemy URL: {settings.SQLALCHEMY_DATABASE_URL}") print(f"DB_DIR is set to: {DB_DIR} (this should be /app/db in production)") - + # First try direct SQLite approach to ensure we have a basic database file try: # Ensure database file exists and is writable - with open(db_file, 'a'): # Try opening for append (creates if doesn't exist) + with open(db_file, "a"): # Try opening for append (creates if doesn't exist) os.utime(db_file, None) # Update access/modify time - + print(f"Database file exists and is writable: {db_file}") - + # Try direct SQLite connection to create task table conn = sqlite3.connect(str(db_file)) - + # Enable foreign keys and WAL journal mode conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA journal_mode = WAL") - + # Create task table if it doesn't exist conn.execute(""" CREATE TABLE IF NOT EXISTS task ( @@ -48,33 +44,89 @@ def init_db() -> None: due_date TEXT, completed INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER + ) + """) + + # Create user table if it doesn't exist + conn.execute(""" + CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + hashed_password TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + is_superuser INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) - - # Create an index on the id column + + # Create indexes conn.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON task(id)") - - # Add a sample task if the table is empty + conn.execute("CREATE INDEX IF NOT EXISTS idx_task_user_id ON task(user_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_user_id ON user(id)") + conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email ON user(email)") + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_user_username ON user(username)" + ) + + # Add a default admin user if the user table is empty cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM task") - count = cursor.fetchone()[0] - - if count == 0: + cursor.execute("SELECT COUNT(*) FROM user") + user_count = cursor.fetchone()[0] + + if user_count == 0: now = datetime.utcnow().isoformat() - conn.execute(""" - INSERT INTO task (title, description, priority, status, completed, created_at, updated_at) + # Import get_password_hash function here to avoid F823 error + from app.core.security import get_password_hash + admin_password_hash = get_password_hash("adminpassword") + conn.execute( + """ + INSERT INTO user (email, username, hashed_password, is_active, is_superuser, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - "Example Task", - "This is an example task created during initialization", - "medium", - "todo", - 0, - now, - now - )) - + """, + ( + "admin@example.com", + "admin", + admin_password_hash, + 1, # is_active + 1, # is_superuser + now, + now, + ), + ) + print("Created default admin user: admin@example.com / adminpassword") + + # Add a sample task if the task table is empty + cursor.execute("SELECT COUNT(*) FROM task") + task_count = cursor.fetchone()[0] + + if task_count == 0: + # Get the admin user ID if it exists + cursor.execute("SELECT id FROM user WHERE username = ?", ("admin",)) + admin_row = cursor.fetchone() + admin_id = admin_row[0] if admin_row else None + + now = datetime.utcnow().isoformat() + conn.execute( + """ + INSERT INTO task (title, description, priority, status, completed, created_at, updated_at, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "Example Task", + "This is an example task created during initialization", + "medium", + "todo", + 0, + now, + now, + admin_id, + ), + ) + conn.commit() cursor.close() conn.close() @@ -82,58 +134,99 @@ def init_db() -> None: except Exception as e: print(f"Error during direct SQLite initialization: {e}") import traceback + print(traceback.format_exc()) - + # Now try with SQLAlchemy as a backup approach try: print("Attempting SQLAlchemy database initialization...") - + # Try to create all tables from models Base.metadata.create_all(bind=engine) print("Successfully created tables with SQLAlchemy") - + # Verify tables exist with engine.connect() as conn: # Get list of tables - result = conn.execute(text( - "SELECT name FROM sqlite_master WHERE type='table'" - )) + result = conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table'") + ) tables = [row[0] for row in result] print(f"Tables in database: {', '.join(tables)}") - - # Verify task table exists - if 'task' in tables: - # Check if task table is empty - result = conn.execute(text("SELECT COUNT(*) FROM task")) - task_count = result.scalar() - print(f"Task table contains {task_count} records") - - # If table exists but is empty, add a sample task - if task_count == 0: - print("Adding sample task with SQLAlchemy") - from app.models.task import Task, TaskPriority, TaskStatus - sample_task = Task( - title="Sample SQLAlchemy Task", - description="This is a sample task created with SQLAlchemy", - priority=TaskPriority.MEDIUM, - status=TaskStatus.TODO, - completed=False, + + # Verify user table exists + if "user" in tables: + # Check if user table is empty + result = conn.execute(text("SELECT COUNT(*) FROM user")) + user_count = result.scalar() + print(f"User table contains {user_count} records") + + # If user table is empty, add default admin user + if user_count == 0: + print("Adding default admin user with SQLAlchemy") + from app.models.user import User + from app.core.security import get_password_hash + + admin_user = User( + email="admin@example.com", + username="admin", + hashed_password=get_password_hash("adminpassword"), + is_active=True, + is_superuser=True, created_at=datetime.utcnow(), - updated_at=datetime.utcnow() + updated_at=datetime.utcnow(), ) from app.db.session import SessionLocal + db = SessionLocal() - db.add(sample_task) + db.add(admin_user) db.commit() + + # Get the admin user ID for the sample task + admin_id = None + admin = db.query(User).filter_by(username="admin").first() + if admin: + admin_id = admin.id + + # Verify task table exists + if "task" in tables: + # Check if task table is empty + result = conn.execute(text("SELECT COUNT(*) FROM task")) + task_count = result.scalar() + print(f"Task table contains {task_count} records") + + # If table exists but is empty, add a sample task + if task_count == 0 and admin_id: + print("Adding sample task with SQLAlchemy") + from app.models.task import Task, TaskPriority, TaskStatus + + sample_task = Task( + title="Sample SQLAlchemy Task", + description="This is a sample task created with SQLAlchemy", + priority=TaskPriority.MEDIUM, + status=TaskStatus.TODO, + completed=False, + user_id=admin_id, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + db.add(sample_task) + db.commit() + print("Added sample task with SQLAlchemy") + db.close() - print("Added sample task with SQLAlchemy") + print("Added default admin user with SQLAlchemy") else: + print("WARNING: 'user' table not found!") + + if "task" not in tables: print("WARNING: 'task' table not found!") - + print("SQLAlchemy database initialization completed") except Exception as e: print(f"Error during SQLAlchemy initialization: {e}") import traceback + print(traceback.format_exc()) print("Continuing despite SQLAlchemy initialization error...") @@ -146,17 +239,19 @@ def create_test_task(): try: conn = sqlite3.connect(str(db_file)) cursor = conn.cursor() - + # Check if task table exists - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task'") + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='task'" + ) if not cursor.fetchone(): print("Task table doesn't exist - cannot create test task") return - + # Check if any tasks exist cursor.execute("SELECT COUNT(*) FROM task") count = cursor.fetchone()[0] - + if count == 0: # Create a task directly with SQLite now = datetime.utcnow().isoformat() @@ -166,47 +261,51 @@ def create_test_task(): title, description, priority, status, completed, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, + """, ( - "Test Task (Direct SQL)", + "Test Task (Direct SQL)", "This is a test task created directly with SQLite", "medium", "todo", 0, # not completed now, - now - ) + now, + ), ) conn.commit() task_id = cursor.lastrowid print(f"Created test task with direct SQLite, ID: {task_id}") else: print(f"Found {count} existing tasks, no need to create test task") - + conn.close() - + except Exception as e: print(f"Error with direct SQLite test task creation: {e}") # Continue with SQLAlchemy approach - + # Now try with SQLAlchemy try: from app.crud.task import task as task_crud from app.schemas.task import TaskCreate from app.db.session import SessionLocal - + db = SessionLocal() try: # Check if there are any tasks try: - existing_tasks = db.execute(text("SELECT COUNT(*) FROM task")).scalar() + existing_tasks = db.execute( + text("SELECT COUNT(*) FROM task") + ).scalar() if existing_tasks > 0: - print(f"Test task not needed, found {existing_tasks} existing tasks") + print( + f"Test task not needed, found {existing_tasks} existing tasks" + ) return except Exception as e: print(f"Error checking for existing tasks: {e}") # Continue anyway to try creating a task - + # Create a test task test_task = TaskCreate( title="Test Task (SQLAlchemy)", @@ -214,23 +313,24 @@ def create_test_task(): priority="medium", status="todo", due_date=datetime.utcnow() + timedelta(days=7), - completed=False + completed=False, ) - + created_task = task_crud.create(db, obj_in=test_task) print(f"Created test task with SQLAlchemy, ID: {created_task.id}") - + finally: db.close() except Exception as e: print(f"Error with SQLAlchemy test task creation: {e}") - + except Exception as e: print(f"Global error creating test task: {e}") import traceback + print(traceback.format_exc()) if __name__ == "__main__": init_db() - create_test_task() \ No newline at end of file + create_test_task() diff --git a/app/db/session.py b/app/db/session.py index 9d35fd9..1bcb23c 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,12 +1,9 @@ -import os import time import sqlite3 from typing import Generator -from pathlib import Path from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker -from sqlalchemy.exc import OperationalError, SQLAlchemyError from app.core.config import settings, DB_DIR @@ -22,19 +19,20 @@ try: print(f"Database file created or verified: {db_file}") except Exception as e: print(f"Warning: Could not create database file: {e}") - + # Configure SQLite connection with simplified, robust settings engine = create_engine( settings.SQLALCHEMY_DATABASE_URL, connect_args={ "check_same_thread": False, - "timeout": 30, # Wait up to 30 seconds for the lock + "timeout": 30, # Wait up to 30 seconds for the lock }, # Minimal pool settings for stability - pool_pre_ping=True, # Verify connections before usage - echo=True, # Log all SQL for debugging + pool_pre_ping=True, # Verify connections before usage + echo=True, # Log all SQL for debugging ) + # Add essential SQLite optimizations @event.listens_for(engine, "connect") def optimize_sqlite_connection(dbapi_connection, connection_record): @@ -46,9 +44,11 @@ def optimize_sqlite_connection(dbapi_connection, connection_record): except Exception as e: print(f"Warning: Could not configure SQLite connection: {e}") + # Simplified Session factory SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + # More robust database access with retry logic and error printing def get_db() -> Generator: """ @@ -56,34 +56,35 @@ def get_db() -> Generator: """ db = None retries = 3 - + for attempt in range(retries): try: db = SessionLocal() # Log connection attempt - print(f"Database connection attempt {attempt+1}") - + print(f"Database connection attempt {attempt + 1}") + # Test connection with a simple query db.execute("SELECT 1") - + # Connection succeeded print("Database connection successful") yield db break - + except Exception as e: # Close failed connection if db: db.close() db = None - - error_msg = f"Database connection attempt {attempt+1} failed: {e}" + + error_msg = f"Database connection attempt {attempt + 1} failed: {e}" print(error_msg) - + # Log critical error details import traceback + print(f"Error traceback: {traceback.format_exc()}") - + # Check if we can directly access database try: # Try direct sqlite3 connection as a test @@ -93,19 +94,19 @@ def get_db() -> Generator: print("Direct SQLite connection succeeded but SQLAlchemy failed") except Exception as direct_e: print(f"Direct SQLite connection also failed: {direct_e}") - + # Last attempt - raise the error to return 500 status if attempt == retries - 1: print("All database connection attempts failed") raise - + # Otherwise sleep and retry time.sleep(1) - + # Always ensure db is closed try: if db: print("Closing database connection") db.close() except Exception as e: - print(f"Error closing database: {e}") \ No newline at end of file + print(f"Error closing database: {e}") diff --git a/app/models/task.py b/app/models/task.py index b4f034e..ac338f8 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,8 +1,17 @@ from datetime import datetime from enum import Enum -from typing import Optional -from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Enum as SQLEnum +from sqlalchemy import ( + Column, + Integer, + String, + Text, + DateTime, + Boolean, + Enum as SQLEnum, + ForeignKey, +) +from sqlalchemy.orm import relationship from app.db.base_class import Base @@ -28,4 +37,8 @@ class Task(Base): due_date = Column(DateTime, nullable=True) completed = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) \ No newline at end of file + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Add relationship to User model + user_id = Column(Integer, ForeignKey("user.id"), nullable=True) + user = relationship("User", back_populates="tasks") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..33005e6 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,20 @@ +from datetime import datetime + +from sqlalchemy import Column, Integer, String, DateTime, Boolean +from sqlalchemy.orm import relationship + +from app.db.base_class import Base + + +class User(Base): + id = Column(Integer, primary_key=True, index=True) + email = Column(String(255), unique=True, index=True, nullable=False) + username = Column(String(100), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationship with Task model + tasks = relationship("Task", back_populates="user", cascade="all, delete-orphan") diff --git a/app/schemas/task.py b/app/schemas/task.py index 7021af6..ba425d6 100644 --- a/app/schemas/task.py +++ b/app/schemas/task.py @@ -13,7 +13,7 @@ class TaskBase(BaseModel): status: TaskStatus = TaskStatus.TODO due_date: Optional[datetime] = None completed: bool = False - + model_config = { "json_encoders": { datetime: lambda dt: dt.isoformat(), @@ -32,7 +32,7 @@ class TaskUpdate(BaseModel): status: Optional[TaskStatus] = None due_date: Optional[datetime] = None completed: Optional[bool] = None - + model_config = { "json_encoders": { datetime: lambda dt: dt.isoformat(), @@ -45,10 +45,10 @@ class TaskUpdate(BaseModel): "description": "Updated task description", "priority": "high", "status": "in_progress", - "completed": False + "completed": False, } ] - } + }, } @@ -57,10 +57,8 @@ class TaskInDBBase(TaskBase): created_at: datetime updated_at: datetime - model_config = { - "from_attributes": True - } + model_config = {"from_attributes": True} class Task(TaskInDBBase): - pass \ No newline at end of file + pass diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..e52f177 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,61 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field, validator + + +class UserBase(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = None + is_active: Optional[bool] = True + is_superuser: Optional[bool] = False + + +class UserCreate(UserBase): + email: EmailStr = Field(..., description="User email") + username: str = Field(..., min_length=3, max_length=50, description="Username") + password: str = Field(..., min_length=8, description="Password") + + @validator("username") + def username_valid(cls, v): + # Additional validation for username + if not v.isalnum(): + raise ValueError("Username must be alphanumeric") + return v + + +class UserUpdate(UserBase): + password: Optional[str] = Field(None, min_length=8, description="Password") + + +class UserInDBBase(UserBase): + id: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class User(UserInDBBase): + """Return user information without sensitive data""" + + pass + + +class UserInDB(UserInDBBase): + """User model stored in DB, with hashed password""" + + hashed_password: str + + +class Token(BaseModel): + """OAuth2 compatible token""" + + access_token: str + token_type: str + + +class TokenPayload(BaseModel): + """JWT token payload""" + + sub: Optional[int] = None diff --git a/main.py b/main.py index 1cf033c..d5a60f0 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,14 @@ import sys import os from pathlib import Path +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse # Add project root to Python path for imports in alembic migrations project_root = Path(__file__).parent.absolute() sys.path.insert(0, str(project_root)) -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse - from app.api.routers import api_router from app.core.config import settings from app.db import init_db @@ -19,17 +18,19 @@ print("Starting database initialization...") try: # Get absolute path of the database file from your config from app.db.session import db_file + print(f"Database path: {db_file}") - + # Check directory permissions for the configured DB_DIR from app.core.config import DB_DIR + print(f"Database directory: {DB_DIR}") print(f"Database directory exists: {DB_DIR.exists()}") print(f"Database directory is writable: {os.access(DB_DIR, os.W_OK)}") - + # Initialize the database and create test task init_db.init_db() - + # Try to create a test task try: init_db.create_test_task() @@ -39,6 +40,7 @@ try: except Exception as e: print(f"Error initializing database: {e}") import traceback + print(f"Detailed error: {traceback.format_exc()}") # Continue with app startup even if DB init fails, to allow debugging @@ -53,33 +55,34 @@ app.add_middleware( allow_headers=["*"], ) + # Add comprehensive exception handlers for better error reporting @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): import traceback import sys - + # Log the full error with traceback to stdout/stderr error_tb = traceback.format_exc() print(f"CRITICAL ERROR: {str(exc)}", file=sys.stderr) print(f"Request path: {request.url.path}", file=sys.stderr) print(f"Traceback:\n{error_tb}", file=sys.stderr) - + # Get request info for debugging headers = dict(request.headers) # Remove sensitive headers - if 'authorization' in headers: - headers['authorization'] = '[REDACTED]' - if 'cookie' in headers: - headers['cookie'] = '[REDACTED]' - + if "authorization" in headers: + headers["authorization"] = "[REDACTED]" + if "cookie" in headers: + headers["cookie"] = "[REDACTED]" + # Include minimal traceback in response for debugging - tb_lines = error_tb.split('\n') + tb_lines = error_tb.split("\n") simplified_tb = [] for line in tb_lines: - if line and not line.startswith(' '): + if line and not line.startswith(" "): simplified_tb.append(line) - + # Create detailed error response error_detail = { "status": "error", @@ -87,29 +90,34 @@ async def global_exception_handler(request: Request, exc: Exception): "type": str(type(exc).__name__), "path": request.url.path, "method": request.method, - "traceback_summary": simplified_tb[-10:] if len(simplified_tb) > 10 else simplified_tb, + "traceback_summary": simplified_tb[-10:] + if len(simplified_tb) > 10 + else simplified_tb, } - + # Add SQLite diagnostic check try: import sqlite3 from app.db.session import db_file - + # Try basic SQLite operations conn = sqlite3.connect(str(db_file)) cursor = conn.cursor() cursor.execute("PRAGMA integrity_check") integrity = cursor.fetchone()[0] - + # Check if task table exists - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task'") + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='task'" + ) task_table_exists = cursor.fetchone() is not None - + # Get file info import os + file_exists = os.path.exists(db_file) file_size = os.path.getsize(db_file) if file_exists else 0 - + # Add SQLite diagnostics to response error_detail["db_diagnostics"] = { "file_exists": file_exists, @@ -117,11 +125,11 @@ async def global_exception_handler(request: Request, exc: Exception): "integrity": integrity, "task_table_exists": task_table_exists, } - + conn.close() except Exception as db_error: error_detail["db_diagnostics"] = {"error": str(db_error)} - + # Return the error response print(f"Returning error response: {error_detail}") return JSONResponse( @@ -129,6 +137,7 @@ async def global_exception_handler(request: Request, exc: Exception): content=error_detail, ) + # Include the API router directly (no version prefix) app.include_router(api_router) @@ -141,16 +150,23 @@ def api_info(): return { "name": settings.PROJECT_NAME, "version": "1.0.0", - "description": "A RESTful API for managing tasks", + "description": "A RESTful API for managing tasks with user authentication", "endpoints": { + "authentication": { + "register": "/auth/register", + "login": "/auth/login", + "me": "/auth/me", + "test-token": "/auth/test-token", + }, "tasks": "/tasks", "docs": "/docs", "redoc": "/redoc", "health": "/health", - "db_test": "/db-test" - } + "db_test": "/db-test", + }, } + @app.get("/health", tags=["health"]) def health_check(): """ @@ -172,54 +188,60 @@ def test_db_connection(): from sqlalchemy import text from app.db.session import engine, db_file from app.core.config import DB_DIR - + # First check direct file access file_info = { "db_dir": str(DB_DIR), "db_file": str(db_file), "exists": os.path.exists(db_file), "size": os.path.getsize(db_file) if os.path.exists(db_file) else 0, - "writable": os.access(db_file, os.W_OK) if os.path.exists(db_file) else False, - "dir_writable": os.access(DB_DIR, os.W_OK) if os.path.exists(DB_DIR) else False + "writable": os.access(db_file, os.W_OK) + if os.path.exists(db_file) + else False, + "dir_writable": os.access(DB_DIR, os.W_OK) + if os.path.exists(DB_DIR) + else False, } - + # Try direct SQLite connection sqlite_test = {} try: conn = sqlite3.connect(str(db_file)) sqlite_test["connection"] = "successful" - + # Check if task table exists cursor = conn.cursor() cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") tables = [row[0] for row in cursor.fetchall()] sqlite_test["tables"] = tables - + # Check for task table specifically - if 'task' in tables: + if "task" in tables: cursor.execute("SELECT COUNT(*) FROM task") task_count = cursor.fetchone()[0] sqlite_test["task_count"] = task_count - + # Get a sample task if available if task_count > 0: cursor.execute("SELECT * FROM task LIMIT 1") - column_names = [description[0] for description in cursor.description] + column_names = [ + description[0] for description in cursor.description + ] row = cursor.fetchone() sample_task = dict(zip(column_names, row)) sqlite_test["sample_task"] = sample_task - + # Check database integrity cursor.execute("PRAGMA integrity_check") integrity = cursor.fetchone()[0] sqlite_test["integrity"] = integrity - + conn.close() except Exception as e: sqlite_test["connection"] = "failed" sqlite_test["error"] = str(e) sqlite_test["traceback"] = traceback.format_exc() - + # Try SQLAlchemy connection sqlalchemy_test = {} try: @@ -227,32 +249,38 @@ def test_db_connection(): # Basic connectivity test result = conn.execute(text("SELECT 1")).scalar() sqlalchemy_test["basic_query"] = result - + # Check tables - result = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table'")) + result = conn.execute( + text("SELECT name FROM sqlite_master WHERE type='table'") + ) tables = [row[0] for row in result] sqlalchemy_test["tables"] = tables - + # Check task table - if 'task' in tables: + if "task" in tables: result = conn.execute(text("SELECT COUNT(*) FROM task")) sqlalchemy_test["task_count"] = result.scalar() except Exception as e: sqlalchemy_test["connection"] = "failed" sqlalchemy_test["error"] = str(e) sqlalchemy_test["traceback"] = traceback.format_exc() - + # Check environment env_info = { "cwd": os.getcwd(), - "env_variables": {k: v for k, v in os.environ.items() if k.startswith(('DB_', 'SQL', 'PATH'))} + "env_variables": { + k: v + for k, v in os.environ.items() + if k.startswith(("DB_", "SQL", "PATH")) + }, } - + # Try to create a test file write_test = {} try: test_path = DB_DIR / "write_test.txt" - with open(test_path, 'w') as f: + with open(test_path, "w") as f: f.write("Test content") write_test["success"] = True write_test["path"] = str(test_path) @@ -260,7 +288,7 @@ def test_db_connection(): except Exception as e: write_test["success"] = False write_test["error"] = str(e) - + return { "status": "ok", "timestamp": datetime.datetime.utcnow().isoformat(), @@ -268,13 +296,13 @@ def test_db_connection(): "sqlite_test": sqlite_test, "sqlalchemy_test": sqlalchemy_test, "environment": env_info, - "write_test": write_test + "write_test": write_test, } - + except Exception as e: # Catch-all error handler return { "status": "error", "message": str(e), - "traceback": traceback.format_exc() - } \ No newline at end of file + "traceback": traceback.format_exc(), + } diff --git a/requirements.txt b/requirements.txt index 0de65d8..9b3edd1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,8 @@ alembic>=1.12.0 pydantic>=2.4.2 pydantic-settings>=2.0.3 python-multipart>=0.0.6 -ruff>=0.1.3 \ No newline at end of file +ruff>=0.1.3 +passlib>=1.7.4 +bcrypt>=4.0.1 +python-jose>=3.3.0 +email-validator>=2.0.0 \ No newline at end of file