diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..5cda32e --- /dev/null +++ b/alembic.ini @@ -0,0 +1,97 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# 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 path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses +# os.pathsep. If this key is omitted entirely, it falls back to the legacy +# behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +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 REVISION_SCRIPT_FILENAME + +# 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/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..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..e9443c1 --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +# Create the main API router +api_router = APIRouter() + +# For now, just include a basic health endpoint under API +@api_router.get("/health") +async def api_health(): + """ + API health check endpoint. + """ + return {"status": "healthy", "api_version": "v1"} \ 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..80d34ec --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,18 @@ +from pydantic_settings import BaseSettings +from pathlib import Path + + +class Settings(BaseSettings): + PROJECT_NAME: str = "Todo API" + PROJECT_VERSION: str = "1.0.0" + + # Database settings + DB_DIR: Path = Path("/app/storage/db") + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() \ No newline at end of file diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/todo.py b/app/crud/todo.py new file mode 100644 index 0000000..0cdb112 --- /dev/null +++ b/app/crud/todo.py @@ -0,0 +1,63 @@ +from sqlalchemy.orm import Session +from app.models.todo import Todo +from app.schemas.todo import TodoCreate, TodoUpdate +from typing import List, Optional + + +def get_todo(db: Session, todo_id: int) -> Optional[Todo]: + """ + Get a single todo by ID. + """ + return db.query(Todo).filter(Todo.id == todo_id).first() + + +def get_todos(db: Session, skip: int = 0, limit: int = 100) -> List[Todo]: + """ + Get all todos with pagination. + """ + return db.query(Todo).offset(skip).limit(limit).all() + + +def create_todo(db: Session, todo: TodoCreate) -> Todo: + """ + Create a new todo. + """ + db_todo = Todo( + title=todo.title, + description=todo.description, + completed=todo.completed + ) + db.add(db_todo) + db.commit() + db.refresh(db_todo) + return db_todo + + +def update_todo(db: Session, todo_id: int, todo_update: TodoUpdate) -> Optional[Todo]: + """ + Update an existing todo. + """ + db_todo = db.query(Todo).filter(Todo.id == todo_id).first() + if not db_todo: + return None + + update_data = todo_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_todo, field, value) + + db.commit() + db.refresh(db_todo) + return db_todo + + +def delete_todo(db: Session, todo_id: int) -> bool: + """ + Delete a todo by ID. + """ + db_todo = db.query(Todo).filter(Todo.id == todo_id).first() + if not db_todo: + return False + + db.delete(db_todo) + db.commit() + return True \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py index 9d46b7b..3a34caa 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -1,19 +1,7 @@ -from sqlalchemy import create_engine +""" +Database base configuration. +""" from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -from pathlib import Path - -# Database setup -DB_DIR = Path("/app/storage/db") -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) +# SQLAlchemy Base class for all models Base = declarative_base() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py index 5ac3870..4e7f755 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,13 +1,18 @@ -from sqlalchemy.orm import Session -from app.db.base import SessionLocal +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from pathlib import Path +from app.core.config import settings -def get_db() -> Session: - """ - Dependency to get database session. - """ - db = SessionLocal() - try: - yield db - finally: - db.close() \ No newline at end of file +# Create storage directory if it doesn't exist +DB_DIR = Path("/app/storage/db") +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) \ No newline at end of file diff --git a/app/models/todo.py b/app/models/todo.py index 2bb7ef1..66f2af0 100644 --- a/app/models/todo.py +++ b/app/models/todo.py @@ -1,15 +1,14 @@ -from datetime import datetime -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime - +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func from app.db.base import Base class Todo(Base): __tablename__ = "todos" - + id = Column(Integer, primary_key=True, index=True) - title = Column(String(255), nullable=False) - description = Column(Text, nullable=True) + title = Column(String(255), nullable=False, index=True) + description = Column(String(1000), nullable=True) completed = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) \ No newline at end of file + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/todo.py b/app/schemas/todo.py new file mode 100644 index 0000000..73d5f54 --- /dev/null +++ b/app/schemas/todo.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional + + +class TodoBase(BaseModel): + title: str = Field(..., min_length=1, max_length=255, description="Title of the todo item") + description: Optional[str] = Field(None, max_length=1000, description="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[str] = Field(None, min_length=1, max_length=255, description="Title of the todo item") + description: Optional[str] = Field(None, max_length=1000, description="Description of the todo item") + completed: Optional[bool] = Field(None, description="Whether the todo item is completed") + + +class TodoResponse(TodoBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True \ No newline at end of file