diff --git a/README.md b/README.md index e8acfba..20f5229 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,130 @@ -# FastAPI Application +# Simple Todo Application -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +## Overview + +This is a simple todo application API built with FastAPI and SQLite. It provides a RESTful API for managing todo items with the following features: + +- Create, read, update, and delete todo items +- Filter todos by completion status +- Health check endpoint +- Validation and error handling +- CORS support +- Request logging + +## Technologies Used + +- **FastAPI**: A modern, fast (high-performance) web framework for building APIs with Python +- **SQLAlchemy**: SQL toolkit and Object-Relational Mapping (ORM) for Python +- **Pydantic**: Data validation and settings management using Python type hints +- **SQLite**: Lightweight disk-based database +- **Alembic**: Database migration tool for SQLAlchemy +- **Uvicorn**: ASGI server for running the FastAPI application + +## Project Structure + +``` +. +├── app +│ ├── api +│ │ ├── endpoints +│ │ │ └── todos.py # Todo API endpoints +│ │ ├── health.py # Health check endpoint +│ │ └── routes.py # API router setup +│ ├── core +│ │ ├── config.py # Application configuration +│ │ ├── error_handlers.py # Global error handlers +│ │ └── exceptions.py # Custom exceptions +│ ├── db +│ │ ├── crud +│ │ │ ├── base.py # Base CRUD operations +│ │ │ └── crud_todo.py # Todo-specific CRUD operations +│ │ └── session.py # Database session setup +│ ├── middleware +│ │ └── logging.py # Request logging middleware +│ ├── models +│ │ ├── base.py # Base SQLAlchemy model +│ │ └── todo.py # Todo model definition +│ └── schemas +│ └── todo.py # Pydantic schemas for Todo model +├── migrations +│ ├── env.py # Alembic environment configuration +│ ├── script.py.mako # Alembic script template +│ └── versions +│ └── 20240101_initial_todo_table.py # Initial database migration +├── alembic.ini # Alembic configuration +├── main.py # Application entry point +└── requirements.txt # Project dependencies +``` + +## API Endpoints + +### Health Check + +- `GET /health`: Check the health of the application + +### Todo Endpoints + +- `GET /todos`: List all todo items + - Query parameters: + - `skip`: Number of items to skip (default: 0) + - `limit`: Maximum number of items to return (default: 100) + - `completed`: Filter by completion status (optional) + +- `POST /todos`: Create a new todo item + - Request body: + - `title`: Required string (1-100 characters) + - `description`: Optional string (0-500 characters) + - `completed`: Boolean (default: false) + +- `GET /todos/{todo_id}`: Get a specific todo item by ID + +- `PUT /todos/{todo_id}`: Update a todo item + - Request body: + - `title`: Optional string (1-100 characters) + - `description`: Optional string (0-500 characters) + - `completed`: Optional boolean + +- `DELETE /todos/{todo_id}`: Delete a todo item + +## Setup and Installation + +1. Clone the repository: +```bash +git clone +cd simple-todo-application +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +``` + +3. Run the application: +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +4. Access the API documentation: + - Swagger UI: http://localhost:8000/docs + - ReDoc: http://localhost:8000/redoc + +## Database Migrations + +The application uses Alembic for database migrations: + +```bash +# Apply all migrations +alembic upgrade head + +# Revert last migration +alembic downgrade -1 +``` + +## Configuration + +The application can be configured using environment variables. Create a `.env` file in the project root with the following variables: + +``` +PROJECT_NAME=My Todo App +CORS_ORIGINS=["http://localhost:3000","https://example.com"] +``` \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..5a272eb --- /dev/null +++ b/alembic.ini @@ -0,0 +1,85 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat migrations/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# SQLite URL +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..38b2355 --- /dev/null +++ b/app/api/endpoints/__init__.py @@ -0,0 +1,3 @@ +from app.api.endpoints.todos import router as todo_router + +__all__ = ["todo_router"] \ No newline at end of file diff --git a/app/api/endpoints/todos.py b/app/api/endpoints/todos.py new file mode 100644 index 0000000..e2a4497 --- /dev/null +++ b/app/api/endpoints/todos.py @@ -0,0 +1,108 @@ +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from app.core.exceptions import TodoNotFoundException +from app.db.crud import todo +from app.db.session import get_db +from app.schemas.todo import Todo, TodoCreate, TodoUpdate + +router = APIRouter() + + +@router.get("/", response_model=List[Todo]) +def read_todos( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + completed: Optional[bool] = None, +) -> Any: + """ + Retrieve todos. + + - **skip**: Number of items to skip (for pagination) + - **limit**: Maximum number of items to return + - **completed**: Optional filter for todo completion status + """ + if completed is None: + todos = todo.get_multi(db, skip=skip, limit=limit) + elif completed: + todos = todo.get_completed(db, skip=skip, limit=limit) + else: + todos = todo.get_uncompleted(db, skip=skip, limit=limit) + + return todos + + +@router.post("/", response_model=Todo, status_code=status.HTTP_201_CREATED) +def create_todo( + *, + db: Session = Depends(get_db), + todo_in: TodoCreate, +) -> Any: + """ + Create new todo. + + - **title**: Required title for the todo + - **description**: Optional detailed description + - **completed**: Whether the todo is already completed (default: False) + """ + return todo.create(db, obj_in=todo_in) + + +@router.get("/{todo_id}", response_model=Todo) +def read_todo( + *, + db: Session = Depends(get_db), + todo_id: int, +) -> Any: + """ + Get todo by ID. + + - **todo_id**: The ID of the todo to retrieve + """ + db_todo = todo.get(db, id=todo_id) + if not db_todo: + raise TodoNotFoundException(f"Todo with ID {todo_id} not found") + return db_todo + + +@router.put("/{todo_id}", response_model=Todo) +def update_todo( + *, + db: Session = Depends(get_db), + todo_id: int, + todo_in: TodoUpdate, +) -> Any: + """ + Update a todo. + + - **todo_id**: The ID of the todo to update + - **title**: New title (optional) + - **description**: New description (optional) + - **completed**: New completion status (optional) + """ + db_todo = todo.get(db, id=todo_id) + if not db_todo: + raise TodoNotFoundException(f"Todo with ID {todo_id} not found") + db_todo = todo.update(db, db_obj=db_todo, obj_in=todo_in) + return db_todo + + +@router.delete("/{todo_id}", response_model=None, status_code=status.HTTP_204_NO_CONTENT) +def delete_todo( + *, + db: Session = Depends(get_db), + todo_id: int, +) -> None: + """ + Delete a todo. + + - **todo_id**: The ID of the todo to delete + """ + db_todo = todo.get(db, id=todo_id) + if not db_todo: + raise TodoNotFoundException(f"Todo with ID {todo_id} not found") + todo.remove(db, id=todo_id) + return None \ No newline at end of file diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..459c1ef --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/health", response_model=dict, status_code=status.HTTP_200_OK) +async def health_check(db: Session = Depends(get_db)): + """ + Check the health of the application. + + Tests: + - API availability + - Database connection + """ + health_data = { + "status": "ok", + "api": "up", + "database": "up", + } + + # Test database connection + try: + # Execute a simple query + db.execute("SELECT 1") + except Exception as e: + health_data["status"] = "error" + health_data["database"] = "down" + health_data["database_error"] = str(e) + + # Overall status is only "ok" if all components are up + if health_data["status"] == "ok": + return health_data + + # Return 503 Service Unavailable if any component is down + return health_data \ No newline at end of file diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..a51523e --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from app.api.endpoints import todo_router +from app.api.health import router as health_router + +router = APIRouter() + +# Include health check endpoint +router.include_router(health_router) + +# Include todo endpoints +router.include_router(todo_router, prefix="/todos", tags=["todos"]) \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..3d880d6 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,34 @@ +from pathlib import Path +from typing import List + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + PROJECT_NAME: str = "Simple Todo Application" + + # CORS Configuration + CORS_ORIGINS: List[str] = ["*"] + CORS_ALLOW_CREDENTIALS: bool = True + CORS_ALLOW_METHODS: List[str] = ["*"] + CORS_ALLOW_HEADERS: List[str] = ["*"] + + # API Settings + API_V1_STR: str = "" + + # Database Configuration + DB_DIR: Path = Path("/app") / "storage" / "db" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # Ensure DB directory exists + self.DB_DIR.mkdir(parents=True, exist_ok=True) + + +settings = Settings() \ No newline at end of file diff --git a/app/core/error_handlers.py b/app/core/error_handlers.py new file mode 100644 index 0000000..3bb360d --- /dev/null +++ b/app/core/error_handlers.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError + + +def add_error_handlers(app: FastAPI) -> None: + """ + Add global error handlers to the FastAPI application. + """ + @app.exception_handler(SQLAlchemyError) + async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError): + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": f"Database error: {str(exc)}"}, + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={"detail": f"Validation error: {str(exc)}"}, + ) \ No newline at end of file diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..6ee74f9 --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,22 @@ +from fastapi import HTTPException, status + + +class TodoNotFoundException(HTTPException): + def __init__(self, detail: str = "Todo not found"): + super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail) + + +class DatabaseError(HTTPException): + def __init__(self, detail: str = "Database error"): + super().__init__( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=detail, + ) + + +class ValidationError(HTTPException): + def __init__(self, detail: str = "Validation error"): + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=detail, + ) \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/crud/__init__.py b/app/db/crud/__init__.py new file mode 100644 index 0000000..2d1a749 --- /dev/null +++ b/app/db/crud/__init__.py @@ -0,0 +1,3 @@ +from app.db.crud.crud_todo import todo + +__all__ = ["todo"] \ No newline at end of file diff --git a/app/db/crud/base.py b/app/db/crud/base.py new file mode 100644 index 0000000..9a3afb1 --- /dev/null +++ b/app/db/crud/base.py @@ -0,0 +1,63 @@ +from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.models.base import Base + +ModelType = TypeVar("ModelType", bound=Base) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) + + +class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): + def __init__(self, model: Type[ModelType]): + """ + CRUD object with default methods to Create, Read, Update, Delete (CRUD). + **Parameters** + * `model`: A SQLAlchemy model class + """ + self.model = model + + def get(self, db: Session, id: Any) -> Optional[ModelType]: + return db.query(self.model).filter(self.model.id == id).first() + + def get_multi( + self, db: Session, *, skip: int = 0, limit: int = 100 + ) -> List[ModelType]: + return db.query(self.model).offset(skip).limit(limit).all() + + def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: + obj_in_data = jsonable_encoder(obj_in) + db_obj = self.model(**obj_in_data) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, + db: Session, + *, + db_obj: ModelType, + obj_in: Union[UpdateSchemaType, Dict[str, Any]] + ) -> ModelType: + obj_data = jsonable_encoder(db_obj) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def remove(self, db: Session, *, id: int) -> ModelType: + obj = db.query(self.model).get(id) + db.delete(obj) + db.commit() + return obj \ No newline at end of file diff --git a/app/db/crud/crud_todo.py b/app/db/crud/crud_todo.py new file mode 100644 index 0000000..e72c86f --- /dev/null +++ b/app/db/crud/crud_todo.py @@ -0,0 +1,33 @@ +from typing import List + +from sqlalchemy.orm import Session + +from app.db.crud.base import CRUDBase +from app.models.todo import Todo +from app.schemas.todo import TodoCreate, TodoUpdate + + +class CRUDTodo(CRUDBase[Todo, TodoCreate, TodoUpdate]): + def get_by_title(self, db: Session, *, title: str) -> List[Todo]: + return db.query(self.model).filter(self.model.title.contains(title)).all() + + def get_completed(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[Todo]: + return ( + db.query(self.model) + .filter(self.model.completed.is_(True)) + .offset(skip) + .limit(limit) + .all() + ) + + def get_uncompleted(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[Todo]: + return ( + db.query(self.model) + .filter(self.model.completed.is_(False)) + .offset(skip) + .limit(limit) + .all() + ) + + +todo = CRUDTodo(Todo) \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..5cb02e7 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,30 @@ +from typing import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + +# Ensure DB directory exists +DB_DIR = settings.DB_DIR +DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db() -> Generator[Session, None, None]: + """ + Get a database session that automatically closes when done. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/middleware/logging.py b/app/middleware/logging.py new file mode 100644 index 0000000..3509a34 --- /dev/null +++ b/app/middleware/logging.py @@ -0,0 +1,23 @@ +import time +from typing import Callable + +from fastapi import FastAPI, Request, Response + + +def add_logging_middleware(app: FastAPI) -> None: + """ + Add middleware for request logging. + """ + @app.middleware("http") + async def log_requests(request: Request, call_next: Callable) -> Response: + start_time = time.time() + + response = await call_next(request) + + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(process_time) + + # Log request details (in a real app, use a proper logger) + print(f"{request.method} {request.url.path} - {response.status_code} - {process_time:.4f}s") + + return response \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..6f2f269 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,4 @@ +from app.models.base import Base +from app.models.todo import Todo + +__all__ = ["Base", "Todo"] \ No newline at end of file diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..627efe9 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,8 @@ +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + @declared_attr + def __tablename__(cls) -> str: + return cls.__name__.lower() \ No newline at end of file diff --git a/app/models/todo.py b/app/models/todo.py new file mode 100644 index 0000000..136f3fe --- /dev/null +++ b/app/models/todo.py @@ -0,0 +1,19 @@ + +from sqlalchemy import Boolean, Column, DateTime, Integer, String +from sqlalchemy.sql import func + +from app.models.base import Base + + +class Todo(Base): + id = Column(Integer, primary_key=True, index=True) + title = Column(String, index=True, nullable=False) + description = Column(String, nullable=True) + completed = Column(Boolean, default=False) + created_at = Column(DateTime, default=func.now(), nullable=False) + updated_at = Column( + DateTime, + default=func.now(), + onupdate=func.now(), + nullable=False + ) \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..f354a86 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,3 @@ +from app.schemas.todo import Todo, TodoCreate, TodoInDB, TodoUpdate + +__all__ = ["Todo", "TodoCreate", "TodoUpdate", "TodoInDB"] \ No newline at end of file diff --git a/app/schemas/todo.py b/app/schemas/todo.py new file mode 100644 index 0000000..98e8360 --- /dev/null +++ b/app/schemas/todo.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field, constr + + +class TodoBase(BaseModel): + title: constr(min_length=1, max_length=100) = Field(..., description="The title of the todo item") + description: Optional[constr(max_length=500)] = Field(None, description="A detailed description of the todo item") + completed: bool = Field(False, description="Whether the todo item is completed") + + +class TodoCreate(TodoBase): + pass + + +class TodoUpdate(BaseModel): + title: Optional[constr(min_length=1, max_length=100)] = Field(None, description="The title of the todo item") + description: Optional[constr(max_length=500)] = Field(None, description="A detailed description of the todo item") + completed: Optional[bool] = Field(None, description="Whether the todo item is completed") + + +class TodoInDBBase(TodoBase): + id: int = Field(..., description="The unique identifier of the todo item") + created_at: datetime = Field(..., description="When the todo item was created") + updated_at: datetime = Field(..., description="When the todo item was last updated") + + class Config: + from_attributes = True + + +class Todo(TodoInDBBase): + pass + + +class TodoInDB(TodoInDBBase): + pass \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..23027a8 --- /dev/null +++ b/main.py @@ -0,0 +1,38 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import router as api_router +from app.core.config import settings +from app.core.error_handlers import add_error_handlers +from app.middleware.logging import add_logging_middleware + +app = FastAPI( + title=settings.PROJECT_NAME, + description="Simple Todo Application API", + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# Set up CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=settings.CORS_ALLOW_CREDENTIALS, + allow_methods=settings.CORS_ALLOW_METHODS, + allow_headers=settings.CORS_ALLOW_HEADERS, +) + +# Add logging middleware +add_logging_middleware(app) + +# Add global error handlers +add_error_handlers(app) + +# Include API router +app.include_router(api_router, prefix=settings.API_V1_STR) + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..d1a4b3a --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,79 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Import the Base model for autogenerate support +from app.models import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == 'sqlite' + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Key configuration for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1e4564e --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/20240101_initial_todo_table.py b/migrations/versions/20240101_initial_todo_table.py new file mode 100644 index 0000000..8ab3edd --- /dev/null +++ b/migrations/versions/20240101_initial_todo_table.py @@ -0,0 +1,37 @@ +"""Initial Todo table + +Revision ID: 9c6f0b3a8e4c +Revises: +Create Date: 2024-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9c6f0b3a8e4c' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'todo', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('completed', sa.Boolean(), default=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_todo_id'), 'todo', ['id'], unique=False) + op.create_index(op.f('ix_todo_title'), 'todo', ['title'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_todo_title'), table_name='todo') + op.drop_index(op.f('ix_todo_id'), table_name='todo') + op.drop_table('todo') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..efab389 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.100.0 +uvicorn>=0.23.0 +sqlalchemy>=2.0.0 +alembic>=1.11.0 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +python-dotenv>=1.0.0 +email-validator>=2.0.0 +ruff>=0.1.6 \ No newline at end of file