From 1d568902802daec24cfa61619804bdfaea68bd6b Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Fri, 19 Jun 2026 10:57:08 +0530 Subject: [PATCH 01/22] chore: initial project setup --- .env.example | 0 .github/pull_request_template.md | 0 .github/workflows/ci.yml | 0 Dockerfile | 0 Makefile | 0 alembic.ini | 0 app/__init__.py | 0 app/api/__init__.py | 0 app/api/health.py | 0 app/api/ready.py | 0 app/api/tasks.py | 0 app/api/users.py | 0 app/core/__init__.py | 0 app/core/config.py | 0 app/core/logger.py | 0 app/db/__init__.py | 0 app/db/base.py | 0 app/db/session.py | 0 app/main.py | 0 app/models/__init__.py | 0 app/models/task.py | 0 app/models/user.py | 0 app/schemas/__init__.py | 0 app/schemas/task.py | 0 app/schemas/user.py | 0 app/services/__init__.py | 0 app/services/task_service.py | 0 app/services/user_service.py | 0 create_project.py | 92 ++++++++++++++++++++++++++++++++ docker-compose.yml | 0 requirements.txt | 0 scripts/check_env.sh | 0 tests/__init__.py | 0 tests/conftest.py | 0 tests/test_health.py | 0 tests/test_ready.py | 0 tests/test_tasks.py | 0 tests/test_users.py | 0 38 files changed, 92 insertions(+) create mode 100644 .env.example create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/health.py create mode 100644 app/api/ready.py create mode 100644 app/api/tasks.py create mode 100644 app/api/users.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/logger.py create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/db/session.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/task.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/task.py create mode 100644 app/schemas/user.py create mode 100644 app/services/__init__.py create mode 100644 app/services/task_service.py create mode 100644 app/services/user_service.py create mode 100644 create_project.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 scripts/check_env.sh create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_health.py create mode 100644 tests/test_ready.py create mode 100644 tests/test_tasks.py create mode 100644 tests/test_users.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e69de29 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..e69de29 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/health.py b/app/api/health.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/ready.py b/app/api/ready.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/tasks.py b/app/api/tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 0000000..e69de29 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..e69de29 diff --git a/app/core/logger.py b/app/core/logger.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/task.py b/app/models/task.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/task.py b/app/schemas/task.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/task_service.py b/app/services/task_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..e69de29 diff --git a/create_project.py b/create_project.py new file mode 100644 index 0000000..61e7743 --- /dev/null +++ b/create_project.py @@ -0,0 +1,92 @@ +from pathlib import Path + +PROJECT_STRUCTURE = { + "app": { + "__init__.py": "", + "main.py": "", + "api": { + "__init__.py": "", + "users.py": "", + "tasks.py": "", + "health.py": "", + "ready.py": "", + }, + "core": { + "__init__.py": "", + "config.py": "", + "logger.py": "", + }, + "db": { + "__init__.py": "", + "base.py": "", + "session.py": "", + }, + "models": { + "__init__.py": "", + "user.py": "", + "task.py": "", + }, + "schemas": { + "__init__.py": "", + "user.py": "", + "task.py": "", + }, + "services": { + "__init__.py": "", + "user_service.py": "", + "task_service.py": "", + }, + }, + "tests": { + "__init__.py": "", + "conftest.py": "", + "test_users.py": "", + "test_tasks.py": "", + "test_health.py": "", + "test_ready.py": "", + }, + "scripts": { + "check_env.sh": "", + }, + "alembic": { + "versions": {}, + }, + ".github": { + "workflows": { + "ci.yml": "", + }, + "pull_request_template.md": "", + }, + ".env.example": "", + ".gitignore": "", + "requirements.txt": "", + "Makefile": "", + "Dockerfile": "", + "docker-compose.yml": "", + "alembic.ini": "", + "README.md": "", +} + + +def create_structure(base_path: Path, structure: dict): + for name, content in structure.items(): + path = base_path / name + + if isinstance(content, dict): + path.mkdir(parents=True, exist_ok=True) + create_structure(path, content) + else: + path.parent.mkdir(parents=True, exist_ok=True) + + if not path.exists(): + path.touch() + print(f"Created file: {path}") + else: + print(f"Exists: {path}") + + +if __name__ == "__main__": + root = Path.cwd() + create_structure(root, PROJECT_STRUCTURE) + + print("\n✅ TaskTracker folder structure created successfully.") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/scripts/check_env.sh b/scripts/check_env.sh new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ready.py b/tests/test_ready.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..e69de29 From 0b0f6f9f0140a9431c776cde3ef1aa6c241690c6 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Fri, 19 Jun 2026 11:26:59 +0530 Subject: [PATCH 02/22] feat: add health and readiness probe endpoints --- .env.example | 0 app/api/health.py | 10 ++++++++++ app/api/ready.py | 22 ++++++++++++++++++++++ app/core/config.py | 15 +++++++++++++++ app/db/session.py | 22 ++++++++++++++++++++++ app/main.py | 11 +++++++++++ requirements.txt | 7 +++++++ tests/test_health.py | 13 +++++++++++++ tests/test_ready.py | 10 ++++++++++ 9 files changed, 110 insertions(+) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/health.py b/app/api/health.py index e69de29..5d2787b 100644 --- a/app/api/health.py +++ b/app/api/health.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +router = APIRouter(tags=["Health"]) + + +@router.get("/healthz") +def health_check(): + return { + "status": "ok" + } \ No newline at end of file diff --git a/app/api/ready.py b/app/api/ready.py index e69de29..8808966 100644 --- a/app/api/ready.py +++ b/app/api/ready.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.db.session import get_db + +router = APIRouter(tags=["Readiness"]) + + +@router.get("/readyz") +def readiness_check(db: Session = Depends(get_db)): + try: + db.execute(text("SELECT 1")) + return { + "status": "ready" + } + + except Exception: + raise HTTPException( + status_code=503, + detail="Database not ready" + ) \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index e69de29..19f607c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + DATABASE_URL: str + LOG_LEVEL: str = "INFO" + APP_PORT: int = 8000 + + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore" + ) + + +settings = Settings() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py index e69de29..10727a2 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings + +engine = create_engine(settings.DATABASE_URL) + +SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine +) + + +def get_db(): + db = SessionLocal() + + try: + yield db + + finally: + db.close() \ No newline at end of file diff --git a/app/main.py b/app/main.py index e69de29..c228804 100644 --- a/app/main.py +++ b/app/main.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI + +from app.api.health import router as health_router +from app.api.ready import router as ready_router + +app = FastAPI( + title="TaskTracker API" +) + +app.include_router(health_router) +app.include_router(ready_router) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e69de29..04f106e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi +uvicorn +sqlalchemy +psycopg2-binary +pydantic-settings +python-dotenv +pytest \ No newline at end of file diff --git a/tests/test_health.py b/tests/test_health.py index e69de29..d209723 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -0,0 +1,13 @@ +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_healthz(): + response = client.get("/healthz") + + assert response.status_code == 200 + assert response.json() == { + "status": "ok" + } \ No newline at end of file diff --git a/tests/test_ready.py b/tests/test_ready.py index e69de29..fc5f71b 100644 --- a/tests/test_ready.py +++ b/tests/test_ready.py @@ -0,0 +1,10 @@ +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_readyz(): + response = client.get("/readyz") + + assert response.status_code in [200, 503] \ No newline at end of file From 8af2ac4be0a972dc4b38149cf557fdccd784cc65 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar <101800022+mgaditya-er@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:29:43 +0530 Subject: [PATCH 03/22] feat: implement user and task management APIs --- app/api/tasks.py | 143 ++++++++++++++++++++++++++++++++++++++++++++ app/api/users.py | 65 ++++++++++++++++++++ app/db/base.py | 3 + app/main.py | 6 +- app/models/task.py | 12 ++++ app/models/user.py | 10 ++++ app/schemas/task.py | 25 ++++++++ app/schemas/user.py | 15 +++++ requirements.txt | 5 +- 9 files changed, 282 insertions(+), 2 deletions(-) diff --git a/app/api/tasks.py b/app/api/tasks.py index e69de29..709f609 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -0,0 +1,143 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.task import Task +from app.models.user import User +from app.schemas.task import TaskCreate, TaskUpdate + +router = APIRouter( + prefix="/tasks", + tags=["Tasks"] +) + +VALID_STATUS = [ + "pending", + "in_progress", + "completed" +] + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_task( + payload: TaskCreate, + db: Session = Depends(get_db) +): + + owner = db.query(User).filter( + User.id == payload.owner_id + ).first() + + if not owner: + raise HTTPException( + status_code=404, + detail="Owner not found" + ) + + task = Task( + title=payload.title, + description=payload.description, + owner_id=payload.owner_id + ) + + db.add(task) + db.commit() + db.refresh(task) + + return task + + +@router.get("") +def get_tasks( + status: str | None = Query(None), + owner_id: int | None = Query(None), + db: Session = Depends(get_db) +): + + query = db.query(Task) + + if status: + query = query.filter(Task.status == status) + + if owner_id: + query = query.filter(Task.owner_id == owner_id) + + return query.all() + + +@router.get("/{task_id}") +def get_task( + task_id: int, + db: Session = Depends(get_db) +): + + task = db.query(Task).filter( + Task.id == task_id + ).first() + + if not task: + raise HTTPException( + status_code=404, + detail="Task not found" + ) + + return task + + +@router.put("/{task_id}") +def update_task( + task_id: int, + payload: TaskUpdate, + db: Session = Depends(get_db) +): + + task = db.query(Task).filter( + Task.id == task_id + ).first() + + if not task: + raise HTTPException( + status_code=404, + detail="Task not found" + ) + + if payload.status and payload.status not in VALID_STATUS: + raise HTTPException( + status_code=400, + detail="Invalid task status" + ) + + update_data = payload.model_dump( + exclude_unset=True + ) + + for key, value in update_data.items(): + setattr(task, key, value) + + db.commit() + db.refresh(task) + + return task + + +@router.delete( + "/{task_id}", + status_code=status.HTTP_204_NO_CONTENT +) +def delete_task( + task_id: int, + db: Session = Depends(get_db) +): + + task = db.query(Task).filter( + Task.id == task_id + ).first() + + if not task: + raise HTTPException( + status_code=404, + detail="Task not found" + ) + + db.delete(task) + db.commit() \ No newline at end of file diff --git a/app/api/users.py b/app/api/users.py index e69de29..410d7c1 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -0,0 +1,65 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.user import User +from app.schemas.user import UserCreate + +router = APIRouter( + prefix="/users", + tags=["Users"] +) + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_user( + payload: UserCreate, + db: Session = Depends(get_db) +): + + existing_user = db.query(User).filter( + User.email == payload.email + ).first() + + if existing_user: + raise HTTPException( + status_code=409, + detail="Email already exists" + ) + + user = User( + name=payload.name, + email=payload.email + ) + + db.add(user) + db.commit() + db.refresh(user) + + return user + + +@router.get("") +def get_users( + db: Session = Depends(get_db) +): + return db.query(User).all() + + +@router.get("/{user_id}") +def get_user( + user_id: int, + db: Session = Depends(get_db) +): + + user = db.query(User).filter( + User.id == user_id + ).first() + + if not user: + raise HTTPException( + status_code=404, + detail="User not found" + ) + + return user \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py index e69de29..95ebc0c 100644 --- a/app/db/base.py +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base + +Base = declarative_base() \ No newline at end of file diff --git a/app/main.py b/app/main.py index c228804..4be352a 100644 --- a/app/main.py +++ b/app/main.py @@ -2,10 +2,14 @@ from app.api.health import router as health_router from app.api.ready import router as ready_router +from app.api.users import router as user_router +from app.api.tasks import router as task_router app = FastAPI( title="TaskTracker API" ) app.include_router(health_router) -app.include_router(ready_router) \ No newline at end of file +app.include_router(ready_router) +app.include_router(user_router) +app.include_router(task_router) \ No newline at end of file diff --git a/app/models/task.py b/app/models/task.py index e69de29..d5bd08e 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String, ForeignKey +from app.db.base import Base + + +class Task(Base): + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False) + description = Column(String(500)) + status = Column(String(50), default="pending", nullable=False) + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index e69de29..be92d80 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, String +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + email = Column(String(100), unique=True, nullable=False) \ No newline at end of file diff --git a/app/schemas/task.py b/app/schemas/task.py index e69de29..b324936 100644 --- a/app/schemas/task.py +++ b/app/schemas/task.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class TaskCreate(BaseModel): + title: str = Field(..., min_length=3, max_length=200) + description: Optional[str] = None + owner_id: int + + +class TaskUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=3, max_length=200) + description: Optional[str] = None + status: Optional[str] = None + + +class TaskResponse(BaseModel): + id: int + title: str + description: Optional[str] + status: str + owner_id: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py index e69de29..6750755 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, EmailStr, Field + + +class UserCreate(BaseModel): + name: str = Field(..., min_length=2, max_length=100) + email: EmailStr + + +class UserResponse(BaseModel): + id: int + name: str + email: str + + class Config: + from_attributes = True \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 04f106e..8f83493 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,9 @@ fastapi uvicorn sqlalchemy psycopg2-binary +pydantic pydantic-settings python-dotenv -pytest \ No newline at end of file +email-validator +pytest +pytest-cov \ No newline at end of file From b3f1634696b066227891dc1f08bb44c2b3cfcf2c Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Fri, 19 Jun 2026 12:43:16 +0530 Subject: [PATCH 04/22] feat: configure Alembic migration framework --- alembic/README | 1 + alembic/env.py | 95 ++++++++++++++++++++++++++++++++++++++++++ alembic/script.py.mako | 28 +++++++++++++ requirements.txt | 3 +- 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..c49e1d7 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,95 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +import os + +database_url = os.getenv("DATABASE_URL") + +if not database_url: + raise ValueError( + "DATABASE_URL environment variable is required" + ) + +config.set_main_option( + "sqlalchemy.url", + database_url +) +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from app.db.base import Base +from app.models.user import User +from app.models.task import Task + +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() -> None: + """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() -> None: + """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: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/requirements.txt b/requirements.txt index 8f83493..5516544 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ pydantic-settings python-dotenv email-validator pytest -pytest-cov \ No newline at end of file +pytest-cov +alembic \ No newline at end of file From 2b1873dc464725970dbc5a0e2dff257340108510 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Fri, 19 Jun 2026 13:12:57 +0530 Subject: [PATCH 05/22] feat: add Alembic migration support --- alembic.ini | 149 ++++++++++++++++++ alembic/env.py | 8 + ...93e924a46_create_users_and_tasks_tables.py | 60 +++++++ 3 files changed, 217 insertions(+) create mode 100644 alembic/versions/c2993e924a46_create_users_and_tasks_tables.py diff --git a/alembic.ini b/alembic.ini index e69de29..807ded2 100644 --- a/alembic.ini +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/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 +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# 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 /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[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 + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +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 diff --git a/alembic/env.py b/alembic/env.py index c49e1d7..e27b8d7 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -9,8 +9,14 @@ # access to the values within the .ini file in use. config = context.config +from alembic import context +from dotenv import load_dotenv import os +config = context.config + +load_dotenv() + database_url = os.getenv("DATABASE_URL") if not database_url: @@ -31,6 +37,8 @@ # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata +# target_metadata = None +# replacing with the actual metadata from models from app.db.base import Base from app.models.user import User from app.models.task import Task diff --git a/alembic/versions/c2993e924a46_create_users_and_tasks_tables.py b/alembic/versions/c2993e924a46_create_users_and_tasks_tables.py new file mode 100644 index 0000000..ccf4fa1 --- /dev/null +++ b/alembic/versions/c2993e924a46_create_users_and_tasks_tables.py @@ -0,0 +1,60 @@ +"""create users and tasks tables + +Revision ID: c2993e924a46 +Revises: +Create Date: 2026-06-19 13:10:33.579909 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c2993e924a46' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('tasks', 'title', + existing_type=sa.VARCHAR(length=200), + nullable=False) + op.alter_column('tasks', 'status', + existing_type=sa.VARCHAR(length=50), + nullable=False) + op.alter_column('tasks', 'owner_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('users', 'name', + existing_type=sa.VARCHAR(length=100), + nullable=False) + op.alter_column('users', 'email', + existing_type=sa.VARCHAR(length=100), + nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'email', + existing_type=sa.VARCHAR(length=100), + nullable=True) + op.alter_column('users', 'name', + existing_type=sa.VARCHAR(length=100), + nullable=True) + op.alter_column('tasks', 'owner_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('tasks', 'status', + existing_type=sa.VARCHAR(length=50), + nullable=True) + op.alter_column('tasks', 'title', + existing_type=sa.VARCHAR(length=200), + nullable=True) + # ### end Alembic commands ### From 4cefaac48dbc0eb7cb4372b0a606d207e3d17ebe Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Fri, 19 Jun 2026 13:27:32 +0530 Subject: [PATCH 06/22] feat: add environment based configuration --- app/core/config.py | 16 +++++++++++----- app/core/startup.py | 14 ++++++++++++++ app/main.py | 3 +++ requirements.txt | 3 ++- 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 app/core/startup.py diff --git a/app/core/config.py b/app/core/config.py index 19f607c..92e4c07 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,15 +1,21 @@ -from pydantic_settings import BaseSettings, SettingsConfigDict +# app/core/config.py +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import ValidationError class Settings(BaseSettings): DATABASE_URL: str - LOG_LEVEL: str = "INFO" - APP_PORT: int = 8000 + LOG_LEVEL: str + APP_PORT: int model_config = SettingsConfigDict( env_file=".env", extra="ignore" ) - -settings = Settings() \ No newline at end of file +try: + settings = Settings() +except ValidationError as e: + raise RuntimeError( + f"Missing required environment variables: {e}" + ) \ No newline at end of file diff --git a/app/core/startup.py b/app/core/startup.py new file mode 100644 index 0000000..ce0ae45 --- /dev/null +++ b/app/core/startup.py @@ -0,0 +1,14 @@ +from app.core.config import settings + + +def validate_settings(): + required = [ + settings.DATABASE_URL, + settings.APP_PORT, + settings.LOG_LEVEL + ] + + if not all(required): + raise ValueError( + "Required environment variables are missing" + ) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 4be352a..3155982 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,13 @@ from fastapi import FastAPI +from app.core.startup import validate_settings from app.api.health import router as health_router from app.api.ready import router as ready_router from app.api.users import router as user_router from app.api.tasks import router as task_router +validate_settings() + app = FastAPI( title="TaskTracker API" ) diff --git a/requirements.txt b/requirements.txt index 5516544..15c4e1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ python-dotenv email-validator pytest pytest-cov -alembic \ No newline at end of file +alembic +pytest \ No newline at end of file From 302a7ffd8dd867714214275baa7dcfc7c3c27093 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Fri, 19 Jun 2026 13:42:57 +0530 Subject: [PATCH 07/22] feat: add structured JSON logging with request id --- app/api/tasks.py | 149 +++++++++++++++++++++++++++++++++-- app/api/users.py | 50 +++++++++++- app/core/logger.py | 34 ++++++++ app/core/logging_config.py | 33 ++++++++ app/main.py | 4 + app/middleware/request_id.py | 23 ++++++ 6 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 app/core/logging_config.py create mode 100644 app/middleware/request_id.py diff --git a/app/api/tasks.py b/app/api/tasks.py index 709f609..9ba339f 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -1,4 +1,13 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, status +import logging + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + Query, + Request, + status +) from sqlalchemy.orm import Session from app.db.session import get_db @@ -6,6 +15,8 @@ from app.models.user import User from app.schemas.task import TaskCreate, TaskUpdate +logger = logging.getLogger(__name__) + router = APIRouter( prefix="/tasks", tags=["Tasks"] @@ -20,6 +31,7 @@ @router.post("", status_code=status.HTTP_201_CREATED) def create_task( + request: Request, payload: TaskCreate, db: Session = Depends(get_db) ): @@ -29,6 +41,18 @@ def create_task( ).first() if not owner: + + logger.warning( + "task creation failed - owner not found", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) + raise HTTPException( status_code=404, detail="Owner not found" @@ -44,11 +68,23 @@ def create_task( db.commit() db.refresh(task) + logger.info( + f"task created id={task.id}", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) + return task @router.get("") def get_tasks( + request: Request, status: str | None = Query(None), owner_id: int | None = Query(None), db: Session = Depends(get_db) @@ -57,17 +93,35 @@ def get_tasks( query = db.query(Task) if status: - query = query.filter(Task.status == status) + query = query.filter( + Task.status == status + ) if owner_id: - query = query.filter(Task.owner_id == owner_id) + query = query.filter( + Task.owner_id == owner_id + ) + + tasks = query.all() + + logger.info( + "tasks fetched", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) - return query.all() + return tasks @router.get("/{task_id}") def get_task( task_id: int, + request: Request, db: Session = Depends(get_db) ): @@ -76,17 +130,41 @@ def get_task( ).first() if not task: + + logger.warning( + f"task not found id={task_id}", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) + raise HTTPException( status_code=404, detail="Task not found" ) + logger.info( + f"task fetched id={task_id}", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) + return task @router.put("/{task_id}") def update_task( task_id: int, + request: Request, payload: TaskUpdate, db: Session = Depends(get_db) ): @@ -96,12 +174,36 @@ def update_task( ).first() if not task: + + logger.warning( + f"task update failed id={task_id}", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) + raise HTTPException( status_code=404, detail="Task not found" ) if payload.status and payload.status not in VALID_STATUS: + + logger.warning( + f"invalid task status={payload.status}", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) + raise HTTPException( status_code=400, detail="Invalid task status" @@ -117,6 +219,17 @@ def update_task( db.commit() db.refresh(task) + logger.info( + f"task updated id={task_id}", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) + return task @@ -126,6 +239,7 @@ def update_task( ) def delete_task( task_id: int, + request: Request, db: Session = Depends(get_db) ): @@ -134,10 +248,35 @@ def delete_task( ).first() if not task: + + logger.warning( + f"task delete failed id={task_id}", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) + raise HTTPException( status_code=404, detail="Task not found" ) + logger.info( + f"task deleted id={task_id}", + extra={ + "request_id": getattr( + request.state, + "request_id", + "N/A" + ) + } + ) + db.delete(task) - db.commit() \ No newline at end of file + db.commit() + + return None \ No newline at end of file diff --git a/app/api/users.py b/app/api/users.py index 410d7c1..153efe0 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -1,10 +1,14 @@ -from fastapi import APIRouter, Depends, HTTPException, status +import logging + +from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session from app.db.session import get_db from app.models.user import User from app.schemas.user import UserCreate +logger = logging.getLogger(__name__) + router = APIRouter( prefix="/users", tags=["Users"] @@ -13,6 +17,7 @@ @router.post("", status_code=status.HTTP_201_CREATED) def create_user( + request: Request, payload: UserCreate, db: Session = Depends(get_db) ): @@ -22,6 +27,13 @@ def create_user( ).first() if existing_user: + logger.warning( + "user creation failed - email already exists", + extra={ + "request_id": request.state.request_id + } + ) + raise HTTPException( status_code=409, detail="Email already exists" @@ -36,19 +48,38 @@ def create_user( db.commit() db.refresh(user) + logger.info( + f"user created id={user.id}", + extra={ + "request_id": request.state.request_id + } + ) + return user @router.get("") def get_users( + request: Request, db: Session = Depends(get_db) ): - return db.query(User).all() + + users = db.query(User).all() + + logger.info( + "users fetched", + extra={ + "request_id": request.state.request_id + } + ) + + return users @router.get("/{user_id}") def get_user( user_id: int, + request: Request, db: Session = Depends(get_db) ): @@ -57,9 +88,24 @@ def get_user( ).first() if not user: + + logger.warning( + f"user not found id={user_id}", + extra={ + "request_id": request.state.request_id + } + ) + raise HTTPException( status_code=404, detail="User not found" ) + logger.info( + f"user fetched id={user_id}", + extra={ + "request_id": request.state.request_id + } + ) + return user \ No newline at end of file diff --git a/app/core/logger.py b/app/core/logger.py index e69de29..516e5b9 100644 --- a/app/core/logger.py +++ b/app/core/logger.py @@ -0,0 +1,34 @@ +import json +import logging +from datetime import datetime + + +class JsonFormatter(logging.Formatter): + def format(self, record): + log_record = { + "timestamp": datetime.utcnow().isoformat(), + "level": record.levelname, + "message": record.getMessage(), + "request_id": getattr( + record, + "request_id", + "N/A" + ) + } + + return json.dumps(log_record) + + +def setup_logger(): + logger = logging.getLogger() + + logger.setLevel(logging.INFO) + + logger.handlers.clear() + + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + + logger.addHandler(handler) + + return logger \ No newline at end of file diff --git a/app/core/logging_config.py b/app/core/logging_config.py new file mode 100644 index 0000000..4e9837f --- /dev/null +++ b/app/core/logging_config.py @@ -0,0 +1,33 @@ +import json +import logging +from datetime import datetime + + +class JsonFormatter(logging.Formatter): + + def format(self, record): + + log_record = { + "timestamp": datetime.utcnow().isoformat(), + "level": record.levelname, + "message": record.getMessage(), + "request_id": getattr( + record, + "request_id", + "N/A" + ) + } + + return json.dumps(log_record) + + +def setup_logging(): + + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + + root_logger.handlers.clear() + root_logger.addHandler(handler) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 3155982..059ab2e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,16 +1,20 @@ from fastapi import FastAPI from app.core.startup import validate_settings +from app.core.logger import setup_logger from app.api.health import router as health_router from app.api.ready import router as ready_router from app.api.users import router as user_router from app.api.tasks import router as task_router +from app.middleware.request_id import RequestIDMiddleware validate_settings() +logger = setup_logger() app = FastAPI( title="TaskTracker API" ) +app.add_middleware(RequestIDMiddleware) app.include_router(health_router) app.include_router(ready_router) diff --git a/app/middleware/request_id.py b/app/middleware/request_id.py new file mode 100644 index 0000000..b68d3fc --- /dev/null +++ b/app/middleware/request_id.py @@ -0,0 +1,23 @@ +import uuid + +from starlette.middleware.base import BaseHTTPMiddleware + + +class RequestIDMiddleware(BaseHTTPMiddleware): + + async def dispatch( + self, + request, + call_next + ): + request.state.request_id = str( + uuid.uuid4() + ) + + response = await call_next(request) + + response.headers["X-Request-ID"] = ( + request.state.request_id + ) + + return response \ No newline at end of file From 1ef92f06fc2229673e5d99f72e9a7d1dd9f0d2d0 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Fri, 19 Jun 2026 13:43:48 +0530 Subject: [PATCH 08/22] feat: add structured JSON logging with request id --- ...d JSON logging with request id\357\200\242" | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 "tructured JSON logging with request id\357\200\242" diff --git "a/tructured JSON logging with request id\357\200\242" "b/tructured JSON logging with request id\357\200\242" new file mode 100644 index 0000000..3f839c1 --- /dev/null +++ "b/tructured JSON logging with request id\357\200\242" @@ -0,0 +1,18 @@ + dev + feature/alembic-migrations + feature/env-config + feature/health-ready-probes +* feature/json-logging + feature/project-setup + feature/user-task-api + main + stg + remotes/origin/HEAD -> origin/main + remotes/origin/dev + remotes/origin/feature/alembic-migrations + remotes/origin/feature/env-config + remotes/origin/feature/health-ready-probes + remotes/origin/feature/project-setup + remotes/origin/feature/user-task-api + remotes/origin/main + remotes/origin/stg From c1b16a33606805da932f0049ac3e99ece3e79481 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Fri, 19 Jun 2026 15:49:08 +0530 Subject: [PATCH 09/22] test: fix API test failures and improve test isolation --- app/api/tasks.py | 168 ++++++++++++++------------------------------ app/api/users.py | 49 ++++++------- requirements.txt | 3 +- test.db | Bin 0 -> 24576 bytes tests/conftest.py | 51 ++++++++++++++ tests/test_tasks.py | 110 +++++++++++++++++++++++++++++ tests/test_users.py | 72 +++++++++++++++++++ 7 files changed, 309 insertions(+), 144 deletions(-) create mode 100644 test.db diff --git a/app/api/tasks.py b/app/api/tasks.py index 9ba339f..5cea72b 100644 --- a/app/api/tasks.py +++ b/app/api/tasks.py @@ -13,7 +13,7 @@ from app.db.session import get_db from app.models.task import Task from app.models.user import User -from app.schemas.task import TaskCreate, TaskUpdate +from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse logger = logging.getLogger(__name__) @@ -22,37 +22,30 @@ tags=["Tasks"] ) -VALID_STATUS = [ - "pending", - "in_progress", - "completed" -] +VALID_STATUS = ["pending", "in_progress", "completed"] -@router.post("", status_code=status.HTTP_201_CREATED) +# ---------------- CREATE TASK ---------------- +@router.post( + "", + status_code=status.HTTP_201_CREATED, + response_model=TaskResponse +) def create_task( request: Request, payload: TaskCreate, db: Session = Depends(get_db) ): - owner = db.query(User).filter( - User.id == payload.owner_id - ).first() + request_id = getattr(request.state, "request_id", "N/A") - if not owner: + owner = db.query(User).filter(User.id == payload.owner_id).first() + if not owner: logger.warning( "task creation failed - owner not found", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) - raise HTTPException( status_code=404, detail="Owner not found" @@ -70,19 +63,17 @@ def create_task( logger.info( f"task created id={task.id}", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) return task -@router.get("") +# ---------------- GET TASKS ---------------- +@router.get( + "", + response_model=list[TaskResponse] +) def get_tasks( request: Request, status: str | None = Query(None), @@ -90,58 +81,46 @@ def get_tasks( db: Session = Depends(get_db) ): + request_id = getattr(request.state, "request_id", "N/A") + query = db.query(Task) if status: - query = query.filter( - Task.status == status - ) + query = query.filter(Task.status == status) if owner_id: - query = query.filter( - Task.owner_id == owner_id - ) + query = query.filter(Task.owner_id == owner_id) tasks = query.all() logger.info( "tasks fetched", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) return tasks -@router.get("/{task_id}") +# ---------------- GET TASK BY ID ---------------- +@router.get( + "/{task_id}", + response_model=TaskResponse +) def get_task( task_id: int, request: Request, db: Session = Depends(get_db) ): - task = db.query(Task).filter( - Task.id == task_id - ).first() + request_id = getattr(request.state, "request_id", "N/A") - if not task: + task = db.query(Task).filter(Task.id == task_id).first() + if not task: logger.warning( f"task not found id={task_id}", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) - raise HTTPException( status_code=404, detail="Task not found" @@ -149,19 +128,17 @@ def get_task( logger.info( f"task fetched id={task_id}", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) return task -@router.put("/{task_id}") +# ---------------- UPDATE TASK ---------------- +@router.put( + "/{task_id}", + response_model=TaskResponse +) def update_task( task_id: int, request: Request, @@ -169,49 +146,31 @@ def update_task( db: Session = Depends(get_db) ): - task = db.query(Task).filter( - Task.id == task_id - ).first() + request_id = getattr(request.state, "request_id", "N/A") - if not task: + task = db.query(Task).filter(Task.id == task_id).first() + if not task: logger.warning( f"task update failed id={task_id}", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) - raise HTTPException( status_code=404, detail="Task not found" ) if payload.status and payload.status not in VALID_STATUS: - logger.warning( f"invalid task status={payload.status}", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) - raise HTTPException( status_code=400, detail="Invalid task status" ) - update_data = payload.model_dump( - exclude_unset=True - ) + update_data = payload.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(task, key, value) @@ -221,18 +180,13 @@ def update_task( logger.info( f"task updated id={task_id}", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) return task +# ---------------- DELETE TASK ---------------- @router.delete( "/{task_id}", status_code=status.HTTP_204_NO_CONTENT @@ -243,40 +197,26 @@ def delete_task( db: Session = Depends(get_db) ): - task = db.query(Task).filter( - Task.id == task_id - ).first() + request_id = getattr(request.state, "request_id", "N/A") - if not task: + task = db.query(Task).filter(Task.id == task_id).first() + if not task: logger.warning( f"task delete failed id={task_id}", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) - raise HTTPException( status_code=404, detail="Task not found" ) + db.delete(task) + db.commit() + logger.info( f"task deleted id={task_id}", - extra={ - "request_id": getattr( - request.state, - "request_id", - "N/A" - ) - } + extra={"request_id": request_id} ) - db.delete(task) - db.commit() - return None \ No newline at end of file diff --git a/app/api/users.py b/app/api/users.py index 153efe0..d007e4f 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -5,23 +5,22 @@ from app.db.session import get_db from app.models.user import User -from app.schemas.user import UserCreate +from app.schemas.user import UserCreate, UserResponse logger = logging.getLogger(__name__) -router = APIRouter( - prefix="/users", - tags=["Users"] -) +router = APIRouter(prefix="/users", tags=["Users"]) -@router.post("", status_code=status.HTTP_201_CREATED) +@router.post("", status_code=status.HTTP_201_CREATED, response_model=UserResponse) def create_user( request: Request, payload: UserCreate, db: Session = Depends(get_db) ): + request_id = getattr(request.state, "request_id", "N/A") + existing_user = db.query(User).filter( User.email == payload.email ).first() @@ -29,9 +28,7 @@ def create_user( if existing_user: logger.warning( "user creation failed - email already exists", - extra={ - "request_id": request.state.request_id - } + extra={"request_id": request_id} ) raise HTTPException( @@ -50,50 +47,46 @@ def create_user( logger.info( f"user created id={user.id}", - extra={ - "request_id": request.state.request_id - } + extra={"request_id": request_id} ) - return user + return UserResponse.model_validate(user) -@router.get("") +@router.get("", response_model=list[UserResponse]) def get_users( request: Request, db: Session = Depends(get_db) ): + request_id = getattr(request.state, "request_id", "N/A") + users = db.query(User).all() logger.info( "users fetched", - extra={ - "request_id": request.state.request_id - } + extra={"request_id": request_id} ) - return users + return users -@router.get("/{user_id}") +@router.get("/{user_id}", response_model=UserResponse) def get_user( user_id: int, request: Request, db: Session = Depends(get_db) ): - user = db.query(User).filter( - User.id == user_id - ).first() + request_id = getattr(request.state, "request_id", "N/A") + + user = db.query(User).filter(User.id == user_id).first() if not user: logger.warning( f"user not found id={user_id}", - extra={ - "request_id": request.state.request_id - } + extra={"request_id": request_id} ) raise HTTPException( @@ -103,9 +96,7 @@ def get_user( logger.info( f"user fetched id={user_id}", - extra={ - "request_id": request.state.request_id - } + extra={"request_id": request_id} ) - return user \ No newline at end of file + return UserResponse.model_validate(user) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 15c4e1e..bd51310 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ email-validator pytest pytest-cov alembic -pytest \ No newline at end of file +pytest +httpx \ No newline at end of file diff --git a/test.db b/test.db new file mode 100644 index 0000000000000000000000000000000000000000..10094598c8cc44e0fe0c322dc8c8adf41829ff1a GIT binary patch literal 24576 zcmeI)&yLbS9Ki7@EJT{1dobyBlD*Kxm|cxW6M>!GC1P>ApmJ)etw|$)R$Aho_2xtD zi+S?u6L`~^3Ef@RZ@MU@t5v9x|huq30HqC%tA@2V)kLG3ba&rY41Q0*~0R#|0009ILK;Zur z=+x@op686SS(0947f})CpYkY6Y^--f)gCI@3l7ygc_S+afwUuJD@*(bzr`QV^PF#Q zi}?K{wF$eat?GyEPG6M;ZX-+dj6-#-LKzH(G8pyykEGiuvSO0TH|?-{(hgfs_V@j@ zbdu&D7TI}`&1YAGp6NlRG%w;}ncvvGUON9WOBZ@kcOLe2*n8Cu$MU5b+trx)js~IX z9S3G#t1R|qsE$;qg04D~%RF7=cDU;LWjY*`rs2q1s}0tg_000IagfB*ue0RR6lC5Q+hfB*sr zAb Date: Fri, 19 Jun 2026 16:24:51 +0530 Subject: [PATCH 10/22] test: increase coverage for startup and configuration modules --- test.db | Bin 24576 -> 24576 bytes tests/test_db_session.py | 15 +++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 tests/test_db_session.py diff --git a/test.db b/test.db index 10094598c8cc44e0fe0c322dc8c8adf41829ff1a..715278a7eb10ba9c2a2ac50e03049f8ae83f806e 100644 GIT binary patch delta 37 ncmZoTz}Rqrae|Z(lN1930~-{xGB7YMnW$sL#3Z#bVL?0qh-e2c delta 37 mcmZoTz}Rqrae|Z(qYDEA0~-{x0_j&1b&Qx8T{b2xhz9_Wd Date: Sat, 20 Jun 2026 01:38:06 +0530 Subject: [PATCH 11/22] chore: add makefile for development workflows --- Makefile | 20 ++++++++++++++++++++ make | 0 2 files changed, 20 insertions(+) create mode 100644 make diff --git a/Makefile b/Makefile index e69de29..2a1c5cb 100644 --- a/Makefile +++ b/Makefile @@ -0,0 +1,20 @@ +.PHONY: run test coverage lint format install + +install: + pip install -r requirements.txt + +run: + uvicorn app.main:app --reload + +test: + pytest -v + +coverage: + pytest --cov=app --cov-report=term-missing + +lint: + flake8 app tests + +format: + black . + isort . \ No newline at end of file diff --git a/make b/make new file mode 100644 index 0000000..e69de29 From a3107484daceac510ecc7cc0427c4227372c163b Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Sat, 20 Jun 2026 02:17:29 +0530 Subject: [PATCH 12/22] chore: add development automation scripts --- scripts/check_env.ps1 | 36 ++++++++++++++++++++++++++++++++++++ scripts/run_app.ps1 | 3 +++ scripts/run_coverage.ps1 | 0 scripts/run_tests.ps1 | 3 +++ scripts/setup_dev.ps1 | 5 +++++ test.db | Bin 24576 -> 24576 bytes 6 files changed, 47 insertions(+) create mode 100644 scripts/check_env.ps1 create mode 100644 scripts/run_app.ps1 create mode 100644 scripts/run_coverage.ps1 create mode 100644 scripts/run_tests.ps1 create mode 100644 scripts/setup_dev.ps1 diff --git a/scripts/check_env.ps1 b/scripts/check_env.ps1 new file mode 100644 index 0000000..5f815dc --- /dev/null +++ b/scripts/check_env.ps1 @@ -0,0 +1,36 @@ +Write-Host "" +Write-Host "Environment Validation" +Write-Host "" + +python --version +git --version + +Write-Host "" + +try { + docker --version +} +catch { + Write-Host "Docker Not Installed" +} + +try { + kubectl version --client +} +catch { + Write-Host "Kubectl Not Installed" +} + +try { + helm version +} +catch { + Write-Host "Helm Not Installed" +} + +try { + terraform version +} +catch { + Write-Host "Terraform Not Installed" +} \ No newline at end of file diff --git a/scripts/run_app.ps1 b/scripts/run_app.ps1 new file mode 100644 index 0000000..eeb478e --- /dev/null +++ b/scripts/run_app.ps1 @@ -0,0 +1,3 @@ +Write-Host "Starting FastAPI Application..." + +uvicorn app.main:app --reload \ No newline at end of file diff --git a/scripts/run_coverage.ps1 b/scripts/run_coverage.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/scripts/run_tests.ps1 b/scripts/run_tests.ps1 new file mode 100644 index 0000000..d213f59 --- /dev/null +++ b/scripts/run_tests.ps1 @@ -0,0 +1,3 @@ +Write-Host "Running Test Suite..." + +pytest -v \ No newline at end of file diff --git a/scripts/setup_dev.ps1 b/scripts/setup_dev.ps1 new file mode 100644 index 0000000..0ef4a46 --- /dev/null +++ b/scripts/setup_dev.ps1 @@ -0,0 +1,5 @@ +Write-Host "Installing dependencies..." + +pip install -r requirements.txt + +Write-Host "Setup completed." \ No newline at end of file diff --git a/test.db b/test.db index 715278a7eb10ba9c2a2ac50e03049f8ae83f806e..5abcee876cd7bbfab52975998786f8f1858afd3f 100644 GIT binary patch delta 35 lcmZoTz}Rqrae}m92Ll5G8x*rLFfg%9)G=o4*qE>&9sq{~2WJ2P delta 35 lcmZoTz}Rqrae}m<6axbT8x*rLFfcBesAJ41wJ~8qJOF>^2Pyyn From 3225a375ac179756f688d4df67f5d917634e1c0a Mon Sep 17 00:00:00 2001 From: Aditya Ghyar <101800022+mgaditya-er@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:33:55 +0530 Subject: [PATCH 13/22] docs: add README and architecture documentation --- README.md | 59 ++++- docs/architecture.md | 511 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 docs/architecture.md diff --git a/README.md b/README.md index ca3e49f..8f810f3 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ -# TaskTracker -Production-ready TaskTracker backend built with FastAPI, PostgreSQL, Docker, Kubernetes, Terraform and CI/CD practices. Includes migrations, structured JSON logging, health checks, automated testing and GitHub workflow automation. +# TaskTracker Backend + +TaskTracker is a FastAPI-based backend application for managing users and tasks. + +--- + +## Tech Stack + +- FastAPI +- PostgreSQL +- SQLAlchemy +- Pytest +- Docker (Upcoming) +- Kubernetes (Upcoming) + +--- + +## Features + +### Users + +- Create User +- Get All Users +- Get User By ID +- Duplicate Email Validation + +### Tasks + +- Create Task +- Get All Tasks +- Get Task By ID +- Update Task +- Delete Task +- Owner Validation +- Status Validation + +### Health Checks + +- /healthz +- /readyz + +--- + +## Project Structure + +```text +app/ +├── api/ +├── core/ +├── db/ +├── middleware/ +├── models/ +├── schemas/ +└── services/ + +tests/ +docs/ \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..d919d0e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,511 @@ +# TaskTracker Backend Architecture + +## Overview + +TaskTracker is a REST API backend application built using FastAPI and PostgreSQL. + +The application provides APIs for: + +* User Management +* Task Management +* Health Monitoring +* Readiness Checks + +The architecture follows a layered design to ensure maintainability, scalability, and testability. + +--- + +# High Level Architecture + +```text + +------------------+ + | Client | + | Postman / UI App | + +--------+---------+ + | + v + +------------------+ + | FastAPI API | + +--------+---------+ + | + +--------------------+--------------------+ + | | + v v ++------------------+ +------------------+ +| Request Layer | | Middleware Layer | +| Routers | | Request ID | ++--------+---------+ +--------+---------+ + | | + +----------------+--------------------+ + | + v + +------------------+ + | Service Layer | + +--------+---------+ + | + v + +------------------+ + | Database Layer | + | SQLAlchemy ORM | + +--------+---------+ + | + v + +------------------+ + | PostgreSQL | + +------------------+ +``` + +--- + +# Project Structure + +```text +TaskTracker/ +│ +├── app/ +│ ├── api/ +│ │ ├── health.py +│ │ ├── ready.py +│ │ ├── users.py +│ │ └── tasks.py +│ │ +│ ├── core/ +│ │ ├── config.py +│ │ ├── logger.py +│ │ └── startup.py +│ │ +│ ├── db/ +│ │ ├── base.py +│ │ └── session.py +│ │ +│ ├── middleware/ +│ │ └── request_id.py +│ │ +│ ├── models/ +│ │ ├── user.py +│ │ └── task.py +│ │ +│ ├── schemas/ +│ │ ├── user.py +│ │ └── task.py +│ │ +│ ├── services/ +│ │ ├── user_service.py +│ │ └── task_service.py +│ │ +│ └── main.py +│ +├── tests/ +├── docs/ +│ └── architecture.md +│ +├── requirements.txt +├── README.md +└── Makefile +``` + +--- + +# Component Description + +## API Layer + +Location: + +```text +app/api/ +``` + +Responsibilities: + +* Receive HTTP requests +* Validate request payloads +* Call business logic +* Return API responses + +Endpoints: + +### User APIs + +```text +POST /users +GET /users +GET /users/{id} +``` + +### Task APIs + +```text +POST /tasks +GET /tasks +GET /tasks/{id} +PUT /tasks/{id} +DELETE /tasks/{id} +``` + +### Monitoring APIs + +```text +GET /healthz +GET /readyz +``` + +--- + +## Schema Layer + +Location: + +```text +app/schemas/ +``` + +Responsibilities: + +* Request validation +* Response serialization +* Input sanitization + +Examples: + +```python +UserCreate +UserResponse + +TaskCreate +TaskUpdate +TaskResponse +``` + +--- + +## Model Layer + +Location: + +```text +app/models/ +``` + +Responsibilities: + +* Database table definitions +* ORM mapping + +### User Table + +```text +users +``` + +Fields: + +```text +id +name +email +``` + +### Task Table + +```text +tasks +``` + +Fields: + +```text +id +title +description +status +owner_id +``` + +--- + +## Database Layer + +Location: + +```text +app/db/ +``` + +Responsibilities: + +* Database connection +* Session management +* Transaction handling + +Files: + +### base.py + +Responsible for: + +```text +SQLAlchemy Base Declaration +``` + +### session.py + +Responsible for: + +```text +Database Engine +SessionLocal +Dependency Injection +``` + +--- + +## Middleware Layer + +Location: + +```text +app/middleware/ +``` + +Responsibilities: + +* Request Tracking +* Request ID Generation + +Example: + +```text +X-Request-ID +``` + +Benefits: + +* Easier debugging +* Log correlation +* Distributed tracing support + +--- + +## Logging Layer + +Location: + +```text +app/core/logger.py +``` + +Responsibilities: + +* Structured logging +* Error logging +* Request tracing + +Example Log: + +```json +{ + "timestamp": "2026-01-01T10:00:00", + "level": "INFO", + "message": "task created", + "request_id": "abc123" +} +``` + +--- + +# Database Design + +## Entity Relationship Diagram + +```text ++---------+ +| Users | ++---------+ +| id | +| name | +| email | ++----+----+ + | + | + | One-to-Many + | + v ++------------+ +| Tasks | ++------------+ +| id | +| title | +| description| +| status | +| owner_id | ++------------+ +``` + +Relationship: + +```text +One User +can own +Many Tasks +``` + +--- + +# Request Flow + +## Create Task Flow + +```text +Client + | + v +POST /tasks + | + v +TaskCreate Schema Validation + | + v +Owner Validation + | + v +Task Model Creation + | + v +Database Commit + | + v +Response Returned +``` + +--- + +# Error Handling + +Supported Errors: + +## Validation Error + +```text +400 Bad Request +``` + +Example: + +```json +{ + "detail": "Invalid task status" +} +``` + +--- + +## Resource Not Found + +```text +404 Not Found +``` + +Example: + +```json +{ + "detail": "Task not found" +} +``` + +--- + +## Duplicate Resource + +```text +409 Conflict +``` + +Example: + +```json +{ + "detail": "Email already exists" +} +``` + +--- + +# Testing Strategy + +Framework: + +```text +pytest +``` + +Coverage Areas: + +* User APIs +* Task APIs +* Validation Rules +* Error Handling +* Health Checks +* Readiness Checks + +Current Target: + +```text +80%+ +``` + +--- + +# Deployment Architecture (Future) + +```text + Internet + | + v + +----------------+ + | Load Balancer | + +-------+--------+ + | + v + Kubernetes + | + +---------------+---------------+ + | | + v v ++-------------+ +-------------+ +| FastAPI Pod | | FastAPI Pod | ++------+------+ +------+------+ + | | + +---------------+---------------+ + | + v + PostgreSQL + | + v + Storage +``` + +Future Additions: + +* Docker +* Kubernetes +* Alembic +* CI/CD Pipeline +* Redis Cache +* JWT Authentication +* Monitoring & Alerting + +--- + +# Design Principles + +The application follows: + +* Separation of Concerns +* Dependency Injection +* Layered Architecture +* RESTful API Design +* Test-Driven Development +* Structured Logging +* Scalability First Approach From e85868c1b26862554bca4c64f250761bd6f5acd2 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar <101800022+mgaditya-er@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:55:07 +0530 Subject: [PATCH 14/22] Feature/docker ci pipeline (#11) * ci: add GitHub Actions workflow * ci: update requirement.txt * ci: update requirement.txt --------- Co-authored-by: Aditya Ghyar --- .dockerignore | 11 +++++++ .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++++++++ Dockerfile | 21 +++++++++++++ docker-compose.yml | 26 ++++++++++++++++ docs/pipeline.md | 24 +++++++++++++++ requirements-dev.txt | 5 +++ requirements.txt | 9 +++--- 7 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 docs/pipeline.md create mode 100644 requirements-dev.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..76bfd39 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +venv +__pycache__ +.pytest_cache +tests +htmlcov +.coverage +.env +docs +*.pyc \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e69de29..12029b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: TaskTracker CI + +on: + push: + branches: + - main + - develop + - feature/** + pull_request: + branches: + - main + - develop + +jobs: + test: + + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: tasktracker + + ports: + - 5432:5432 + + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/tasktracker + APP_PORT: 8000 + LOG_LEVEL: INFO + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Tests + run: | + pytest -v + + - name: Generate Coverage Report + run: | + pytest --cov=app --cov-report=term-missing + + - name: Build Docker Image + run: | + docker build -t tasktracker:latest . \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e69de29..a6bbfa5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12.10-slim-bookworm AS builder + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +FROM python:3.12.10-slim-bookworm + +RUN useradd -m app + +WORKDIR /app + +COPY --from=builder /install /usr/local +COPY app ./app + +USER app + +EXPOSE 8000 + +CMD ["uvicorn","app.main:app","--host","0.0.0.0","--port","8000"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e69de29..e96c888 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: "3.9" + +services: + + postgres: + image: postgres:16 + container_name: tasktracker-postgres + restart: always + environment: + POSTGRES_DB: tasktracker + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + + api: + build: . + container_name: tasktracker-api + depends_on: + - postgres + environment: + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/tasktracker + APP_PORT: 8000 + LOG_LEVEL: INFO + ports: + - "8000:8000" \ No newline at end of file diff --git a/docs/pipeline.md b/docs/pipeline.md new file mode 100644 index 0000000..dae6608 --- /dev/null +++ b/docs/pipeline.md @@ -0,0 +1,24 @@ +# CI/CD Pipeline Documentation + +## Overview + +The TaskTracker project uses GitHub Actions for Continuous Integration. + +Pipeline executes automatically on: + +- Push to feature branches +- Push to develop +- Push to main +- Pull Requests + +--- + +## Pipeline Stages + +### 1. Checkout + +Downloads repository source code. + +```yaml +uses: actions/checkout@v4 +``` \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ecb310f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +pytest +pytest-cov +httpx \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index bd51310..c72d4a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,12 @@ -fastapi -uvicorn -sqlalchemy +fastapi==0.116.1 +uvicorn==0.35.0 +sqlalchemy==2.0.41 psycopg2-binary pydantic pydantic-settings python-dotenv email-validator -pytest -pytest-cov alembic +pytest-cov pytest httpx \ No newline at end of file From 7bef0f18586a25d7882206fc605c5a59b62c81d9 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar <101800022+mgaditya-er@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:17:58 +0530 Subject: [PATCH 15/22] Feature/docker ci pipeline (#12) * ci: add GitHub Actions workflow * ci: update requirement.txt * ci: update requirement.txt * ci : publish docker image from main and dev branch --------- Co-authored-by: Aditya Ghyar --- .github/workflows/ci.yml | 45 +++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12029b1..d81bc37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,21 +13,17 @@ on: jobs: test: - runs-on: ubuntu-latest services: postgres: image: postgres:16 - env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: tasktracker - ports: - 5432:5432 - options: >- --health-cmd="pg_isready -U postgres" --health-interval=10s @@ -40,27 +36,38 @@ jobs: LOG_LEVEL: INFO steps: - - name: Checkout Repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Install Dependencies + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + python -m pip install -r requirements.txt - - name: Run Tests - run: | - pytest -v + - name: Run tests + run: python -m pytest -v - - name: Generate Coverage Report - run: | - pytest --cov=app --cov-report=term-missing + - name: Generate coverage report + run: python -m pytest --cov=app --cov-report=term-missing - - name: Build Docker Image - run: | - docker build -t tasktracker:latest . \ No newline at end of file + publish: + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/tasktracker:latest From 65c78c8fd981bf1e640a70d8f66786de0c707312 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Wed, 8 Jul 2026 21:24:21 +0530 Subject: [PATCH 16/22] docs: update README with Docker and CI/CD details --- README.md | 50 ++++++++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 8f810f3..3c6de83 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # TaskTracker Backend -TaskTracker is a FastAPI-based backend application for managing users and tasks. +TaskTracker is a production-ready backend API built with FastAPI for managing users and tasks, featuring automated testing, containerization, and CI/CD. --- ## Tech Stack -- FastAPI -- PostgreSQL -- SQLAlchemy -- Pytest -- Docker (Upcoming) -- Kubernetes (Upcoming) +* FastAPI +* PostgreSQL +* SQLAlchemy +* Alembic +* Pytest & pytest-cov +* Docker & Docker Compose +* GitHub Actions --- @@ -19,25 +20,28 @@ TaskTracker is a FastAPI-based backend application for managing users and tasks. ### Users -- Create User -- Get All Users -- Get User By ID -- Duplicate Email Validation +* Create and retrieve users +* Email uniqueness validation ### Tasks -- Create Task -- Get All Tasks -- Get Task By ID -- Update Task -- Delete Task -- Owner Validation -- Status Validation +* Create, retrieve, update, and delete tasks +* Filter tasks by owner and status +* Input and ownership validation -### Health Checks +### Health Endpoints -- /healthz -- /readyz +* `/healthz` +* `/readyz` + +--- + +## DevOps Highlights + +* Multi-stage Docker image running as a non-root user +* Docker Compose setup with PostgreSQL health checks +* Automated testing and coverage reporting via GitHub Actions +* Automatic Docker Hub image publishing after successful CI --- @@ -54,4 +58,6 @@ app/ └── services/ tests/ -docs/ \ No newline at end of file +docs/ +.github/workflows/ +``` From 3f8113791b006300fdbd77828f329d1a1b03b9bd Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Wed, 8 Jul 2026 21:28:19 +0530 Subject: [PATCH 17/22] ci : publish docker image from main and dev branch --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d81bc37..0860149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,13 @@ on: branches: - main - develop + - dev - feature/** pull_request: branches: - main - develop + - dev jobs: test: From c84cf98b896fefa4da8a750d496d2b3c1a7566ef Mon Sep 17 00:00:00 2001 From: Aditya Ghyar <101800022+mgaditya-er@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:01:50 +0530 Subject: [PATCH 18/22] Docs/readme and architecture (#13) * docs: add architecture documentation * docs: update pipeline documentation --------- Co-authored-by: Aditya Ghyar --- docs/pipeline.md | 59 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/docs/pipeline.md b/docs/pipeline.md index dae6608..c4e8d1e 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -2,23 +2,60 @@ ## Overview -The TaskTracker project uses GitHub Actions for Continuous Integration. +TaskTracker uses GitHub Actions to automate testing, coverage reporting, and Docker image publishing. The workflow runs on pushes to the configured development and feature branches, as well as on pull requests. -Pipeline executes automatically on: - -- Push to feature branches -- Push to develop -- Push to main -- Pull Requests +The pipeline executes automatically on: +- Pushes to feature branches +- Pushes to `develop` +- Pushes to `main` +- Pull requests targeting the integration branches --- ## Pipeline Stages + ### 1. Checkout -Downloads repository source code. +The workflow checks out the latest version of the source code. + +### 2. Environment Setup + +Python 3.12 is installed, and all project dependencies are restored from `requirements.txt`. + +### 3. Testing + +The complete Pytest test suite is executed against a PostgreSQL 16 service container. + +### 4. Coverage + +Code coverage is generated using `pytest-cov` to ensure adequate test coverage. + +### 5. Image Publishing + +After all tests pass, the Docker image is built and pushed to Docker Hub from the configured release branches. + +--- + +## Required Secrets + +The following repository secrets must be configured: + +* `DOCKERHUB_USERNAME` +* `DOCKERHUB_TOKEN` + +--- + +## Published Image + +The latest image is available as: + +`/tasktracker:latest` + +Replace `` with your actual Docker Hub username. + +--- + +## Monitoring the Pipeline -```yaml -uses: actions/checkout@v4 -``` \ No newline at end of file +Workflow runs and logs are available in the **Actions** tab of the GitHub repository. From 5a9be4f039949de1096d51f8a43772a1787b33a5 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar <101800022+mgaditya-er@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:26:12 +0530 Subject: [PATCH 19/22] build: separate test and runtime Dockerfiles (#14) Co-authored-by: Aditya Ghyar --- .dockerignore | 3 ++- Dockerfile | 4 +++- Dockerfile.test | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 Dockerfile.test diff --git a/.dockerignore b/.dockerignore index 76bfd39..1d5e5ef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,5 @@ htmlcov .coverage .env docs -*.pyc \ No newline at end of file +*.pyc +!tests/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a6bbfa5..ea79268 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,8 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt +COPY app ./app + FROM python:3.12.10-slim-bookworm RUN useradd -m app @@ -18,4 +20,4 @@ USER app EXPOSE 8000 -CMD ["uvicorn","app.main:app","--host","0.0.0.0","--port","8000"] \ No newline at end of file +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..cf76478 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,15 @@ +FROM python:3.12.10-slim-bookworm + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY tests ./tests +COPY pytest.ini* ./ +ENV DATABASE_URL=sqlite:///./test.db \ + APP_PORT=8000 \ + LOG_LEVEL=INFO + +RUN python -m pytest -v \ No newline at end of file From 2f1e13818a2108f1a7280d6810dfa548ca8afb01 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Thu, 9 Jul 2026 14:20:45 +0530 Subject: [PATCH 20/22] ci: add lint, coverage, and Trivy quality gates --- .github/workflows/ci.yml | 6 ++++++ requirements.txt | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0860149..f0a4c9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,12 @@ jobs: - name: Run tests run: python -m pytest -v + - name: Lint with Ruff + run: ruff check . + + - name: Run tests with coverage gate + run: python -m pytest --cov=app --cov-report=term-missing --cov-fail-under=80 + - name: Generate coverage report run: python -m pytest --cov=app --cov-report=term-missing diff --git a/requirements.txt b/requirements.txt index c72d4a8..8b9fa60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ email-validator alembic pytest-cov pytest -httpx \ No newline at end of file +httpx +Ruff \ No newline at end of file From b6b31b248c2b0585636c37af8ae40f2f1859ae40 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Thu, 9 Jul 2026 14:21:24 +0530 Subject: [PATCH 21/22] ci: add lint, coverage, and Trivy quality gates --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0a4c9f..ff352ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,9 +57,7 @@ jobs: - name: Run tests with coverage gate run: python -m pytest --cov=app --cov-report=term-missing --cov-fail-under=80 - - - name: Generate coverage report - run: python -m pytest --cov=app --cov-report=term-missing + publish: if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') From 206573c0a2e416c0bb84a27c38e42083b1861247 Mon Sep 17 00:00:00 2001 From: Aditya Ghyar Date: Thu, 9 Jul 2026 14:39:50 +0530 Subject: [PATCH 22/22] ci: add lint, coverage, and Trivy quality gates --- alembic/env.py | 2 -- pyproject.toml | 2 ++ tests/test_tasks.py | 1 - tests/test_users.py | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) create mode 100644 pyproject.toml diff --git a/alembic/env.py b/alembic/env.py index e27b8d7..8b817c8 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -40,8 +40,6 @@ # target_metadata = None # replacing with the actual metadata from models from app.db.base import Base -from app.models.user import User -from app.models.task import Task target_metadata = Base.metadata diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2bebdde --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.ruff.lint.per-file-ignores] +"alembic/env.py" = ["E402", "F401"] \ No newline at end of file diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 3a4d009..dd06af5 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1,4 +1,3 @@ -import pytest def create_user(client): response = client.post( diff --git a/tests/test_users.py b/tests/test_users.py index 4421cd1..d390cc9 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -1,4 +1,3 @@ -import pytest def test_create_user(client):